bottleneckcrm/mobile/lib/data/models/invoice.dart
Ashwin ed4263a534 Refactor tests and add new functionality for ticket closing and mailbox handling
- Updated phone viewport test to check for new text related to SNS topics.
- Enhanced audit log screen tests with new credential events and filters.
- Modified mailbox form sheet tests to handle SNS Topic ARN correctly.
- Introduced new tests for ticket form close date functionality, ensuring correct behavior for optional close dates.
- Added support for organization timezone handling in tests, ensuring accurate date calculations based on user organization.
- Improved unit tests for mailbox and organization date handling, ensuring proper behavior for various scenarios.
- Implemented bulk close date functionality in ticket selection tests, verifying correct behavior for date selection and submission.
2026-09-27 11:43:51 +05:30

428 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../services/org_date.dart';
import 'deal.dart' show Currency;
/// The eight values `invoices.models.INVOICE_STATUS` accepts.
///
/// Stored exactly as the server spells them, underscore and all, because the
/// `?status=` filter matches on the stored value. `Partially_Paid` is the one
/// that reads wrong to a human, so the label is separate from the value.
///
/// Carries its own colour, as `LeadStatus` and `TicketStatus` do, so the
/// existing `StatusBadge` can draw it without a second palette. Red is money
/// at risk, green is settled, grey is not yet in play.
enum InvoiceStatus {
draft('Draft', 'Draft', AppColors.gray500),
sent('Sent', 'Sent', AppColors.primary600),
viewed('Viewed', 'Viewed', AppColors.primary600),
paid('Paid', 'Paid', AppColors.success600),
partiallyPaid('Partially_Paid', 'Partially paid', AppColors.warning600),
overdue('Overdue', 'Overdue', AppColors.danger600),
pending('Pending', 'Pending', AppColors.warning600),
cancelled('Cancelled', 'Cancelled', AppColors.gray500);
const InvoiceStatus(this.value, this.label, this.color);
final String value;
final String label;
final Color color;
/// An unknown status keeps its server spelling rather than collapsing to a
/// default. A new status added to the backend should look unfamiliar here,
/// not silently render as "Draft".
static InvoiceStatus? fromString(String? raw) {
if (raw == null) return null;
for (final status in InvoiceStatus.values) {
if (status.value == raw) return status;
}
return null;
}
}
/// Money is settled on these, so the due date stops meaning anything.
const _settled = {InvoiceStatus.paid, InvoiceStatus.cancelled};
/// One invoice.
///
/// Built from two serializers that overlap but do not match. The list
/// (`InvoiceListSerializer`) sends `account` as a bare uuid with the name
/// beside it in `account_name`; the detail (`InvoiceSerializer`) nests the
/// whole account object and drops `account_name` entirely. `fromJson` reads
/// both, so a row and a detail produce the same shape and `accountName` is
/// never the literal text of a map.
class Invoice {
const Invoice({
required this.id,
required this.invoiceNumber,
this.title = '',
this.status,
this.rawStatus = '',
this.accountId,
this.accountName,
this.contactName,
this.clientName = '',
this.clientEmail = '',
this.issueDate,
this.dueDate,
this.subtotal = 0,
this.discountAmount = 0,
this.taxAmount = 0,
this.shippingAmount = 0,
this.totalAmount = 0,
this.amountPaid = 0,
this.amountDue = 0,
this.currency,
this.isOverdue = false,
this.lineItemsCount = 0,
this.lineItems = const [],
this.payments = const [],
this.notes,
this.terms,
this.poNumber,
});
final String id;
final String invoiceNumber;
final String title;
/// Null when the server sent a status this build does not know.
final InvoiceStatus? status;
/// What the server actually said, kept so an unknown status can still be
/// shown and still round-trips through the `?status=` filter.
final String rawStatus;
final String? accountId;
final String? accountName;
final String? contactName;
final String clientName;
final String clientEmail;
final DateTime? issueDate;
final DateTime? dueDate;
/// The detail's breakdown of [totalAmount], as the server computed it: the
/// lines' net amounts summed, less the invoice's own discount, plus tax on
/// what is left, plus shipping. The list rows do not carry these, so they
/// read 0 there.
final double subtotal;
final double discountAmount;
final double taxAmount;
final double shippingAmount;
/// Amounts arrive as decimal strings, so they are parsed rather than cast.
final double totalAmount;
final double amountPaid;
final double amountDue;
/// The invoice's own currency, not the org's. An org can bill in more than
/// one, and the rate on the invoice is the one that was agreed.
final String? currency;
final bool isOverdue;
/// The list sends a count; the detail sends the items themselves.
final int lineItemsCount;
final List<InvoiceLineItem> lineItems;
final List<Payment> payments;
final String? notes;
final String? terms;
final String? poNumber;
String get statusLabel => status?.label ?? rawStatus;
/// Grey for a status this build does not recognise: unfamiliar rather than
/// wrong, since colouring an unknown status green would be a claim.
Color get statusColor => status?.color ?? AppColors.gray500;
Currency get currencyInfo => Currency.fromString(currency);
bool get isSettled => status != null && _settled.contains(status);
/// How many line items to claim. The detail carries the rows themselves and
/// the list carries only a count, so neither field alone is right on both.
int get itemCount => lineItems.isNotEmpty ? lineItems.length : lineItemsCount;
/// Whole days past the due date, negative while there is still time.
///
/// Null on a settled invoice and on one with no due date, because there is
/// no honest answer in either case: "4 days late" against a paid invoice is
/// simply wrong, and it was on screen in v1.
int? get daysLate {
final due = dueDate;
if (due == null || isSettled) return null;
// Counted from the org's today, the day the API marks an invoice overdue.
return -orgDaysUntil(due);
}
bool get isLate => (daysLate ?? -1) > 0;
/// Whether the send button should be offered.
///
/// Deliberately narrower than the API, which refuses only Paid and
/// Cancelled and would therefore allow a resend of anything else. The web
/// list offers Send on a draft and Send reminder on an overdue invoice and
/// nothing else, and this mirrors that rather than inventing a third rule.
/// The server is still the check that counts.
bool get canSend =>
status == InvoiceStatus.draft || status == InvoiceStatus.overdue;
/// Whether to offer recording a payment.
///
/// There has to be a balance to settle, and a cancelled invoice is not owed.
/// Both halves mirror a refusal the API enforces: `PaymentCreateSerializer`
/// rejects a payment against a cancelled invoice outright, and rejects any
/// amount above `amount_due`, which on a paid invoice is every amount.
bool get canRecordPayment =>
amountDue > 0 &&
status != InvoiceStatus.cancelled &&
status != InvoiceStatus.paid;
/// Cancel is refused on an already-cancelled invoice and on a paid one.
bool get canCancel =>
status != null &&
status != InvoiceStatus.cancelled &&
status != InvoiceStatus.paid;
factory Invoice.fromJson(Map<String, dynamic> json) {
final account = json['account'];
final contact = json['contact'];
return Invoice(
id: json['id']?.toString() ?? '',
invoiceNumber: json['invoice_number']?.toString() ?? '',
title: json['invoice_title'] as String? ?? '',
status: InvoiceStatus.fromString(json['status'] as String?),
rawStatus: json['status']?.toString() ?? '',
accountId: account is Map<String, dynamic>
? account['id']?.toString()
: account?.toString(),
accountName: account is Map<String, dynamic>
? account['name'] as String?
: json['account_name'] as String?,
contactName: contact is Map<String, dynamic>
? _personName(contact)
: json['contact_name'] as String?,
clientName: json['client_name'] as String? ?? '',
clientEmail: json['client_email'] as String? ?? '',
issueDate: _date(json['issue_date']),
dueDate: _date(json['due_date']),
subtotal: _amount(json['subtotal']),
discountAmount: _amount(json['discount_amount']),
taxAmount: _amount(json['tax_amount']),
shippingAmount: _amount(json['shipping_amount']),
totalAmount: _amount(json['total_amount']),
amountPaid: _amount(json['amount_paid']),
amountDue: _amount(json['amount_due']),
currency: json['currency'] as String?,
isOverdue: json['is_overdue'] as bool? ?? false,
lineItemsCount: json['line_items_count'] as int? ?? 0,
lineItems: _rows(
json['line_items'],
).map(InvoiceLineItem.fromJson).toList(),
payments: _rows(json['payments']).map(Payment.fromJson).toList(),
notes: json['notes'] as String?,
terms: json['terms'] as String?,
poNumber: json['po_number'] as String?,
);
}
}
/// One billed line.
class InvoiceLineItem {
const InvoiceLineItem({
required this.id,
this.name = '',
this.description,
this.quantity = 0,
this.unitPrice = 0,
this.discountAmount = 0,
this.amount = 0,
});
final String id;
final String name;
final String? description;
final double quantity;
final double unitPrice;
/// What the line's own discount took off quantity x unit price.
final double discountAmount;
/// The server's `net_amount`: quantity x unit price less the line's own
/// discount, which is what the lines add up to in the subtotal.
final double amount;
/// Trailing zeroes dropped, so 2 units reads "2" and 1.5 hours reads "1.5".
String get quantityLabel {
final whole = quantity.truncateToDouble() == quantity;
return whole ? quantity.toStringAsFixed(0) : quantity.toString();
}
factory InvoiceLineItem.fromJson(Map<String, dynamic> json) {
return InvoiceLineItem(
id: json['id']?.toString() ?? '',
// `name` is the line's own text and `product_name` is the catalogue
// entry behind it. A free-text line has no product, so name comes first.
name: (json['name'] as String?)?.trim().isNotEmpty == true
? json['name'] as String
: json['product_name'] as String? ?? '',
description: json['description'] as String?,
quantity: _amount(json['quantity']),
unitPrice: _amount(json['unit_price']),
discountAmount: _amount(json['discount_amount']),
// A server before 1.10.0 sends no `net_amount`; its `total` was the
// figure shown then.
amount: _amount(json['net_amount'] ?? json['total']),
);
}
}
/// A payment already recorded against an invoice.
class Payment {
const Payment({
required this.id,
this.amount = 0,
this.paymentDate,
this.method = '',
this.referenceNumber,
this.notes,
});
final String id;
final double amount;
final DateTime? paymentDate;
final String method;
final String? referenceNumber;
final String? notes;
String get methodLabel => paymentMethods[method] ?? method;
factory Payment.fromJson(Map<String, dynamic> json) {
return Payment(
id: json['id']?.toString() ?? '',
amount: _amount(json['amount']),
paymentDate: _date(json['payment_date']),
method: json['payment_method']?.toString() ?? '',
referenceNumber: json['reference_number'] as String?,
notes: json['notes'] as String?,
);
}
}
/// `invoices.models.PAYMENT_METHODS`, value to label.
const Map<String, String> paymentMethods = {
'CASH': 'Cash',
'CHECK': 'Check',
'CREDIT_CARD': 'Credit card',
'BANK_TRANSFER': 'Bank transfer',
'PAYPAL': 'PayPal',
'STRIPE': 'Stripe',
'OTHER': 'Other',
};
/// The list header figures.
///
/// The API computes these over everything the caller can see, NOT over the
/// active filter and not over the loaded page. Labelling them as describing
/// the filtered list would be false, which is why the screen does not.
///
/// Every invoice carries its own currency and there are no exchange rates, so
/// the server never adds two currencies together: the amounts arrive as a
/// `by_currency` list and are kept here as one [InvoiceMoneyTotals] per
/// currency code. The counts have no currency and stay single figures.
class InvoiceTotals {
const InvoiceTotals({
this.count = 0,
this.money = const {},
this.actionNeeded = 0,
});
final int count;
/// The amounts, keyed by currency code. Empty when nothing is visible. A
/// server older than the per-currency totals sends only the plain figures,
/// which are kept under an empty code.
final Map<String, InvoiceMoneyTotals> money;
/// A count, not an amount: drafts to send plus anything overdue to chase.
final int actionNeeded;
factory InvoiceTotals.fromJson(Map<String, dynamic> json) {
final rows = json['by_currency'];
return InvoiceTotals(
count: json['count'] as int? ?? 0,
money: rows is List
? {
for (final row in rows.whereType<Map<String, dynamic>>())
if (row['currency'] is String)
row['currency'] as String: InvoiceMoneyTotals.fromJson(row),
}
: {'': InvoiceMoneyTotals.fromJson(json)},
actionNeeded: json['action_needed'] as int? ?? 0,
);
}
}
/// The list header's amounts in one currency.
class InvoiceMoneyTotals {
const InvoiceMoneyTotals({
this.outstanding = 0,
this.overdue = 0,
this.dueThisMonth = 0,
this.paidThisQuarter = 0,
this.draft = 0,
});
final double outstanding;
final double overdue;
final double dueThisMonth;
final double paidThisQuarter;
final double draft;
factory InvoiceMoneyTotals.fromJson(Map<String, dynamic> json) {
return InvoiceMoneyTotals(
outstanding: _amount(json['outstanding']),
overdue: _amount(json['overdue']),
dueThisMonth: _amount(json['due_this_month']),
paidThisQuarter: _amount(json['paid_this_quarter']),
draft: _amount(json['draft']),
);
}
}
String? _personName(Map<String, dynamic> contact) {
final parts = [
contact['first_name'],
contact['last_name'],
].map((p) => p?.toString().trim() ?? '').where((p) => p.isNotEmpty);
final name = parts.join(' ');
return name.isEmpty ? null : name;
}
List<Map<String, dynamic>> _rows(dynamic value) {
if (value is! List) return const [];
return value.whereType<Map<String, dynamic>>().toList();
}
/// Amounts cross the wire as decimal strings ("1250.00"), so `as double` would
/// throw on every one of them.
double _amount(dynamic value) {
if (value is num) return value.toDouble();
return double.tryParse(value?.toString() ?? '') ?? 0;
}
/// A YYYY-MM-DD date as a local calendar date.
///
/// Parsed with an explicit midnight so the value is local rather than UTC.
/// Reading a UTC midnight anywhere behind Greenwich lands on the day before,
/// which would report an invoice due today as a day overdue.
DateTime? _date(dynamic value) {
final text = value?.toString().trim() ?? '';
if (text.isEmpty || text == 'null') return null;
if (RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(text)) {
return DateTime.tryParse('${text}T00:00:00');
}
return DateTime.tryParse(text)?.toLocal();
}