@tallyui/pos 2.0.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.
- package/dist/index.d.ts +321 -61
- package/dist/index.js +1391 -185
- package/package.json +8 -8
- package/src/index.ts +15 -7
- package/src/order/index.ts +3 -0
- package/src/order/order-builder.ts +23 -7
- package/src/order/order-manager.ts +3 -1
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +9 -3
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +5 -4
- package/src/outbox/index.ts +2 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +331 -27
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +17 -3
- package/src/outbox/use-order-outbox.ts +20 -6
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +99 -11
- package/src/pos-order/finalize.ts +145 -7
- package/src/pos-order/index.ts +2 -2
- package/src/pos-order/needs-attention.ts +9 -4
- package/src/pos-order/open.ts +100 -37
- package/src/pos-order/schema.ts +35 -5
- package/src/pos-order/types.ts +31 -5
- package/src/product/search-products.ts +5 -2
- package/src/receipt/build-receipt-data.ts +5 -3
- package/src/receipt/types.ts +1 -0
- package/src/register/closure-document.ts +7 -0
- package/src/register/index.ts +5 -3
- package/src/register/movement-input.ts +1 -1
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-document.ts +26 -1
- package/src/register/session-store.ts +41 -12
- package/src/register/use-register-session.ts +46 -2
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +97 -40
- package/src/store-settings/map-store-settings.ts +7 -2
- package/src/store-settings/use-store-settings.ts +22 -7
- package/src/tax/exact.ts +74 -11
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +26 -4
- package/src/tax/types.ts +6 -0
|
@@ -1,20 +1,98 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { Order } from '../order/types';
|
|
3
|
-
import
|
|
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';
|
|
4
7
|
import { uuidv7 } from './uuidv7';
|
|
5
8
|
|
|
6
9
|
export interface FinalizeOptions {
|
|
10
|
+
localWarnings?: PosOrderLocalWarning[];
|
|
7
11
|
registerId?: string;
|
|
8
12
|
// No `sessionId`: `stampSession` is the only way to set a sale's session, because it checks the
|
|
9
13
|
// session is live. Tests and migrations that need a stamped order spread `{ ...order, sessionId }`.
|
|
10
14
|
cashierRef?: string;
|
|
11
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). */
|
|
12
17
|
newId?: () => string;
|
|
13
18
|
/** The store's `order.create` capability (ADR-062); `undefined` is treated as 1. */
|
|
14
19
|
capabilities?: ServerCapabilities;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
/**
|
|
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
|
+
*/
|
|
18
96
|
export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
|
|
19
97
|
if (!order.lineItems.length) throw new Error('finalize: no lines');
|
|
20
98
|
// Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
|
|
@@ -41,6 +119,21 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
|
|
|
41
119
|
let change = order.paidMinor - order.totalMinor;
|
|
42
120
|
const cash = order.payments.reduce((sum, p) => sum + (p.method === 'cash' ? p.amountMinor : 0), 0);
|
|
43
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
|
+
}
|
|
44
137
|
const newId = options.newId ?? uuidv7;
|
|
45
138
|
const id = newId();
|
|
46
139
|
const lines = order.lineItems.map((line) => ({
|
|
@@ -55,6 +148,18 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
|
|
|
55
148
|
id: newId(), method: payment.method as PosOrderPayment['method'], amountMinor: payment.amountMinor,
|
|
56
149
|
...(payment.reference !== undefined ? { reference: payment.reference } : {}),
|
|
57
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
|
+
});
|
|
58
163
|
for (let i = payments.length - 1; i >= 0; i--) {
|
|
59
164
|
const payment = payments[i];
|
|
60
165
|
if (payment.method !== 'cash') continue;
|
|
@@ -66,16 +171,49 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
|
|
|
66
171
|
if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
|
|
67
172
|
throw new Error('finalize: payments do not reconcile');
|
|
68
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 };
|
|
69
205
|
const now = (options.now ?? new Date()).toISOString();
|
|
70
|
-
return {
|
|
206
|
+
return freezeSentForm({
|
|
71
207
|
id, createdAt: now, updatedAt: now, commandId: newId(), syncStatus: 'pending',
|
|
72
208
|
currency: order.currency, pricesIncludeTax: order.pricesIncludeTax, lines, payments,
|
|
73
209
|
subtotalMinor: order.subtotalMinor, discountMinor: order.discountMinor,
|
|
74
|
-
taxMinor: order.taxMinor, totalMinor: order.totalMinor,
|
|
210
|
+
taxMinor: order.taxMinor, totalMinor: order.totalMinor, taxRounding,
|
|
211
|
+
...(display && taxByRate ? { display, taxByRate } : {}),
|
|
75
212
|
customer: order.customer ? { id: order.customer.id, name: order.customer.name,
|
|
76
213
|
...(order.customer.email !== undefined ? { email: order.customer.email } : {}) } : null,
|
|
77
214
|
...(order.note ? { note: order.note } : {}),
|
|
78
215
|
...(options.registerId !== undefined ? { registerId: options.registerId } : {}),
|
|
79
216
|
...(options.cashierRef !== undefined ? { cashierRef: options.cashierRef } : {}),
|
|
80
|
-
|
|
217
|
+
...(localWarnings?.length ? { localWarnings } : {}),
|
|
218
|
+
});
|
|
81
219
|
}
|
package/src/pos-order/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrder } from './types';
|
|
1
|
+
export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrderLocalWarning, PosOrderServerFailures, PosOrder } from './types';
|
|
2
2
|
export { uuidv7 } from './uuidv7';
|
|
3
3
|
export { finalizeOrder } from './finalize';
|
|
4
4
|
export type { FinalizeOptions } from './finalize';
|
|
5
|
-
export { toOrderCreateEnvelope } from './command';
|
|
5
|
+
export { toOrderCreateEnvelope, UnsupportedOrderVersionError } from './command';
|
|
6
6
|
export { posOrderSchema, posOrderCollection } from './schema';
|
|
7
7
|
export { addPosOrderCollection, PosOrderOpenClosedError, posOrdersLogger } from './open';
|
|
8
8
|
export { getDeviceId } from './device-id';
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { knownWarnings } from '@tallyui/core';
|
|
1
2
|
import type { PosOrder } from './types';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* The orders a cashier must look at: rejected, applied with
|
|
5
|
-
* closed (`lateSessionId
|
|
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`.
|
|
6
9
|
*/
|
|
7
|
-
export function needsAttention(orders: PosOrder[]): PosOrder[] {
|
|
10
|
+
export function needsAttention(orders: PosOrder[], options: { stuckCommandIds?: readonly string[] } = {}): PosOrder[] {
|
|
8
11
|
return orders.filter((order) => order.syncStatus === 'rejected'
|
|
9
|
-
|| (order.
|
|
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))
|
|
10
15
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
11
16
|
}
|
package/src/pos-order/open.ts
CHANGED
|
@@ -12,18 +12,22 @@ import type { PosOrder } from './types';
|
|
|
12
12
|
export const posOrdersLogger = createLogger('pos-orders');
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* After this long waiting for
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* After this long waiting for the open, a close gives up rather than hang forever. RxDB 17.5 cancels
|
|
16
|
+
* a running migration as the database closes, and the open then rejects with
|
|
17
|
+
* `PosOrderOpenClosedError`; the run reads or writes nothing more in a store the close closes (see
|
|
18
|
+
* `openPosOrders`). The next open adds the collection without `autoMigrate` and starts and awaits the
|
|
19
|
+
* migration directly, so it migrates again: RxDB 17.5's `startMigration()` ignores the leftover status
|
|
20
|
+
* (only `migratePromise()` trusts a leftover `DONE`). It also resets that status, so the record
|
|
21
|
+
* describes the new run, and removes the run's checkpoint (RxDB bug 1). An order that run already
|
|
22
|
+
* copied is found equal and skipped, so no order is lost.
|
|
20
23
|
*/
|
|
21
24
|
export const POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 10_000;
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* `addPosOrderCollection` stopped, before any further write, because its database is closing: it
|
|
25
|
-
* was called once `close()` had begun,
|
|
26
|
-
* `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`). No order is lost: reopen the
|
|
28
|
+
* was called once `close()` had begun, the close cancelled its migration (RxDB 17.5), or the close
|
|
29
|
+
* stopped waiting for it (after `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`). No order is lost: reopen the
|
|
30
|
+
* database and call it again.
|
|
27
31
|
*/
|
|
28
32
|
export class PosOrderOpenClosedError extends Error {
|
|
29
33
|
readonly code = 'POS_ORDER_OPEN_CLOSED';
|
|
@@ -50,12 +54,14 @@ function waitWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean
|
|
|
50
54
|
/**
|
|
51
55
|
* Writes each order of the stored older version over its differing copy in the current version,
|
|
52
56
|
* through the same write RxDB's migration makes, and leaves orders without a copy (and any deleted
|
|
53
|
-
* one) to the migration. `from` is whichever older version is stored, 0 or
|
|
57
|
+
* one) to the migration. `from` is whichever older version is stored, 0, 1 or 2.
|
|
54
58
|
*/
|
|
55
|
-
async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any
|
|
59
|
+
async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any>,
|
|
60
|
+
stopIfClosing: () => void) {
|
|
56
61
|
const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
|
|
57
62
|
for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0;
|
|
58
63
|
page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
|
|
64
|
+
stopIfClosing();
|
|
59
65
|
const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
|
|
60
66
|
const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) =>
|
|
61
67
|
({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
|
|
@@ -71,16 +77,20 @@ async function writeOverStaleCopies(collection: RxCollection, from: RxStorageIns
|
|
|
71
77
|
* stopped and closes the collection, so the app can surface the error and open again; it never
|
|
72
78
|
* deletes an order.
|
|
73
79
|
*
|
|
74
|
-
* RxDB
|
|
80
|
+
* RxDB 17.5.0 trusts the status its last migration stored: a leftover `ERROR` rejects
|
|
75
81
|
* `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
|
|
76
82
|
* `DONE` left before a rollback resolves it before the older version's new orders have moved. So the
|
|
77
|
-
* collection is added without `autoMigrate
|
|
78
|
-
* (
|
|
83
|
+
* collection is added without `autoMigrate` and the migration is started and awaited directly: RxDB
|
|
84
|
+
* 17.5's `startMigration()` ignores a leftover status (only `migratePromise()` trusts a leftover
|
|
85
|
+
* `DONE`), which lets a rolled-back or interrupted run migrate again and recovers its orders. The
|
|
86
|
+
* status is reset first so its record describes the new run, and a failed run's checkpoint is
|
|
87
|
+
* removed (RxDB bug 1); never an order or its storage.
|
|
79
88
|
*
|
|
80
|
-
* A close waits for the
|
|
81
|
-
* database's close has begun,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
89
|
+
* A close waits for the open, up to `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`, and cancels a running
|
|
90
|
+
* migration (RxDB 17.5). Called once the database's close has begun, when the close cancels its
|
|
91
|
+
* migration, or when the close stops waiting, it rejects with `PosOrderOpenClosedError`
|
|
92
|
+
* (`code: 'POS_ORDER_OPEN_CLOSED'`) before any further write: reopen and call it again. Any other
|
|
93
|
+
* rejection (DM4, a storage error) is a real failure.
|
|
84
94
|
*
|
|
85
95
|
* `closeWaitMs` is for tests only; the first open on a database sets it for that database's close.
|
|
86
96
|
*/
|
|
@@ -88,11 +98,11 @@ export async function addPosOrderCollection(db: RxDatabase, closeWaitMs = POS_OR
|
|
|
88
98
|
// The reset below runs before RxDB elects which tab migrates, so a second tab could reset a
|
|
89
99
|
// migration another tab is running. TallyUI is single-instance (ADR-061).
|
|
90
100
|
if (db.multiInstance) throw new Error('addPosOrderCollection: multiInstance databases are not supported (ADR-061)');
|
|
91
|
-
// RxDB
|
|
92
|
-
// close may already have read `db.onClose` (:
|
|
101
|
+
// RxDB 17.5.0 sets the private `closePromise` as `close()` begins (rx-database.js:447), and that
|
|
102
|
+
// close may already have read `db.onClose` (:473-476), so it would not wait for this open.
|
|
93
103
|
if (db.closed || (db as unknown as { closePromise: unknown }).closePromise) throw new PosOrderOpenClosedError(db.name);
|
|
94
104
|
// Registered before the first await. RxDB's close reads `db.onClose` once, when the database is
|
|
95
|
-
// idle (rx-database.js:
|
|
105
|
+
// idle (rx-database.js:473-476), and creating or removing a store doesn't keep it busy: a handler
|
|
96
106
|
// added after an await missed that close, which then closed storage under the open (2026-09-27).
|
|
97
107
|
const previous = latestOpens.get(db);
|
|
98
108
|
if (!previous) {
|
|
@@ -105,8 +115,10 @@ export async function addPosOrderCollection(db: RxDatabase, closeWaitMs = POS_OR
|
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefined): Promise<RxCollection<PosOrder>> {
|
|
108
|
-
//
|
|
109
|
-
|
|
118
|
+
// Set once RxDB cancels this open's migration while it runs (see `startMigration` below).
|
|
119
|
+
let cancelled = false;
|
|
120
|
+
// The backstop, after each await: `closed` is set only once every store is closed (rx-database.js:444).
|
|
121
|
+
const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
|
|
110
122
|
const stopIfClosing = () => { if (closing()) throw new PosOrderOpenClosedError(db.name); };
|
|
111
123
|
const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
|
|
112
124
|
const state = collection.getMigrationState();
|
|
@@ -114,12 +126,22 @@ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefi
|
|
|
114
126
|
stopIfClosing();
|
|
115
127
|
const mustMigrate = await state.mustMigrate;
|
|
116
128
|
stopIfClosing();
|
|
117
|
-
if (!mustMigrate)
|
|
129
|
+
if (!mustMigrate) {
|
|
130
|
+
// RxDB 17 blocks writes (COL25) from collection creation until its own `migrationNeeded()` read.
|
|
131
|
+
// With nothing to migrate, `startMigration()` sets and clears that block on the same memoised `mustMigrate`,
|
|
132
|
+
// as `migratePromise()` does for `autoMigrate` (rx-collection.js:939-941), so the open resolves only once writes are allowed.
|
|
133
|
+
await state.startMigration();
|
|
134
|
+
stopIfClosing();
|
|
135
|
+
return collection;
|
|
136
|
+
}
|
|
118
137
|
// Never reset a still-settling earlier attempt's checkpoint out from under it: each new
|
|
119
138
|
// migration on this database starts only once the one before it has fully settled.
|
|
120
139
|
await previous;
|
|
121
140
|
stopIfClosing();
|
|
122
|
-
//
|
|
141
|
+
// Makes the stored status record describe this run (RUNNING, a fresh count, no leftover error):
|
|
142
|
+
// RxDB 17.5's `runMigration` overwrites only `count.total` and counts `handled` on from the stored
|
|
143
|
+
// value. It isn't what recovers a rollback (`startMigration()` ignores the status); "after a
|
|
144
|
+
// rollback to the version-N app" in open.test-helper.ts pins the record after a recovery.
|
|
123
145
|
await state.updateStatus((status) => {
|
|
124
146
|
// RxDB writes only what the handler changes in place.
|
|
125
147
|
delete status.error;
|
|
@@ -127,6 +149,7 @@ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefi
|
|
|
127
149
|
status.count = { total: 0, handled: 0, percent: 0 };
|
|
128
150
|
return status;
|
|
129
151
|
});
|
|
152
|
+
// RxDB bug 1, which still reproduces on 17.5.0 (repro 2026-09-29):
|
|
130
153
|
// A failed run leaves its checkpoint, which can be past an order it never copied (a queued
|
|
131
154
|
// batch that finds its docs taken by a failed one still stores its checkpoint). The next run
|
|
132
155
|
// would skip that order, then remove its storage. So every run starts from the first order,
|
|
@@ -138,35 +161,46 @@ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefi
|
|
|
138
161
|
schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)), devMode: overwritable.isDevMode() });
|
|
139
162
|
await (closing() ? checkpoint.close() : checkpoint.remove());
|
|
140
163
|
stopIfClosing();
|
|
141
|
-
// RxDB bug 2
|
|
142
|
-
//
|
|
164
|
+
// RxDB bug 2, which still reproduces on 17.5.0 (repro 2026-09-29: a batch in flight when `cancel()`
|
|
165
|
+
// is called lands after it resolves, 200 of 600 documents): a run's replication can outlive the
|
|
166
|
+
// run. It wakes only on the stored older version's change stream (one
|
|
143
167
|
// shared across instances, as on memory storage), then writes its checkpoint into the store a later
|
|
144
168
|
// run removed (an unhandled `removed already`) and its conflicts into the older version. So that
|
|
145
169
|
// stream ends once the run has settled. And RxDB never overwrites a copy in the current version (it
|
|
146
|
-
// drops the assumed state): a stale copy wins its conflict
|
|
170
|
+
// drops the assumed state): a stale copy wins its conflict. 17.5.0 keeps the copy and ignores the
|
|
171
|
+
// conflict (rx-migration-state.js `masterWrite` returns none); 17.4.0 and 16.21 loop on it. Yet a copy is only
|
|
147
172
|
// ever an earlier older-version state (the collection opens only once the older version is gone), so
|
|
148
173
|
// the newer state goes first. A rare exception: after a rollback, an older build's one-time carry-over
|
|
149
174
|
// (for example medusapos's Dexie import, run again from a leftover Dexie database, after a failed
|
|
150
175
|
// removal or from an old tab) can bulk-insert an older state of order A into an empty older version
|
|
151
176
|
// while the current version holds A as sent. This overwrite then makes A pending again, and it is sent
|
|
152
177
|
// twice; the server's commandId idempotency is what stops a double charge. Also, a deleted
|
|
153
|
-
// older-version order that has a copy is skipped in writeOverStaleCopies, so
|
|
154
|
-
//
|
|
178
|
+
// older-version order that has a copy is skipped in writeOverStaleCopies, so if anything ever
|
|
179
|
+
// deleted a pos_orders document, its stale copy would win (17.5.0) or RxDB would loop on it (earlier).
|
|
155
180
|
const settled = new Subject<void>();
|
|
156
181
|
// Once the close stops waiting it closes this version's store and the internal store while RxDB's
|
|
157
|
-
// migration runs on (rx-database.js:
|
|
182
|
+
// migration runs on (rx-database.js:476-485). A write called on a closed SQLite instance throws
|
|
158
183
|
// inside its transaction and poisons every later write on the handle until restart (rxdb-premium
|
|
159
|
-
// bug 6), while its close waits for a write called before it (sqlite-storage-instance.js `close`,
|
|
184
|
+
// bug 6, which still reproduces on 17.5.0, repro 2026-09-29), while its close waits for a write called before it (sqlite-storage-instance.js `close`,
|
|
160
185
|
// openWriteCount$; reproduced). So from the moment the close gives up, before it closes any store,
|
|
161
186
|
// the run's reads and writes of those two stores stop here. A refused one of this version rejects
|
|
162
187
|
// the run's push, which RxDB catches as a replication error (upstream.js:372), so the run ends in
|
|
163
188
|
// ERROR. Status writes are dropped instead: RxDB never awaits the per-order ones
|
|
164
|
-
// (rx-migration-state.js:
|
|
189
|
+
// (rx-migration-state.js:357-362), so a rejection there would be unhandled.
|
|
190
|
+
// Every call a gate lets through that returns a promise is counted until it settles, so a cancelled
|
|
191
|
+
// open (below) settles only once none of the run's calls can still reach a store the close closes.
|
|
192
|
+
let inFlight = 0;
|
|
193
|
+
let drained: (() => void) | undefined;
|
|
194
|
+
const track = (result: unknown) => {
|
|
195
|
+
if (!(result instanceof Promise)) return result;
|
|
196
|
+
inFlight++;
|
|
197
|
+
return result.finally(() => { if (--inFlight === 0) drained?.(); });
|
|
198
|
+
};
|
|
165
199
|
const gate = <T extends object>(target: T, closed: (key: 'bulkWrite' | 'findDocumentsById', first: any[]) => Promise<unknown>): T =>
|
|
166
200
|
new Proxy(target, { get: (t, key) => {
|
|
167
201
|
const value = Reflect.get(t, key);
|
|
168
202
|
return typeof value !== 'function' ? value : (...args: any[]) =>
|
|
169
|
-
(closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : value.apply(t, args));
|
|
203
|
+
(closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : track(value.apply(t, args)));
|
|
170
204
|
} });
|
|
171
205
|
const internalStore = gate(db.internalStore, async (key, first) => {
|
|
172
206
|
const ids = key === 'bulkWrite' ? first.map((row: { document: { id: string } }) => row.document.id) : first;
|
|
@@ -177,16 +211,45 @@ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefi
|
|
|
177
211
|
state.database = new Proxy(db, { get: (target, key) => (key === 'internalStore' ? internalStore : Reflect.get(target, key)) });
|
|
178
212
|
const migrateStorage = state.migrateStorage.bind(state);
|
|
179
213
|
state.migrateStorage = async (from, current, batchSize) => {
|
|
214
|
+
stopIfClosing();
|
|
215
|
+
// RxDB 17.5 sets `canceled` but never reads it, so a cancelled run would otherwise still
|
|
216
|
+
// create a checkpoint store and a replication after `cancel()` returned.
|
|
180
217
|
const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
|
|
181
|
-
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
|
|
218
|
+
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
|
|
219
|
+
stopIfClosing();
|
|
182
220
|
return migrateStorage(new Proxy(from, { get: (target, key) => {
|
|
183
221
|
const value = key === 'changeStream' ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
|
|
184
222
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
185
223
|
} }), to, batchSize);
|
|
186
224
|
};
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
225
|
+
// RxDB 17.5 hooks the database's and the collection's close to `cancel()` once the run starts
|
|
226
|
+
// (rx-migration-state.js `startMigration`), and a cancelled run stops for good: its
|
|
227
|
+
// `startMigration()` never settles. So a cancel while the run is pending settles the open:
|
|
228
|
+
// `cancelled` first makes the gates above refuse the run's later reads and writes, the open waits
|
|
229
|
+
// for the calls they already let through (a batch in flight still lands after `cancel()`, bug 2,
|
|
230
|
+
// and one landing on a store the close has closed would poison the handle, bug 6), then it
|
|
231
|
+
// rejects with `PosOrderOpenClosedError`. That covers the `db.onClose` path: the close's own
|
|
232
|
+
// handler waits for the open (up to its limit, so a give-up never hangs) before it closes storage;
|
|
233
|
+
// `cancel()`'s return does not wait for the drain. A cancel after the run has settled (the catch
|
|
234
|
+
// below closes the collection) changes nothing.
|
|
235
|
+
// This assumes 17.5's `cancel()` comes only from the close hooks. RxDB 16.x also calls it at the
|
|
236
|
+
// end of a successful run, while `running` is still true: the gates would then drop its last
|
|
237
|
+
// writes and pos_orders would never open. The wrapper depends on 17.5's `cancel()` and `startMigration()`
|
|
238
|
+
// behaviour, which changed within a minor release before, so @tallyui/pos requires rxdb ~17.5.0.
|
|
239
|
+
let running = true;
|
|
240
|
+
let onCancel!: () => void;
|
|
241
|
+
const cancelledRun = new Promise<void>((resolve) => { onCancel = resolve; });
|
|
242
|
+
const cancel = state.cancel.bind(state);
|
|
243
|
+
state.cancel = () => {
|
|
244
|
+
if (running) {
|
|
245
|
+
cancelled = true;
|
|
246
|
+
if (inFlight === 0) onCancel();
|
|
247
|
+
else drained = onCancel;
|
|
248
|
+
}
|
|
249
|
+
return cancel();
|
|
250
|
+
};
|
|
251
|
+
// Settles once the migration has: DONE, ERROR with its old storage closed, or cancelled by a close.
|
|
252
|
+
await Promise.race([state.startMigration(), cancelledRun]).finally(() => { running = false; settled.next(); });
|
|
190
253
|
stopIfClosing();
|
|
191
254
|
const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data as RxMigrationStatus | undefined;
|
|
192
255
|
stopIfClosing();
|
|
@@ -197,7 +260,7 @@ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefi
|
|
|
197
260
|
throw newRxError('DM4', { collection: collection.name, error: status?.error });
|
|
198
261
|
} catch (error) {
|
|
199
262
|
await collection.close();
|
|
200
|
-
// A backstop: a read of a store the close has closed fails on SQLite with rxdb-premium bug 5's raw
|
|
263
|
+
// A backstop: a read of a store the close has closed fails on SQLite with rxdb-premium bug 5's (still on 17.5.0) raw
|
|
201
264
|
// `ReferenceError: context is not defined`. The internal store's reads are locked runs, which the
|
|
202
265
|
// close's idle waits cover (rx-storage-helper.js:486), and no test reaches this; but once the close
|
|
203
266
|
// has given up, any failure means the open was closed under, so it gets the coded error.
|
package/src/pos-order/schema.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { addRxPlugin, type MigrationStrategies, type RxJsonSchema } from 'rxdb';
|
|
2
2
|
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
|
|
3
|
+
import { DEFAULT_TAX_ROUNDING } from '../tax/exact';
|
|
4
|
+
import { contentVersion } from './command';
|
|
3
5
|
import type { PosOrder } from './types';
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -8,9 +10,17 @@ import type { PosOrder } from './types';
|
|
|
8
10
|
* sale), and ADR-065's `display` and `taxByRate`, declared ahead of the job that writes them so a
|
|
9
11
|
* till migrates once. Create the collection with `posOrderCollection()`, never with this schema
|
|
10
12
|
* alone: RxDB refuses a version above 0 without its migration strategies.
|
|
13
|
+
* Version 3 adds an index on `sessionId` (with its `maxLength`), and the optional `sentVersion` and `downgradedFrom` (the outbox's version fallback), and changes nothing else.
|
|
14
|
+
* Version 4 adds the optional `localWarnings` and `serverFailures` (the outbox's stuck clock start, latest reason and
|
|
15
|
+
* isolation, restored when an outbox starts), and changes nothing else.
|
|
16
|
+
* Version 5 lets `sentVersion` and `downgradedFrom` be 4 (order.create version 4, #286), and changes nothing else. Its
|
|
17
|
+
* migration sets a row's missing `sentVersion` to its content version, the version any earlier attempt went out at.
|
|
18
|
+
* Like version 4 (ADR-069), it is one-way: an older build shows no orders.
|
|
19
|
+
* Version 6 adds `taxRounding`, the strategy the sale's figures were computed with (#287; never sent), and changes
|
|
20
|
+
* nothing else. Its migration sets it on every older row. It is one-way like version 5.
|
|
11
21
|
*/
|
|
12
22
|
export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
13
|
-
version:
|
|
23
|
+
version: 6, primaryKey: 'id', type: 'object', additionalProperties: false,
|
|
14
24
|
properties: {
|
|
15
25
|
id: { type: 'string', maxLength: 36 },
|
|
16
26
|
commandId: { type: 'string', maxLength: 36 },
|
|
@@ -21,7 +31,7 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
21
31
|
subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' },
|
|
22
32
|
taxMinor: { type: 'integer' }, totalMinor: { type: 'integer' },
|
|
23
33
|
syncStatus: { type: 'string', enum: ['pending', 'applied', 'rejected'], maxLength: 10 },
|
|
24
|
-
note: { type: 'string' }, registerId: { type: 'string' }, sessionId: { type: 'string' }, cashierRef: { type: 'string' },
|
|
34
|
+
note: { type: 'string' }, registerId: { type: 'string' }, sessionId: { type: 'string', maxLength: 36 }, cashierRef: { type: 'string' },
|
|
25
35
|
lines: { type: 'array', items: {
|
|
26
36
|
type: 'object', properties: {
|
|
27
37
|
id: { type: 'string', maxLength: 36 }, productId: { type: 'string' }, variantId: { type: 'string' },
|
|
@@ -49,7 +59,19 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
49
59
|
required: ['code'], additionalProperties: true,
|
|
50
60
|
} },
|
|
51
61
|
error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } }, required: ['code', 'message'] },
|
|
62
|
+
localWarnings: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
63
|
+
code: { type: 'string', maxLength: 64 }, field: { type: 'string', maxLength: 16 }, paymentId: { type: 'string', maxLength: 36 },
|
|
64
|
+
}, required: ['code'] } },
|
|
65
|
+
serverFailures: { type: 'object', additionalProperties: false, properties: {
|
|
66
|
+
since: { type: 'integer', minimum: 0 }, reason: { type: 'string', maxLength: 64 }, isolated: { type: 'boolean' },
|
|
67
|
+
}, required: ['since', 'reason', 'isolated'] },
|
|
52
68
|
lateSessionId: { type: 'string' },
|
|
69
|
+
taxRounding: { type: 'object', additionalProperties: false, properties: {
|
|
70
|
+
granularity: { type: 'string', enum: ['per_order', 'per_line_items', 'per_rate_group_items', 'custom'] },
|
|
71
|
+
mode: { type: 'string', enum: ['half_away_from_zero', 'half_up'] },
|
|
72
|
+
}, required: ['granularity'] },
|
|
73
|
+
sentVersion: { type: 'integer', minimum: 1, maximum: 4 },
|
|
74
|
+
downgradedFrom: { type: 'integer', minimum: 1, maximum: 4 },
|
|
53
75
|
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
54
76
|
display: { type: 'object', additionalProperties: false, properties: {
|
|
55
77
|
currency: { type: 'string' }, exponent: { type: 'integer' }, taxInclusive: { type: 'boolean' },
|
|
@@ -74,8 +96,8 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
74
96
|
} },
|
|
75
97
|
},
|
|
76
98
|
required: ['id', 'createdAt', 'currency', 'pricesIncludeTax', 'lines', 'subtotalMinor', 'discountMinor', 'taxMinor',
|
|
77
|
-
'totalMinor', 'payments', 'customer', 'syncStatus', 'commandId', 'updatedAt'],
|
|
78
|
-
indexes: ['createdAt', 'syncStatus', ['syncStatus', 'createdAt']],
|
|
99
|
+
'totalMinor', 'payments', 'customer', 'syncStatus', 'commandId', 'updatedAt', 'taxRounding'],
|
|
100
|
+
indexes: ['createdAt', 'syncStatus', ['syncStatus', 'createdAt'], 'sessionId'],
|
|
79
101
|
};
|
|
80
102
|
|
|
81
103
|
/**
|
|
@@ -96,5 +118,13 @@ export function posOrderCollection(): { schema: RxJsonSchema<PosOrder>; migratio
|
|
|
96
118
|
// addRxPlugin ignores a plugin it already has.
|
|
97
119
|
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
98
120
|
const identity = (doc: PosOrder) => doc;
|
|
99
|
-
|
|
121
|
+
// Every row before version 5 was built without version 4, so its content version is what any earlier attempt
|
|
122
|
+
// went out at (a downgraded row has its sentVersion already): each retry then resends those bytes (#286).
|
|
123
|
+
const recordSent = (doc: PosOrder) => { doc.sentVersion ??= contentVersion(doc); return doc; };
|
|
124
|
+
// Every row before version 6 was computed per_order + half_away_from_zero: no released build or app set another
|
|
125
|
+
// strategy (#309's `rounding` reaches a sale only through TaxProvider's props, which no app passes yet). Recording
|
|
126
|
+
// it means no older sale is ever re-rounded (#287).
|
|
127
|
+
const recordRounding = (doc: PosOrder) => { doc.taxRounding ??= { ...DEFAULT_TAX_ROUNDING }; return doc; };
|
|
128
|
+
return { schema: posOrderSchema,
|
|
129
|
+
migrationStrategies: { 1: identity, 2: identity, 3: identity, 4: identity, 5: recordSent, 6: recordRounding } };
|
|
100
130
|
}
|