@tallyui/pos 0.1.0 → 3.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2031 -43
  3. package/dist/index.js +4629 -205
  4. package/package.json +23 -12
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +53 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +8 -0
  10. package/src/order/order-builder.ts +256 -95
  11. package/src/order/order-manager.ts +26 -43
  12. package/src/order/tax-figures.ts +23 -0
  13. package/src/order/types.ts +73 -15
  14. package/src/outbox/backend-not-found.ts +27 -0
  15. package/src/outbox/http-transport.ts +67 -0
  16. package/src/outbox/index.ts +9 -0
  17. package/src/outbox/logger.ts +3 -0
  18. package/src/outbox/order-outbox.ts +502 -0
  19. package/src/outbox/register-outbox.ts +250 -0
  20. package/src/outbox/types.test-d.ts +22 -0
  21. package/src/outbox/types.ts +36 -0
  22. package/src/outbox/use-order-outbox.ts +175 -0
  23. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  24. package/src/pos-order/command.ts +123 -0
  25. package/src/pos-order/device-id.ts +23 -0
  26. package/src/pos-order/finalize.ts +219 -0
  27. package/src/pos-order/index.ts +10 -0
  28. package/src/pos-order/needs-attention.ts +16 -0
  29. package/src/pos-order/open.ts +269 -0
  30. package/src/pos-order/same-sale.ts +26 -0
  31. package/src/pos-order/schema.ts +130 -0
  32. package/src/pos-order/types.ts +93 -0
  33. package/src/pos-order/uuidv7.ts +54 -0
  34. package/src/product/index.ts +2 -0
  35. package/src/product/search-products.ts +32 -0
  36. package/src/product/stock.ts +25 -0
  37. package/src/receipt/build-receipt-data.ts +39 -23
  38. package/src/receipt/types.ts +17 -10
  39. package/src/register/__fixtures__/closure-local-row.json +61 -0
  40. package/src/register/__fixtures__/closure.json +197 -0
  41. package/src/register/__fixtures__/corrections-dst.json +63 -0
  42. package/src/register/closure-document.ts +277 -0
  43. package/src/register/closure-rows.ts +65 -0
  44. package/src/register/document-labels.ts +46 -0
  45. package/src/register/expected.ts +66 -0
  46. package/src/register/export-csv.ts +74 -0
  47. package/src/register/facts.ts +109 -0
  48. package/src/register/index.ts +32 -0
  49. package/src/register/money.ts +18 -0
  50. package/src/register/movement-input.ts +59 -0
  51. package/src/register/register-commands.ts +147 -0
  52. package/src/register/register-count.denominations.ts +11 -0
  53. package/src/register/register-count.helpers.ts +64 -0
  54. package/src/register/register-document.ts +268 -0
  55. package/src/register/schemas.ts +175 -0
  56. package/src/register/session-store.ts +570 -0
  57. package/src/register/settled-figures.ts +98 -0
  58. package/src/register/use-register-session.ts +469 -0
  59. package/src/rxdb/index.ts +2 -0
  60. package/src/sale/cart.ts +23 -0
  61. package/src/sale/catalogue.ts +21 -0
  62. package/src/sale/index.ts +5 -0
  63. package/src/sale/use-sale.ts +394 -0
  64. package/src/store-settings/index.ts +5 -0
  65. package/src/store-settings/map-store-settings.ts +18 -0
  66. package/src/store-settings/resolve-store-settings.ts +65 -0
  67. package/src/store-settings/use-store-settings.ts +84 -0
  68. package/src/tax/exact.ts +218 -0
  69. package/src/tax/index.ts +4 -3
  70. package/src/tax/tax-provider.tsx +35 -12
  71. package/src/tax/types.ts +8 -6
  72. package/src/tender/index.ts +2 -0
  73. package/src/tender/tender-state.ts +326 -0
  74. package/src/currency/currency-provider.test.tsx +0 -36
  75. package/src/currency/format-currency.test.ts +0 -36
  76. package/src/currency/format-currency.ts +0 -18
  77. package/src/logging/logger.test.ts +0 -154
  78. package/src/logging/sinks.test.ts +0 -60
  79. package/src/order/discount-engine.test.ts +0 -152
  80. package/src/order/order-builder.test.ts +0 -246
  81. package/src/order/order-manager.test.ts +0 -209
  82. package/src/order/payment.test.ts +0 -91
  83. package/src/receipt/build-receipt-data.test.ts +0 -166
  84. package/src/repository/create-repository.test.ts +0 -166
  85. package/src/tax/calculate.test.ts +0 -59
  86. package/src/tax/calculate.ts +0 -32
  87. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,123 @@
1
+ import type { OrderCreateEnvelope } from '@tallyui/core';
2
+ import { MICROS_PER_MINOR, roundMicrosToMinor, taxMicros } from '../tax/exact';
3
+ import type { PosOrder, PosOrderLine } from './types';
4
+
5
+ export class UnsupportedOrderVersionError extends Error {
6
+ readonly code = 'UNSUPPORTED_ORDER_VERSION';
7
+ constructor(readonly needed: number, readonly supported: number) {
8
+ super(`This sale needs order.create version ${needed}; the server supports up to ${supported}.`);
9
+ this.name = 'UnsupportedOrderVersionError';
10
+ }
11
+ }
12
+
13
+ /**
14
+ * `@tallyui/core/server`'s `payloadBoundErrors` bound, in UTF-16 code units (`String.length`), on
15
+ * every order.create string but `customer.email` (254), `customerId` (64) and `sessionId` (36).
16
+ */
17
+ export const PAYLOAD_STRING_MAX = 255;
18
+
19
+ /** A non-empty string of at most `max` UTF-16 units with no NUL: what order.create's shape check accepts. */
20
+ export const sendable = (value: unknown, max: number): value is string =>
21
+ typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\u0000');
22
+
23
+ /**
24
+ * Why a customer (picked from a search, or restored from a parked order) can't go on a sale, or null: its email
25
+ * (over 254 units, or a NUL) or its id (over 64, or a NUL) would be refused by order.create's shape check.
26
+ */
27
+ export function customerRefusal(customer: { id?: string; email?: string }): string | null {
28
+ return customer.email !== undefined && customer.email !== '' && !sendable(customer.email, 254) ? CUSTOMER_REFUSALS[0]
29
+ : customer.id !== undefined && customer.id !== '' && !sendable(customer.id, 64) ? CUSTOMER_REFUSALS[1] : null;
30
+ }
31
+ /** Every message `customerRefusal` returns (email, then id). */
32
+ export const CUSTOMER_REFUSALS = (['email', 'id'] as const)
33
+ .map((field) => `This customer's ${field} can't be sent to the store, so they weren't added to the sale.`);
34
+
35
+ /** Text with NUL stripped, and over `max` UTF-16 units cut to `max - 1` plus '…', never inside a surrogate pair. */
36
+ export function cutText(text: string, max: number): string {
37
+ const clean = text.replaceAll('\u0000', '');
38
+ if (clean.length <= max) return clean;
39
+ const high = clean.charCodeAt(max - 2);
40
+ return `${clean.slice(0, high >= 0xd800 && high <= 0xdbff ? max - 2 : max - 1)}…`;
41
+ }
42
+
43
+ /**
44
+ * Version 4 (#286): a line's discount made tax-exclusive. An exclusive line's is unchanged; an inclusive line's
45
+ * gross discount D on its amount A becomes `net(A) − net(A − D)`, exact in millionths (net(A − D) from its stored
46
+ * taxLines, the tax on A − D; net(A) from `taxMicros`, as the builder taxes), rounded half away from zero.
47
+ */
48
+ function netDiscountMinor(line: PosOrderLine, pricesIncludeTax: boolean): number {
49
+ if (!(line.taxInclusive ?? pricesIncludeTax) || line.discountMinor === 0) return line.discountMinor;
50
+ const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
51
+ const taxAfter = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
52
+ const taxBefore = taxMicros(line.unitPriceMinor * line.quantity, ratePpm, true);
53
+ return roundMicrosToMinor(BigInt(line.discountMinor) * MICROS_PER_MINOR - taxBefore + taxAfter);
54
+ }
55
+
56
+ /**
57
+ * The order.create version an order's stored content makes: 3 with ADR-065's figures, else 2 when its lines are
58
+ * discounted (ADR-062), else 1. The builder's, and pos_orders v5's migration's for a row with no `sentVersion`.
59
+ */
60
+ export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines'>): 1 | 2 | 3 {
61
+ return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
62
+ }
63
+
64
+ /**
65
+ * Builds the ADR-038 order.create envelope for a PosOrder. It sends the stored order unchanged, so every resend of
66
+ * an order is byte-identical, and an order stored by an older till goes out exactly as that till sent it:
67
+ * `finalizeOrder` freezes the sent form (names and labels cut, an unsendable customer email or id left out).
68
+ * ADR-065's figures make version 3; otherwise a discounted order is version 2 (ADR-062), else version 1, byte-identical.
69
+ * A version-3 order goes as version 4 (#286) only when `maxVersion` is at least 4 and its `sentVersion` doesn't cap it.
70
+ */
71
+ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt = 1,
72
+ options?: { maxVersion?: number }): OrderCreateEnvelope {
73
+ const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
74
+ const content = contentVersion(order);
75
+ const cap = options?.maxVersion === undefined ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
76
+ if (grossMinor > 0 && cap !== undefined && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
77
+ // Version 4 only when the cap allows it (the server advertises 4, and no sentVersion holds the order lower).
78
+ const version = (content === 3 && cap !== undefined && cap >= 4 ? 4
79
+ : Math.min(content, cap ?? content)) as OrderCreateEnvelope['version'];
80
+ const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
81
+ // The order's discount is the sum of its lines', on one basis, so the payload's two always agree.
82
+ const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
83
+ const email = order.customer?.email;
84
+ const id = order.customer?.id;
85
+ // These are the pre-#222 customerId and sessionId checks, kept byte-exact so stored orders resend older tills' bytes; don't replace with sendable.
86
+ const customerId = typeof id === 'string' && id.length > 0 && id.length <= 64 ? id : undefined;
87
+ const sessionId = order.sessionId ?? order.lateSessionId;
88
+ return {
89
+ id: order.commandId, type: 'order.create', version, createdAt: order.createdAt, deviceId, attempt,
90
+ payload: {
91
+ clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
92
+ lines: order.lines.map((line, i) => ({
93
+ clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
94
+ quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
95
+ ...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
96
+ ...(lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}),
97
+ })),
98
+ payments: order.payments.map((payment) => ({
99
+ clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
100
+ ...(payment.tenderedMinor !== undefined ? { tenderedMinor: payment.tenderedMinor } : {}),
101
+ ...(payment.changeMinor !== undefined ? { changeMinor: payment.changeMinor } : {}),
102
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
103
+ })),
104
+ subtotalMinor: order.subtotalMinor,
105
+ ...(discountMinor > 0 ? { discountMinor } : {}),
106
+ taxMinor: order.taxMinor, totalMinor: order.totalMinor,
107
+ ...(version >= 3 ? {
108
+ display: { ...order.display!, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
109
+ clientLineId: lineId, amountMinor, discounts,
110
+ })) },
111
+ taxByRate: order.taxByRate!.map(({ ratePpm, code, netMinor, amountMinor, grossMinor }) => ({
112
+ ratePpm, ...(code !== undefined ? { code } : {}), netMinor, taxMinor: amountMinor, grossMinor,
113
+ })),
114
+ } : {}),
115
+ customer: version >= 3
116
+ ? (email || customerId ? { ...(email ? { email } : {}), ...(customerId ? { customerId } : {}) } : null)
117
+ : (email ? { email } : null),
118
+ ...(order.registerId !== undefined ? { registerId: order.registerId } : {}),
119
+ ...(order.cashierRef !== undefined ? { cashierRef: order.cashierRef } : {}),
120
+ ...(version >= 3 && typeof sessionId === 'string' && sessionId.length > 0 && sessionId.length <= 36 ? { sessionId } : {}),
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,23 @@
1
+ import { uuidv7 } from './uuidv7';
2
+
3
+ /** The device id used when no storage is available, shared by every caller in this process. */
4
+ let processId: string | undefined;
5
+
6
+ /**
7
+ * Returns this device's id (a UUIDv7), stored under `key` in `storage` (web storage, or an app's
8
+ * equivalent) and minted on first use; medusapos passes `'medusapos.register_id'`. With no storage,
9
+ * or one that throws on read or write, it falls back to one id per process.
10
+ */
11
+ export function getDeviceId(storage: { getItem(key: string): string | null; setItem(key: string, value: string): void } | null,
12
+ key: string): string {
13
+ try {
14
+ if (storage) {
15
+ const existing = storage.getItem(key);
16
+ if (existing) return existing;
17
+ const id = uuidv7();
18
+ storage.setItem(key, id);
19
+ return id;
20
+ }
21
+ } catch { /* Use the process id when web storage is unavailable. */ }
22
+ return processId ??= uuidv7();
23
+ }
@@ -0,0 +1,219 @@
1
+ import { minorUnitDigits, type ServerCapabilities, type TaxRounding } from '@tallyui/core';
2
+ import type { Order, SentOrder } from '../order/types';
3
+ import { DEFAULT_TAX_ROUNDING, taxLinesByRate } from '../tax/exact';
4
+ import { outboxLogger } from '../outbox/logger';
5
+ import { cutText, PAYLOAD_STRING_MAX, sendable } from './command';
6
+ import type { PosOrder, PosOrderLocalWarning, PosOrderPayment } from './types';
7
+ import { uuidv7 } from './uuidv7';
8
+
9
+ export interface FinalizeOptions {
10
+ localWarnings?: PosOrderLocalWarning[];
11
+ registerId?: string;
12
+ // No `sessionId`: `stampSession` is the only way to set a sale's session, because it checks the
13
+ // session is live. Tests and migrations that need a stamped order spread `{ ...order, sessionId }`.
14
+ cashierRef?: string;
15
+ now?: Date;
16
+ /** Tests only: replaces uuidv7. Every id it returns must be at most 255 characters with no NUL (order.create's bound). */
17
+ newId?: () => string;
18
+ /** The store's `order.create` capability (ADR-062); `undefined` is treated as 1. */
19
+ capabilities?: ServerCapabilities;
20
+ }
21
+
22
+ /** A line name in a refusal message: 60 UTF-16 units of it plus '…' at most, so a cashier-facing message stays short. */
23
+ export const MESSAGE_NAME_MAX = 61;
24
+
25
+ /** The receipt shows the order as it was stored and sent (Front desk, 2026-09-29). */
26
+ export function withSentForm(order: Order, posOrder: PosOrder): SentOrder {
27
+ const customer: SentOrder['customer'] = order.customer && { ...order.customer };
28
+ if (customer && posOrder.customer?.email === undefined) delete customer.email;
29
+ if (customer && posOrder.customer?.id === undefined) delete customer.id;
30
+ return { ...order, customer,
31
+ lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
32
+ display: posOrder.display ? { ...order.display,
33
+ lines: order.display.lines.map((line, i) => ({ ...line,
34
+ discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display!.lines[i].discounts[j].label })),
35
+ })),
36
+ } : order.display,
37
+ };
38
+ }
39
+
40
+ /** Bounds apply when the sent form is frozen; the outbox freezes older tills' stored orders before their first send. */
41
+ export function freezeSentForm(order: PosOrder): PosOrder {
42
+ let changed = false;
43
+ const lines = order.lines.map((line) => {
44
+ const name = cutText(line.name, PAYLOAD_STRING_MAX);
45
+ changed ||= name !== line.name;
46
+ return { ...line, name };
47
+ });
48
+ const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({ ...line,
49
+ discounts: line.discounts.map((discount) => {
50
+ if (discount.label === undefined) return discount;
51
+ const label = cutText(discount.label, PAYLOAD_STRING_MAX);
52
+ changed ||= label !== discount.label;
53
+ return { ...discount, label };
54
+ }),
55
+ })) };
56
+ const payments = order.payments.map((payment) => {
57
+ if (payment.reference === undefined) return payment;
58
+ const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
59
+ changed ||= reference !== payment.reference;
60
+ return { ...payment, reference };
61
+ });
62
+ const customer = order.customer && { ...order.customer };
63
+ const localWarnings = [...(order.localWarnings ?? [])];
64
+ for (const [field, max] of [['email', 254], ['id', 64]] as const) {
65
+ if (customer && field in customer && !sendable(customer[field], max)) {
66
+ delete customer[field]; changed = true;
67
+ if (!localWarnings.some((warning) => warning.code === 'customer_omitted' && warning.field === field)) localWarnings.push({ code: 'customer_omitted', field });
68
+ }
69
+ }
70
+ return changed ? { ...order, lines, payments, customer, ...(display ? { display } : {}), ...(localWarnings.length ? { localWarnings } : {}) } : order;
71
+ }
72
+
73
+ /**
74
+ * Why a reference the till passes through without minting would fail order.create's shape check
75
+ * (over PAYLOAD_STRING_MAX, or a NUL), or null; `label` names it for the cashier. `useSale` also
76
+ * checks its options and an entered payment reference with it.
77
+ */
78
+ export function referenceError(label: string, value: string | undefined): string | null {
79
+ const reason = referenceReason(value);
80
+ return reason === 'long' ? `${label} is too long (max ${PAYLOAD_STRING_MAX} characters)`
81
+ : reason === 'nul' ? `${label} contains a NUL character` : null;
82
+ }
83
+
84
+ /** referenceError's rule as a reason: 'long' (over PAYLOAD_STRING_MAX), 'nul', or null when finalize accepts the value. */
85
+ export function referenceReason(value: string | undefined): 'long' | 'nul' | null {
86
+ if (value === undefined) return null;
87
+ if (value.length > PAYLOAD_STRING_MAX) return 'long';
88
+ return value.includes('\u0000') ? 'nul' : null;
89
+ }
90
+
91
+ /**
92
+ * Turns a fully paid builder Order into a pending PosOrder without mutating it. The PosOrder holds the sent form,
93
+ * frozen: line names and v3 discount labels are cut to PAYLOAD_STRING_MAX with NUL stripped (`cutText`), and a
94
+ * customer email or id the shape check would refuse is left out, so `toOrderCreateEnvelope` sends it unchanged.
95
+ */
96
+ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
97
+ if (!order.lineItems.length) throw new Error('finalize: no lines');
98
+ // Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
99
+ if (order.discountMinor < 0
100
+ || order.lineItems.some((line) => line.discountMinor < 0)
101
+ || order.discounts.some((d) => d.amountMinor < 0)) {
102
+ throw new Error('finalize: negative discount');
103
+ }
104
+ // An old plugin would reject or mis-apply a version-2 payload; this guard rejects a discount only when the store can't take it yet (ADR-062).
105
+ // Checked as "any non-zero" rather than "> 0": defence in depth, since the builder already clamps every
106
+ // discount to >= 0, so a negative amountMinor should never reach here.
107
+ const hasDiscount = order.discountMinor !== 0
108
+ || order.lineItems.some((line) => line.discountMinor !== 0)
109
+ || order.discounts.some((d) => d.amountMinor !== 0);
110
+ if (hasDiscount && (options.capabilities?.orderCreate ?? 1) < 2) {
111
+ throw new Error('finalize: discounts are not supported by the server yet (order.create v2)');
112
+ }
113
+ for (const payment of order.payments) {
114
+ if (payment.method !== 'cash' && payment.method !== 'external') {
115
+ throw new Error(`finalize: unsupported payment method ${payment.method}`);
116
+ }
117
+ }
118
+ if (order.paidMinor < order.totalMinor) throw new Error('finalize: underpaid');
119
+ let change = order.paidMinor - order.totalMinor;
120
+ const cash = order.payments.reduce((sum, p) => sum + (p.method === 'cash' ? p.amountMinor : 0), 0);
121
+ if (change > cash) throw new Error('finalize: change exceeds cash');
122
+ // Refused before any id is minted, so such a value never reaches the stored order or the outbox.
123
+ // v3 also sends each line discount's id and each tax code (as taxByRate's codes); an id is never clamped.
124
+ const v3 = (options.capabilities?.orderCreate ?? 1) >= 3;
125
+ const references: Array<[string, string | undefined]> = [['registerId', options.registerId], ['cashierRef', options.cashierRef],
126
+ ...order.lineItems.flatMap((line, i): Array<[string, string | undefined]> => {
127
+ const name = `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
128
+ return [line.variantId !== undefined ? [`${name}: the variant id`, line.variantId] : [`${name}: the product id`, line.productId],
129
+ ...(v3 ? order.display.lines[i]?.discounts ?? [] : []).map((d): [string, string] => [`${name}: the discount id`, d.discountId]),
130
+ ...(v3 ? line.taxLines : []).map((tax): [string, string | undefined] => [`${name}: the tax code`, tax.code])];
131
+ }),
132
+ ...order.payments.map((payment): [string, string | undefined] => [`the ${payment.method} payment's reference`, payment.reference])];
133
+ for (const [field, value] of references) {
134
+ const message = referenceError(field, value);
135
+ if (message) throw new Error(`finalize: ${message}`);
136
+ }
137
+ const newId = options.newId ?? uuidv7;
138
+ const id = newId();
139
+ const lines = order.lineItems.map((line) => ({
140
+ id: newId(), productId: line.productId,
141
+ ...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
142
+ name: line.name, sku: line.sku, quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
143
+ discountMinor: line.discountMinor, netMinor: line.netMinor,
144
+ taxLines: line.taxLines.map((tax) => ({ ...tax })),
145
+ ...(line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}),
146
+ }));
147
+ const payments: PosOrderPayment[] = order.payments.map((payment) => ({
148
+ id: newId(), method: payment.method as PosOrderPayment['method'], amountMinor: payment.amountMinor,
149
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
150
+ }));
151
+ // A warning naming a payment the order no longer has is dropped and logged, never thrown: this runs at sale completion.
152
+ const localWarnings = options.localWarnings?.flatMap((warning): PosOrderLocalWarning[] => {
153
+ if (warning.code !== 'payment_reference_dropped') return [{ ...warning }];
154
+ const index = order.payments.findIndex((payment) => payment.id === warning.paymentId);
155
+ if (index === -1) {
156
+ try {
157
+ outboxLogger.warn('finalize: dropped a localWarnings entry naming an unknown payment', { orderId: id, paymentId: warning.paymentId });
158
+ } catch { /* a failing sink must never fail the sale */ }
159
+ return [];
160
+ }
161
+ return [{ ...warning, paymentId: payments[index].id }];
162
+ });
163
+ for (let i = payments.length - 1; i >= 0; i--) {
164
+ const payment = payments[i];
165
+ if (payment.method !== 'cash') continue;
166
+ payment.tenderedMinor = payment.amountMinor;
167
+ payment.changeMinor = Math.min(change, payment.tenderedMinor);
168
+ payment.amountMinor -= payment.changeMinor;
169
+ change -= payment.changeMinor;
170
+ }
171
+ if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
172
+ throw new Error('finalize: payments do not reconcile');
173
+ }
174
+ let display: PosOrder['display'];
175
+ let taxByRate: PosOrder['taxByRate'];
176
+ if ((options.capabilities?.orderCreate ?? 1) >= 3) {
177
+ if (order.display.lines.length !== order.lineItems.length
178
+ || order.display.lines.some((line, i) => line.lineId !== order.lineItems[i].id)) {
179
+ throw new Error('finalize: display lines do not match the order lines');
180
+ }
181
+ display = { currency: order.currency, exponent: minorUnitDigits(order.currency), ...order.display,
182
+ lines: order.display.lines.map((line, i) => ({
183
+ lineId: lines[i].id, amountMinor: line.amountMinor,
184
+ discounts: line.discounts.map(({ discountId, label, amountMinor }) => ({
185
+ discountId, ...(label !== undefined ? { label } : {}), amountMinor,
186
+ })),
187
+ })),
188
+ };
189
+ taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, undefined, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
190
+ ratePpm, ...(code !== undefined ? { code } : {}), netMinor, amountMinor, grossMinor: netMinor + amountMinor,
191
+ }));
192
+ if (display.totalMinor !== order.totalMinor || display.taxMinor !== order.taxMinor
193
+ || display.taxInclusive !== order.pricesIncludeTax) {
194
+ throw new Error('finalize: display does not match the order');
195
+ }
196
+ if (taxByRate.reduce((sum, rate) => sum + rate.amountMinor, 0) !== order.taxMinor) {
197
+ throw new Error('finalize: tax by rate does not sum to the order tax');
198
+ }
199
+ }
200
+ // Frozen with the figures (#287), the default included, and never recomputed from the store's later capability:
201
+ // absent on the snapshot is the default the figures used; `custom` computes as the default but records itself.
202
+ const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
203
+ const taxRounding: TaxRounding = rounding.granularity === 'custom' ? { granularity: 'custom' }
204
+ : { granularity: rounding.granularity, mode: rounding.mode };
205
+ const now = (options.now ?? new Date()).toISOString();
206
+ return freezeSentForm({
207
+ id, createdAt: now, updatedAt: now, commandId: newId(), syncStatus: 'pending',
208
+ currency: order.currency, pricesIncludeTax: order.pricesIncludeTax, lines, payments,
209
+ subtotalMinor: order.subtotalMinor, discountMinor: order.discountMinor,
210
+ taxMinor: order.taxMinor, totalMinor: order.totalMinor, taxRounding,
211
+ ...(display && taxByRate ? { display, taxByRate } : {}),
212
+ customer: order.customer ? { id: order.customer.id, name: order.customer.name,
213
+ ...(order.customer.email !== undefined ? { email: order.customer.email } : {}) } : null,
214
+ ...(order.note ? { note: order.note } : {}),
215
+ ...(options.registerId !== undefined ? { registerId: options.registerId } : {}),
216
+ ...(options.cashierRef !== undefined ? { cashierRef: options.cashierRef } : {}),
217
+ ...(localWarnings?.length ? { localWarnings } : {}),
218
+ });
219
+ }
@@ -0,0 +1,10 @@
1
+ export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrderLocalWarning, PosOrderServerFailures, PosOrder } from './types';
2
+ export { uuidv7 } from './uuidv7';
3
+ export { finalizeOrder } from './finalize';
4
+ export type { FinalizeOptions } from './finalize';
5
+ export { toOrderCreateEnvelope, UnsupportedOrderVersionError } from './command';
6
+ export { posOrderSchema, posOrderCollection } from './schema';
7
+ export { addPosOrderCollection, PosOrderOpenClosedError, posOrdersLogger } from './open';
8
+ export { getDeviceId } from './device-id';
9
+ export { needsAttention } from './needs-attention';
10
+ export { sameSale, OrderContentMismatchError } from './same-sale';
@@ -0,0 +1,16 @@
1
+ import { knownWarnings } from '@tallyui/core';
2
+ import type { PosOrder } from './types';
3
+
4
+ /**
5
+ * The orders a cashier must look at: rejected, applied with a known warning, taken after their
6
+ * session closed (`lateSessionId`) or with local warnings (whatever the sync status), or pending with a `commandId` in
7
+ * `stuckCommandIds` (the outbox's `OutboxState.stuck`: the store keeps failing it), newest first.
8
+ * A warning code this till version doesn't know is not shown here (`knownWarnings`). Never changes `orders`.
9
+ */
10
+ export function needsAttention(orders: PosOrder[], options: { stuckCommandIds?: readonly string[] } = {}): PosOrder[] {
11
+ return orders.filter((order) => order.syncStatus === 'rejected'
12
+ || (order.localWarnings?.length ?? 0) > 0
13
+ || (order.syncStatus === 'applied' && knownWarnings(order.warnings).length > 0) || order.lateSessionId !== undefined
14
+ || (order.syncStatus === 'pending' && options.stuckCommandIds?.includes(order.commandId) === true))
15
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
16
+ }