bottleneckcrm/mobile/lib/data/models/org_settings.dart
Ashwin 8eb68b5431 feat: implement invoice and estimate numbering settings (#802)
- Added fields for invoice and estimate number prefixes, date parts, padding, and starting numbers in the organization settings.
- Updated the organization edit and display screens to show examples of new invoice and estimate numbers based on the selected settings.
- Introduced validation for number prefixes and padding, ensuring compliance with server requirements.
- Implemented tests for the new numbering functionality, covering various scenarios including prefix validation and starting number rules.
- Enhanced the mobile app to reflect these changes in the organization settings and display screens.
2026-10-15 19:38:02 +05:30

492 lines
18 KiB
Dart

/// The organization's own settings: the company profile printed on documents,
/// the locale defaults, and the two org-wide case-handling switches.
///
/// `GET/PATCH /api/org/settings/` always acts on `request.profile.org`. **There
/// is no org id in the URL**, so a caller can only ever read or edit their own
/// org. That is the tenant barrier, and there is nothing here to scope by hand.
///
/// The org API key is absent from this payload entirely. It authenticates as
/// the org's first admin, so it is excluded from `OrgSettingsSerializer` and
/// rotated only through `OrgApiKeyView`. `is_active`, the org kill switch, is
/// likewise not editable here.
///
/// The read is open to any member; the PATCH is admin-only and the backend is
/// what refuses. `frontend/src/lib/server/v2/organization.js` carries the same
/// allow-list.
library;
import 'countries.dart';
/// Text, select and number fields the edit form may submit.
///
/// An allow-list, so a field the form never offered cannot be smuggled into a
/// body, and so `api_key`, `is_active`, `id`, `vertical` and `terminology` are
/// never sent from a client even by accident.
const List<String> editableOrgFields = [
'name',
'company_name',
'address_line',
'city',
'state',
'postcode',
'country',
'phone',
'email',
'website',
'tax_id',
'default_currency',
'default_country',
'timezone',
// Invoice and estimate numbering (#802).
'invoice_number_prefix',
'estimate_number_prefix',
'number_date_part',
'number_padding',
'invoice_number_start',
];
/// The two org-wide behaviour switches. Kept apart from the text fields because
/// they are booleans and always travel explicitly: "off" is a real value, not
/// the "absent, so leave alone" convention a blank text field uses.
const List<String> orgBooleanFields = [
'csat_enabled',
'auto_close_children_on_parent_close',
];
class OrgSettings {
const OrgSettings({
this.id = '',
this.name = '',
this.companyName = '',
this.addressLine = '',
this.city = '',
this.state = '',
this.postcode = '',
this.country = '',
this.phone = '',
this.email = '',
this.website = '',
this.taxId = '',
this.defaultCurrency = 'USD',
this.defaultCountry = '',
this.currencySymbol = '\$',
this.timezone = 'UTC',
this.csatEnabled = true,
this.autoCloseChildren = false,
this.vertical = '',
this.invoiceNumberPrefix = 'INV-',
this.estimateNumberPrefix = 'EST-',
this.numberDatePart = 'DAY',
this.numberPadding = 4,
this.invoiceNumberStart = 1,
this.logoUrl,
this.memberCount = 0,
this.createdAt,
});
final String id;
/// What the org is called across the app. `company_name` is the legal name
/// printed on a document; these are deliberately two fields.
final String name;
final String companyName;
final String addressLine;
final String city;
final String state;
final String postcode;
final String country;
final String phone;
final String email;
final String website;
final String taxId;
final String defaultCurrency;
final String defaultCountry;
final String currencySymbol;
final String timezone;
/// Org-level kill switch. False short-circuits the post-close signal before
/// any survey email is sent, org-wide, with no per-team exception.
final bool csatEnabled;
/// The DEFAULT state of the cascade checkbox when a parent ticket is closed.
/// See [cascadeDefaultExplanation]: it decides how the prompt starts, not
/// what happens without one.
final bool autoCloseChildren;
/// Which vertical pack was applied. Written by the pack applier server-side
/// and read-only here.
final String vertical;
/// How new invoice and estimate numbers look (#802): the prefix, then the
/// date and a hyphen unless [numberDatePart] is `NONE`, then a counter
/// padded to [numberPadding] digits. The defaults are the format every org
/// had before this was a setting. See [exampleNumber].
final String invoiceNumberPrefix;
final String estimateNumberPrefix;
final String numberDatePart;
final int numberPadding;
/// The lowest counter the next invoice may take. Undated numbers only.
final int invoiceNumberStart;
final String? logoUrl;
final int memberCount;
final DateTime? createdAt;
factory OrgSettings.fromJson(Map<String, dynamic> json) => OrgSettings(
id: json['id']?.toString() ?? '',
name: _text(json['name']),
companyName: _text(json['company_name']),
addressLine: _text(json['address_line']),
city: _text(json['city']),
state: _text(json['state']),
postcode: _text(json['postcode']),
country: _text(json['country']),
phone: _text(json['phone']),
email: _text(json['email']),
website: _text(json['website']),
taxId: _text(json['tax_id']),
defaultCurrency: json['default_currency']?.toString() ?? 'USD',
defaultCountry: _text(json['default_country']),
currencySymbol: json['currency_symbol']?.toString() ?? '\$',
timezone: json['timezone']?.toString() ?? 'UTC',
csatEnabled: json['csat_enabled'] as bool? ?? true,
autoCloseChildren:
json['auto_close_children_on_parent_close'] as bool? ?? false,
vertical: _text(json['vertical']),
invoiceNumberPrefix: json['invoice_number_prefix']?.toString() ?? 'INV-',
estimateNumberPrefix: json['estimate_number_prefix']?.toString() ?? 'EST-',
numberDatePart: json['number_date_part']?.toString() ?? 'DAY',
numberPadding: _intOr(json['number_padding'], 4),
invoiceNumberStart: _intOr(json['invoice_number_start'], 1),
logoUrl: json['logo_url']?.toString(),
memberCount: _intOr(json['member_count'], 0),
createdAt: DateTime.tryParse(
json['created_at']?.toString() ?? '',
)?.toLocal(),
);
/// The postal address on one line, in the order a label prints it.
String get addressSummary => [
addressLine,
city,
state,
postcode,
country,
].where((part) => part.trim().isNotEmpty).join(', ');
/// The timezone with its underscores opened out, for reading.
String get timezoneLabel => timezone.replaceAll('_', ' ');
}
String _text(dynamic value) => value?.toString() ?? '';
int _intOr(dynamic value, int fallback) {
if (value is int) return value;
if (value is num) return value.round();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
/// One IANA zone as `/api/org/timezones/` sends it.
///
/// Served rather than read from the device because the two vocabularies
/// disagree: a phone reports `Asia/Calcutta` where the server also knows
/// `Asia/Kolkata`, and a picker that cannot find the stored value would offer
/// to move the org somewhere it never chose.
class TimezoneOption {
const TimezoneOption({required this.name, required this.offsetMinutes});
final String name;
final int offsetMinutes;
factory TimezoneOption.fromJson(Map<String, dynamic> json) => TimezoneOption(
name: json['name']?.toString() ?? 'UTC',
offsetMinutes: _intOr(json['offset_minutes'], 0),
);
/// "Asia/Kolkata (UTC+05:30)". The offset is what makes a 490-entry list
/// readable.
String get label =>
'${name.replaceAll('_', ' ')} (${_offset(offsetMinutes)})';
}
String _offset(int minutes) {
if (minutes == 0) return 'UTC';
final sign = minutes < 0 ? '-' : '+';
final abs = minutes.abs();
final hh = (abs ~/ 60).toString().padLeft(2, '0');
final mm = (abs % 60).toString().padLeft(2, '0');
return 'UTC$sign$hh:$mm';
}
/// The body for `PATCH /api/org/settings/`.
///
/// Every field goes every time, because this form edits them all at once and a
/// partial body would make "cleared" and "not sent" the same thing. The two
/// booleans go as real booleans for the same reason.
///
/// [invoiceNumberStart] goes as 1 whenever [numberDatePart] dates the number:
/// the server refuses a start with a date, including one still stored from
/// undated numbering, because a dated count restarts each period.
///
/// Nothing read-only is included: no `id`, no `api_key` (which the payload has
/// never carried), no `is_active`, no `vertical`, no `terminology`,
/// no `member_count`.
Map<String, dynamic> orgSettingsPayload({
required String name,
required String companyName,
required String addressLine,
required String city,
required String state,
required String postcode,
required String country,
required String phone,
required String email,
required String website,
required String taxId,
required String defaultCurrency,
required String defaultCountry,
required String timezone,
required bool csatEnabled,
required bool autoCloseChildren,
required String invoiceNumberPrefix,
required String estimateNumberPrefix,
required String numberDatePart,
required int numberPadding,
required int invoiceNumberStart,
}) => {
'name': name.trim(),
'company_name': companyName.trim(),
'address_line': addressLine.trim(),
'city': city.trim(),
'state': state.trim(),
'postcode': postcode.trim(),
'country': country.trim(),
'phone': phone.trim(),
'email': email.trim(),
'website': website.trim(),
'tax_id': taxId.trim(),
'default_currency': defaultCurrency.trim(),
'default_country': defaultCountry.trim(),
'timezone': timezone.trim(),
'csat_enabled': csatEnabled,
'auto_close_children_on_parent_close': autoCloseChildren,
'invoice_number_prefix': invoiceNumberPrefix.trim(),
'estimate_number_prefix': estimateNumberPrefix.trim(),
'number_date_part': numberDatePart,
'number_padding': numberPadding,
'invoice_number_start': numberDatePart == 'NONE' ? invoiceNumberStart : 1,
};
/// What an org save has to fix before it is worth sending, or null.
///
/// The serializer is the rule; these two exist so a typo answers immediately.
/// `email` is an `EmailField` and `website` a `URLField`, both of which reject
/// on the server with a message that names the field.
String? orgSettingsProblem({required String email, required String website}) {
final mail = email.trim();
if (mail.isNotEmpty &&
!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(mail)) {
return 'That does not look like an email address.';
}
final site = website.trim();
if (site.isNotEmpty && !RegExp(r'^https?://.+\..+').hasMatch(site)) {
return 'Include the full website address, starting with http:// or https://.';
}
return null;
}
/// One entry in a picker.
typedef PickerOption = ({String value, String label});
/// `Org.NUMBER_DATE_PARTS`, labelled as the web's org form labels them.
const List<PickerOption> numberDatePartOptions = [
(value: 'NONE', label: 'No date, one running count'),
(value: 'YEAR', label: 'Year, count restarts each year'),
(value: 'MONTH', label: 'Year and month, count restarts each month'),
(value: 'DAY', label: 'Full date, count restarts each day'),
];
/// How many digits of `YYYYMMDD` each date part keeps.
const Map<String, int> _dateDigits = {
'NONE': 0,
'YEAR': 4,
'MONTH': 6,
'DAY': 8,
};
/// What a new number looks like: the series and its first counter. A preview
/// only, as on the web (`$lib/v2/document-numbers.js`); the server assigns the
/// real number on save, counting what the org has already issued.
///
/// [start] counts for undated numbers only, as on the server. [padding] is
/// clamped to the server's 1 to 10, so a width still being typed cannot build
/// an enormous string.
String exampleNumber({
required String prefix,
required String datePart,
required int padding,
required DateTime today,
int start = 1,
}) {
final ymd =
'${today.year.toString().padLeft(4, '0')}'
'${today.month.toString().padLeft(2, '0')}'
'${today.day.toString().padLeft(2, '0')}';
final digits = ymd.substring(0, _dateDigits[datePart] ?? 8);
final series = digits.isEmpty ? prefix : '$prefix$digits-';
final counter = datePart == 'NONE' ? start : 1;
return '$series${counter.toString().padLeft(padding.clamp(1, 10), '0')}';
}
/// The server's prefix rule (`number_prefix_validator`): a number becomes a
/// PDF filename and an email subject, so nothing that means something in a
/// header or a path.
final RegExp _numberPrefix = RegExp(r'^[A-Za-z0-9_-]*$');
/// What the numbering section has to fix before a save is worth sending, or
/// null. The serializer is the rule; this answers a typo immediately. The
/// start is only checked when it will be sent, which is when [datePart] is
/// `NONE`.
String? numberingProblem({
required String invoicePrefix,
required String estimatePrefix,
required String datePart,
required String padding,
required String start,
}) {
if (!_numberPrefix.hasMatch(invoicePrefix.trim()) ||
!_numberPrefix.hasMatch(estimatePrefix.trim())) {
return 'A number prefix can use letters, numbers, hyphens and underscores '
'only.';
}
final width = int.tryParse(padding.trim());
if (width == null || width < 1 || width > 10) {
return 'Counter digits must be from 1 to 10.';
}
final first = int.tryParse(start.trim());
if (datePart == 'NONE' && (first == null || first < 1 || first > 999999999)) {
return 'Start invoices at a whole number from 1 to 999,999,999.';
}
return null;
}
/// The countries the org form offers: every code the backend `COUNTRIES` set
/// accepts, as the web's org form does, so any stored country has an option.
final List<PickerOption> orgCountryOptions = [
for (final c in kCountries) (value: c.code, label: c.label),
];
/// [options], guaranteed to contain [current].
///
/// **A picker that cannot display the stored value is a data-loss bug.** A
/// Flutter dropdown whose value matches no item throws outright, and the usual
/// repair, quietly falling back to the first entry, saves a value the org never
/// chose. The stored value is a code the server accepted, so the honest move is
/// to keep offering it. Used for country, currency and time zone, because an
/// installed build can lag the server: a code the backend added after the build
/// shipped (ZAR was one) must still round-trip unchanged.
List<PickerOption> withCurrent(List<PickerOption> options, String current) {
final value = current.trim();
if (value.isEmpty) return options;
if (options.any((option) => option.value == value)) return options;
return [...options, (value: value, label: value)];
}
/// What the CSAT switch really does, in the words the screen uses.
String csatExplanation(bool enabled) => enabled
? 'A survey goes out after a ticket closes. Turning this off stops every '
'survey org-wide, with no per-team exception.'
: 'No surveys are sent, anywhere in this organization. Nothing on a ticket '
'says so, and there is no per-team exception.';
/// What the cascade switch really does, which is narrower than it looks.
///
/// **It sets how the close prompt starts, and only this app has that prompt.**
/// `CaseCloseWithChildrenView` reads
/// `Org.auto_close_children_on_parent_close` only when the caller omits
/// `cascade` entirely; both clients send it explicitly, so the setting reaches
/// a person through the checkbox and nowhere else. The web app has no such
/// prompt at all: closing a parent there leaves its children open. Saying "it
/// controls whether children close" would describe neither client.
String cascadeDefaultExplanation(bool enabled) => enabled
? 'Closing a parent ticket here starts with "also close linked tickets" '
'ticked. You still confirm, and can untick it.'
: 'Closing a parent ticket here starts with "also close linked tickets" '
'unticked. You still confirm, and can tick it.';
/// The footnote both organization screens carry about the cascade switch.
const String cascadeWebGapNote =
'The web app has no close prompt yet, so closing a parent there leaves its '
'children open whatever this is set to.';
/// A vertical pack as `/api/packs/` lists it.
class VerticalPack {
const VerticalPack({
required this.id,
required this.name,
this.description = '',
this.version = '',
});
final String id;
final String name;
final String description;
final String version;
factory VerticalPack.fromJson(Map<String, dynamic> json) => VerticalPack(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
description: json['description']?.toString() ?? '',
version: json['version']?.toString() ?? '',
);
}
/// What an apply actually did.
///
/// A pack applied to an org that already has some of this configured is the
/// normal case, not a partial failure, so both halves are reported.
class PackApplyReport {
const PackApplyReport({this.created = 0, this.skipped = 0});
final int created;
final int skipped;
factory PackApplyReport.fromJson(Map<String, dynamic> json) {
final report = (json['report'] as Map?)?.cast<String, dynamic>() ?? json;
return PackApplyReport(
created: (report['created'] as List?)?.length ?? 0,
skipped: (report['skipped'] as List?)?.length ?? 0,
);
}
String get summary {
if (created == 0 && skipped == 0) {
return 'Nothing to add. This org already has all of it.';
}
if (skipped == 0) return 'Created $created item${created == 1 ? '' : 's'}.';
if (created == 0) {
return 'Already had everything from this pack, skipped $skipped '
'item${skipped == 1 ? '' : 's'}.';
}
return 'Created $created item${created == 1 ? '' : 's'}, skipped $skipped '
'you already had.';
}
}
/// What a sample-data clear actually deleted, and what it kept.
///
/// Retained rows are not a failure: they are demo records the org has since
/// attached real work to, which the applier deliberately keeps because deleting
/// one would take that work with it.
String sampleDataResult({required int deleted, required int retained}) {
final head = deleted == 0
? 'No sample data to clear.'
: 'Deleted $deleted sample record${deleted == 1 ? '' : 's'}.';
if (retained == 0) return head;
return '$head Kept $retained record${retained == 1 ? '' : 's'} you have '
'since attached real work to.';
}