@tallyui/pos 0.1.0 → 2.0.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,161 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { RxCollection, RxError } from 'rxdb';
3
+ import { createLogger } from '../logging';
4
+ import { OrderContentMismatchError, sameSale, type PosOrder } from '../pos-order';
5
+ import { watchFresh } from '../rxdb';
6
+ import { createOrderOutbox } from './order-outbox';
7
+ import type { CommandTransport, OutboxState } from './types';
8
+
9
+ const idle: OutboxState = { pending: 0, sending: false };
10
+ /** Logs a retried order found stored under another `commandId` (warn), and a content mismatch (error). */
11
+ export const outboxLogger = createLogger('outbox');
12
+
13
+ export interface UseOrderOutboxOptions {
14
+ /** Which order store to use (medusapos: the backend's base URL); `null` means no store. A change reopens. */
15
+ storeKey: string | null;
16
+ /** Opens the order store for `storeKey`; `close()` is called when the key or device id changes, or on unmount. */
17
+ open(storeKey: string): Promise<{ orders: RxCollection<PosOrder>; close(): Promise<void> }>;
18
+ /** Builds the command transport for `storeKey` (the app's HTTP transport and auth headers). Read once per open. */
19
+ transport(storeKey: string): CommandTransport;
20
+ /** The device id sent on every command (see `getDeviceId`). A change reopens. */
21
+ deviceId: string;
22
+ /** Called with `state.sending`, and with `false` on cleanup (medusapos: live-tab's `markBusy('outbox', …)`). */
23
+ onBusy?(busy: boolean): void;
24
+ /** Called when `open` rejects, even if the key has changed since (medusapos: reports storage worker failures). */
25
+ onOpenError?(error: unknown): void;
26
+ }
27
+
28
+ export interface UseOrderOutboxResult {
29
+ /** The open orders collection, or `null` until the current store is ready. */
30
+ orders: RxCollection<PosOrder> | null;
31
+ /** The outbox state, or idle until the current store is ready. */
32
+ state: OutboxState;
33
+ /** The newest 50 orders, newest first; empty until the current store is ready. */
34
+ recent: PosOrder[];
35
+ /**
36
+ * Stores a finalized order, then flushes. Throws the opening error, or "Orders are not ready.", before the store is ready.
37
+ * Recording an order whose `id` is already stored, not deleted, with the same money-bearing content (`sameSale`;
38
+ * a retried `complete()`) counts as stored, never overwrites it, and still flushes, whatever the stored `commandId`
39
+ * (a requeue mints a new one; the difference is logged at warn). Other content rejects with `OrderContentMismatchError`.
40
+ */
41
+ record(posOrder: PosOrder): Promise<void>;
42
+ /**
43
+ * Whether the current store holds this order `id`, not deleted, with the same money-bearing content (`sameSale`),
44
+ * whatever its `commandId`. A primary-key read on the storage instance, past RxDB's query cache. False before the
45
+ * store is ready; a content mismatch is false and logged at error.
46
+ */
47
+ isStored(order: PosOrder): Promise<boolean>;
48
+ /** Sends pending orders; does nothing before the current store is ready. */
49
+ flush(): Promise<void>;
50
+ /** Moves rejected orders back to pending (see `OrderOutbox.requeue`); resolves to 0 before the current store is ready. */
51
+ requeue(orderIds?: string[]): Promise<number>;
52
+ /**
53
+ * The count of `record()` calls not yet settled (resolved or rejected). An app holds sign-out
54
+ * (closing the store) while this is above 0, because a close that lands under a write stuck in
55
+ * storage can wait forever, and TallyUI deliberately doesn't bound that close (#155).
56
+ */
57
+ savesInFlight: number;
58
+ }
59
+
60
+ /** Opens the order store for `storeKey`, runs its outbox and watches the recent orders (lifted from medusapos/app, ADR-052). */
61
+ export function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxResult {
62
+ const { storeKey, deviceId } = options;
63
+ const latest = useRef(options);
64
+ latest.current = options;
65
+ const current = useRef<{
66
+ storeKey: string; orders: RxCollection<PosOrder>; outbox: ReturnType<typeof createOrderOutbox>;
67
+ } | null>(null);
68
+ const openingError = useRef<unknown>(null);
69
+ // Order ids already logged for a content mismatch (see `isStored`), for the hook's lifetime: a hung
70
+ // save's poll re-asks isStored every few seconds, and a mismatch shouldn't get a fresh error each time.
71
+ const loggedMismatches = useRef(new Set<string>());
72
+ const [orders, setOrders] = useState<RxCollection<PosOrder> | null>(null);
73
+ const [state, setState] = useState<OutboxState>(idle);
74
+ const [recent, setRecent] = useState<PosOrder[]>([]);
75
+ const savesInFlight = useRef(0);
76
+ const [savesInFlightCount, setSavesInFlightCount] = useState(0);
77
+
78
+ useEffect(() => {
79
+ let active = true;
80
+ let dispose: (() => void) | undefined;
81
+ openingError.current = null;
82
+ setOrders(null); setState(idle); setRecent([]);
83
+ if (!storeKey) return;
84
+ void latest.current.open(storeKey).then(async (store) => {
85
+ if (!active) { await store.close(); return; }
86
+ const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey) });
87
+ current.current = { storeKey, orders: store.orders, outbox };
88
+ const status = outbox.state$.subscribe(setState);
89
+ // watchFresh: find().$ can leave this stale forever, hiding a new order (RxDB 16.21.1 bug 4).
90
+ const history = watchFresh(store.orders, { sort: [{ createdAt: 'desc' }], limit: 50 }).subscribe(setRecent);
91
+ dispose = () => {
92
+ outbox.stop(); status.unsubscribe(); history.unsubscribe();
93
+ void store.close();
94
+ };
95
+ setOrders(store.orders);
96
+ outbox.start();
97
+ }).catch((error: unknown) => {
98
+ if (active) openingError.current = error;
99
+ latest.current.onOpenError?.(error);
100
+ });
101
+ return () => { active = false; current.current = null; dispose?.(); };
102
+ }, [storeKey, deviceId]);
103
+
104
+ useEffect(() => {
105
+ latest.current.onBusy?.(state.sending);
106
+ return () => latest.current.onBusy?.(false);
107
+ }, [state.sending]);
108
+
109
+ const ready = current.current?.storeKey === storeKey && !!storeKey;
110
+ return { orders: ready ? orders : null, state: ready ? state : idle, recent: ready ? recent : [],
111
+ async flush() {
112
+ const opened = current.current;
113
+ if (opened && opened.storeKey === storeKey) await opened.outbox.flush();
114
+ },
115
+ async requeue(orderIds) {
116
+ const opened = current.current;
117
+ return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
118
+ },
119
+ savesInFlight: savesInFlightCount,
120
+ async record(posOrder) {
121
+ savesInFlight.current++;
122
+ setSavesInFlightCount(savesInFlight.current);
123
+ try {
124
+ const opened = current.current;
125
+ if (!opened || opened.storeKey !== storeKey) throw openingError.current ?? new Error('Orders are not ready.');
126
+ try {
127
+ await opened.orders.insert(posOrder);
128
+ } catch (error) {
129
+ // useSale's retry hands over the order a failed save already stored (complete() is idempotent,
130
+ // ADR-052). RxDB 16's insert throws RxError code 'CONFLICT' for an existing primary key, with the
131
+ // stored document in `parameters.writeError.documentInDb`. The same id and content means it is stored, even
132
+ // under another commandId: a requeue mints one, and requiring it stuck the tender on Retry (medusapos #79).
133
+ const stored = (error as RxError)?.code === 'CONFLICT' ? (error as RxError).parameters.writeError : undefined;
134
+ const inDb = stored?.status === 409 ? stored.documentInDb : undefined;
135
+ if (!inDb || inDb._deleted) throw error;
136
+ if (!sameSale(inDb, posOrder)) throw new OrderContentMismatchError(posOrder.id);
137
+ if (inDb.commandId !== posOrder.commandId) {
138
+ outboxLogger.warn('Recorded an order stored under another commandId', { orderId: posOrder.id,
139
+ storedCommandId: inDb.commandId, recordedCommandId: posOrder.commandId });
140
+ }
141
+ }
142
+ if (current.current === opened) void opened.outbox.flush();
143
+ } finally {
144
+ savesInFlight.current--;
145
+ setSavesInFlightCount(savesInFlight.current);
146
+ }
147
+ },
148
+ async isStored(order) {
149
+ const opened = current.current;
150
+ if (!opened || opened.storeKey !== storeKey) return false;
151
+ const [stored] = await opened.orders.storageInstance.findDocumentsById([order.id], false);
152
+ if (!stored || stored._deleted) return false;
153
+ if (sameSale(stored, order)) return true;
154
+ if (!loggedMismatches.current.has(order.id)) {
155
+ loggedMismatches.current.add(order.id);
156
+ outboxLogger.error('A stored order has this id with different content', { orderId: order.id });
157
+ }
158
+ return false;
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,35 @@
1
+ import type { CommandEnvelope, OrderCreatePayload } from '@tallyui/core';
2
+ import type { PosOrder } from './types';
3
+
4
+ /**
5
+ * Builds the ADR-038 order.create envelope for a PosOrder.
6
+ * A discounted order is version 2 (ADR-062); a discount-free one stays version 1, byte-identical.
7
+ */
8
+ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt = 1): CommandEnvelope<OrderCreatePayload> {
9
+ // The order's discount is the sum of its lines', so the payload's two always agree.
10
+ const discountMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
11
+ return {
12
+ id: order.commandId, type: 'order.create', version: discountMinor > 0 ? 2 : 1, createdAt: order.createdAt, deviceId, attempt,
13
+ payload: {
14
+ clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
15
+ lines: order.lines.map((line) => ({
16
+ clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
17
+ quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
18
+ ...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
19
+ ...(line.discountMinor > 0 ? { discountMinor: line.discountMinor } : {}),
20
+ })),
21
+ payments: order.payments.map((payment) => ({
22
+ clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
23
+ ...(payment.tenderedMinor !== undefined ? { tenderedMinor: payment.tenderedMinor } : {}),
24
+ ...(payment.changeMinor !== undefined ? { changeMinor: payment.changeMinor } : {}),
25
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
26
+ })),
27
+ subtotalMinor: order.subtotalMinor,
28
+ ...(discountMinor > 0 ? { discountMinor } : {}),
29
+ taxMinor: order.taxMinor, totalMinor: order.totalMinor,
30
+ customer: order.customer?.email ? { email: order.customer.email } : null,
31
+ ...(order.registerId !== undefined ? { registerId: order.registerId } : {}),
32
+ ...(order.cashierRef !== undefined ? { cashierRef: order.cashierRef } : {}),
33
+ },
34
+ };
35
+ }
@@ -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,81 @@
1
+ import type { ServerCapabilities } from '@tallyui/core';
2
+ import type { Order } from '../order/types';
3
+ import type { PosOrder, PosOrderPayment } from './types';
4
+ import { uuidv7 } from './uuidv7';
5
+
6
+ export interface FinalizeOptions {
7
+ registerId?: string;
8
+ // No `sessionId`: `stampSession` is the only way to set a sale's session, because it checks the
9
+ // session is live. Tests and migrations that need a stamped order spread `{ ...order, sessionId }`.
10
+ cashierRef?: string;
11
+ now?: Date;
12
+ newId?: () => string;
13
+ /** The store's `order.create` capability (ADR-062); `undefined` is treated as 1. */
14
+ capabilities?: ServerCapabilities;
15
+ }
16
+
17
+ /** Turns a fully paid builder Order into a pending PosOrder without mutating it. */
18
+ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
19
+ if (!order.lineItems.length) throw new Error('finalize: no lines');
20
+ // Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
21
+ if (order.discountMinor < 0
22
+ || order.lineItems.some((line) => line.discountMinor < 0)
23
+ || order.discounts.some((d) => d.amountMinor < 0)) {
24
+ throw new Error('finalize: negative discount');
25
+ }
26
+ // 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).
27
+ // Checked as "any non-zero" rather than "> 0": defence in depth, since the builder already clamps every
28
+ // discount to >= 0, so a negative amountMinor should never reach here.
29
+ const hasDiscount = order.discountMinor !== 0
30
+ || order.lineItems.some((line) => line.discountMinor !== 0)
31
+ || order.discounts.some((d) => d.amountMinor !== 0);
32
+ if (hasDiscount && (options.capabilities?.orderCreate ?? 1) < 2) {
33
+ throw new Error('finalize: discounts are not supported by the server yet (order.create v2)');
34
+ }
35
+ for (const payment of order.payments) {
36
+ if (payment.method !== 'cash' && payment.method !== 'external') {
37
+ throw new Error(`finalize: unsupported payment method ${payment.method}`);
38
+ }
39
+ }
40
+ if (order.paidMinor < order.totalMinor) throw new Error('finalize: underpaid');
41
+ let change = order.paidMinor - order.totalMinor;
42
+ const cash = order.payments.reduce((sum, p) => sum + (p.method === 'cash' ? p.amountMinor : 0), 0);
43
+ if (change > cash) throw new Error('finalize: change exceeds cash');
44
+ const newId = options.newId ?? uuidv7;
45
+ const id = newId();
46
+ const lines = order.lineItems.map((line) => ({
47
+ id: newId(), productId: line.productId,
48
+ ...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
49
+ name: line.name, sku: line.sku, quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
50
+ discountMinor: line.discountMinor, netMinor: line.netMinor,
51
+ taxLines: line.taxLines.map((tax) => ({ ...tax })),
52
+ ...(line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}),
53
+ }));
54
+ const payments: PosOrderPayment[] = order.payments.map((payment) => ({
55
+ id: newId(), method: payment.method as PosOrderPayment['method'], amountMinor: payment.amountMinor,
56
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
57
+ }));
58
+ for (let i = payments.length - 1; i >= 0; i--) {
59
+ const payment = payments[i];
60
+ if (payment.method !== 'cash') continue;
61
+ payment.tenderedMinor = payment.amountMinor;
62
+ payment.changeMinor = Math.min(change, payment.tenderedMinor);
63
+ payment.amountMinor -= payment.changeMinor;
64
+ change -= payment.changeMinor;
65
+ }
66
+ if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
67
+ throw new Error('finalize: payments do not reconcile');
68
+ }
69
+ const now = (options.now ?? new Date()).toISOString();
70
+ return {
71
+ id, createdAt: now, updatedAt: now, commandId: newId(), syncStatus: 'pending',
72
+ currency: order.currency, pricesIncludeTax: order.pricesIncludeTax, lines, payments,
73
+ subtotalMinor: order.subtotalMinor, discountMinor: order.discountMinor,
74
+ taxMinor: order.taxMinor, totalMinor: order.totalMinor,
75
+ customer: order.customer ? { id: order.customer.id, name: order.customer.name,
76
+ ...(order.customer.email !== undefined ? { email: order.customer.email } : {}) } : null,
77
+ ...(order.note ? { note: order.note } : {}),
78
+ ...(options.registerId !== undefined ? { registerId: options.registerId } : {}),
79
+ ...(options.cashierRef !== undefined ? { cashierRef: options.cashierRef } : {}),
80
+ };
81
+ }
@@ -0,0 +1,10 @@
1
+ export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrder } from './types';
2
+ export { uuidv7 } from './uuidv7';
3
+ export { finalizeOrder } from './finalize';
4
+ export type { FinalizeOptions } from './finalize';
5
+ export { toOrderCreateEnvelope } 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,11 @@
1
+ import type { PosOrder } from './types';
2
+
3
+ /**
4
+ * The orders a cashier must look at: rejected, applied with warnings, or taken after their session
5
+ * closed (`lateSessionId`, whatever the sync status), newest first. Never changes `orders`.
6
+ */
7
+ export function needsAttention(orders: PosOrder[]): PosOrder[] {
8
+ return orders.filter((order) => order.syncStatus === 'rejected'
9
+ || (order.syncStatus === 'applied' && !!order.warnings?.length) || order.lateSessionId !== undefined)
10
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
11
+ }
@@ -0,0 +1,206 @@
1
+ import {
2
+ deepEqual, defaultConflictHandler, getChangedDocumentsSince, getRxReplicationMetaInstanceSchema, getSingleDocument, hasEncryption, newRxError,
3
+ overwritable, rxStorageInstanceToReplicationHandler, type RxCollection, type RxDatabase, type RxStorageInstance,
4
+ } from 'rxdb';
5
+ import { getOldCollectionMeta, migrateDocumentData, type RxMigrationStatus } from 'rxdb/plugins/migration-schema';
6
+ import { Subject, takeUntil } from 'rxjs';
7
+ import { createLogger } from '../logging';
8
+ import { posOrderCollection } from './schema';
9
+ import type { PosOrder } from './types';
10
+
11
+ /** `addPosOrderCollection`'s logger: attach a sink to see, for example, the status writes a closing open dropped. */
12
+ export const posOrdersLogger = createLogger('pos-orders');
13
+
14
+ /**
15
+ * After this long waiting for a stuck migration, a close gives up rather than hang forever. The
16
+ * migration is left running, but it reads or writes nothing more in a store the close closes (see
17
+ * `openPosOrders`). The next open finds the leftover status, resets it and the
18
+ * checkpoint (as any DM4 does), and migrates again. An order that run already copied is found
19
+ * equal and skipped, so the worst case is an `ERROR` status on the next open, never a lost order.
20
+ */
21
+ export const POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 10_000;
22
+
23
+ /**
24
+ * `addPosOrderCollection` stopped, before any further write, because its database is closing: it
25
+ * was called once `close()` had begun, or the close stopped waiting for it (after
26
+ * `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`). No order is lost: reopen the database and call it again.
27
+ */
28
+ export class PosOrderOpenClosedError extends Error {
29
+ readonly code = 'POS_ORDER_OPEN_CLOSED';
30
+ constructor(readonly databaseName: string) {
31
+ super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
32
+ this.name = 'PosOrderOpenClosedError';
33
+ }
34
+ }
35
+
36
+ /** Every open per database so far, settled or not: one promise, so a close waits for all of them
37
+ * across DM4 retries with one handler, and a new open's reset waits for every earlier migration. */
38
+ const latestOpens = new WeakMap<RxDatabase, Promise<unknown>>();
39
+ /** Databases whose close stopped waiting (the limit passed), so storage is closing under the open. */
40
+ const closedUnderOpen = new WeakSet<RxDatabase>();
41
+
42
+ /** Resolves true once `promise` settles, or false after `ms`. */
43
+ function waitWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean> {
44
+ return new Promise((resolve) => {
45
+ const timer = setTimeout(() => resolve(false), ms);
46
+ promise.finally(() => { clearTimeout(timer); resolve(true); });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Writes each order of the stored older version over its differing copy in the current version,
52
+ * 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 1.
54
+ */
55
+ async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any>) {
56
+ const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
57
+ for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0;
58
+ page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
59
+ const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
60
+ const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) =>
61
+ ({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
62
+ const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
63
+ // A write error (an older state the current version refuses) stops the run with DM4, as RxDB's own write does.
64
+ if (stale.length > 0) await handler.masterWrite(stale);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Adds `pos_orders` to `db` and resolves once no order of an older version is left to migrate. The one
70
+ * sanctioned way to open it (ADR-032 amendment 2). On DM4 it rejects after the migration has
71
+ * stopped and closes the collection, so the app can surface the error and open again; it never
72
+ * deletes an order.
73
+ *
74
+ * RxDB 16.21 trusts the status its last migration stored: a leftover `ERROR` rejects
75
+ * `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
76
+ * `DONE` left before a rollback resolves it before the older version's new orders have moved. So the
77
+ * collection is added without `autoMigrate`, the status and a failed run's checkpoint are reset
78
+ * (never an order or its storage), and the migration itself is awaited.
79
+ *
80
+ * A close waits for the whole open, up to `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`. Called once the
81
+ * database's close has begun, or when the close stops waiting, it rejects with
82
+ * `PosOrderOpenClosedError` (`code: 'POS_ORDER_OPEN_CLOSED'`) before any further write: reopen and
83
+ * call it again. Any other rejection (DM4, a storage error) is a real failure.
84
+ *
85
+ * `closeWaitMs` is for tests only; the first open on a database sets it for that database's close.
86
+ */
87
+ export async function addPosOrderCollection(db: RxDatabase, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS): Promise<RxCollection<PosOrder>> {
88
+ // The reset below runs before RxDB elects which tab migrates, so a second tab could reset a
89
+ // migration another tab is running. TallyUI is single-instance (ADR-061).
90
+ if (db.multiInstance) throw new Error('addPosOrderCollection: multiInstance databases are not supported (ADR-061)');
91
+ // RxDB 16.21.1 sets the private `closePromise` as `close()` begins (rx-database.js:356), and that
92
+ // close may already have read `db.onClose` (:381), so it would not wait for this open.
93
+ if (db.closed || (db as unknown as { closePromise: unknown }).closePromise) throw new PosOrderOpenClosedError(db.name);
94
+ // Registered before the first await. RxDB's close reads `db.onClose` once, when the database is
95
+ // idle (rx-database.js:381), and creating or removing a store doesn't keep it busy: a handler
96
+ // added after an await missed that close, which then closed storage under the open (2026-09-27).
97
+ const previous = latestOpens.get(db);
98
+ if (!previous) {
99
+ db.onClose.push(() => waitWithTimeout(latestOpens.get(db)!, closeWaitMs)
100
+ .then((settled) => { if (!settled) closedUnderOpen.add(db); }));
101
+ }
102
+ const opening = openPosOrders(db, previous);
103
+ latestOpens.set(db, Promise.all([previous, opening.catch(() => undefined)]));
104
+ return opening;
105
+ }
106
+
107
+ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefined): Promise<RxCollection<PosOrder>> {
108
+ // The backstop, after each await: `closed` is set only once every store is closed (rx-database.js:353).
109
+ const closing = () => closedUnderOpen.has(db) || db.closed;
110
+ const stopIfClosing = () => { if (closing()) throw new PosOrderOpenClosedError(db.name); };
111
+ const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
112
+ const state = collection.getMigrationState();
113
+ try {
114
+ stopIfClosing();
115
+ const mustMigrate = await state.mustMigrate;
116
+ stopIfClosing();
117
+ if (!mustMigrate) return collection;
118
+ // Never reset a still-settling earlier attempt's checkpoint out from under it: each new
119
+ // migration on this database starts only once the one before it has fully settled.
120
+ await previous;
121
+ stopIfClosing();
122
+ // Older-version orders exist (their collection record is there), so any stored status is a past run's.
123
+ await state.updateStatus((status) => {
124
+ // RxDB writes only what the handler changes in place.
125
+ delete status.error;
126
+ status.status = 'RUNNING';
127
+ status.count = { total: 0, handled: 0, percent: 0 };
128
+ return status;
129
+ });
130
+ // A failed run leaves its checkpoint, which can be past an order it never copied (a queued
131
+ // batch that finds its docs taken by a failed one still stores its checkpoint). The next run
132
+ // would skip that order, then remove its storage. So every run starts from the first order,
133
+ // as RxDB's own first run does; an order already copied is found equal and skipped.
134
+ const old = (await state.oldCollectionMeta)!.data.schema;
135
+ stopIfClosing();
136
+ const checkpoint = await db.storage.createStorageInstance({ databaseName: db.name, collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
137
+ databaseInstanceToken: db.token, multiInstance: db.multiInstance, options: {}, password: db.password,
138
+ schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)), devMode: overwritable.isDevMode() });
139
+ await (closing() ? checkpoint.close() : checkpoint.remove());
140
+ stopIfClosing();
141
+ // RxDB bug 2: `cancel()` stops `this.replicationState`, which `migrateStorage` never sets, so each
142
+ // run's replication outlives the run. It wakes only on the stored older version's change stream (one
143
+ // shared across instances, as on memory storage), then writes its checkpoint into the store a later
144
+ // run removed (an unhandled `removed already`) and its conflicts into the older version. So that
145
+ // 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, or 16.21 loops on it. Yet a copy is only
147
+ // ever an earlier older-version state (the collection opens only once the older version is gone), so
148
+ // the newer state goes first. A rare exception: after a rollback, an older build's one-time carry-over
149
+ // (for example medusapos's Dexie import, run again from a leftover Dexie database, after a failed
150
+ // removal or from an old tab) can bulk-insert an older state of order A into an empty older version
151
+ // while the current version holds A as sent. This overwrite then makes A pending again, and it is sent
152
+ // 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 RxDB would still
154
+ // loop on it if anything ever deleted a pos_orders document.
155
+ const settled = new Subject<void>();
156
+ // 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:381-385). A write called on a closed SQLite instance throws
158
+ // 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`,
160
+ // openWriteCount$; reproduced). So from the moment the close gives up, before it closes any store,
161
+ // the run's reads and writes of those two stores stop here. A refused one of this version rejects
162
+ // the run's push, which RxDB catches as a replication error (upstream.js:372), so the run ends in
163
+ // ERROR. Status writes are dropped instead: RxDB never awaits the per-order ones
164
+ // (rx-migration-state.js:274-278), so a rejection there would be unhandled.
165
+ const gate = <T extends object>(target: T, closed: (key: 'bulkWrite' | 'findDocumentsById', first: any[]) => Promise<unknown>): T =>
166
+ new Proxy(target, { get: (t, key) => {
167
+ const value = Reflect.get(t, key);
168
+ return typeof value !== 'function' ? value : (...args: any[]) =>
169
+ (closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : value.apply(t, args));
170
+ } });
171
+ const internalStore = gate(db.internalStore, async (key, first) => {
172
+ const ids = key === 'bulkWrite' ? first.map((row: { document: { id: string } }) => row.document.id) : first;
173
+ posOrdersLogger.warn(`The database closed during the migration: dropped a ${key === 'bulkWrite' ? 'status write' : 'status read'}`,
174
+ { database: db.name, ids });
175
+ return key === 'bulkWrite' ? { error: [] } : [];
176
+ });
177
+ state.database = new Proxy(db, { get: (target, key) => (key === 'internalStore' ? internalStore : Reflect.get(target, key)) });
178
+ const migrateStorage = state.migrateStorage.bind(state);
179
+ state.migrateStorage = async (from, current, batchSize) => {
180
+ const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
181
+ if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
182
+ return migrateStorage(new Proxy(from, { get: (target, key) => {
183
+ const value = key === 'changeStream' ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
184
+ return typeof value === 'function' ? value.bind(target) : value;
185
+ } }), to, batchSize);
186
+ };
187
+ // Settles once the migration has: DONE, or ERROR with its old storage closed. RxDB's close
188
+ // does not stop a migration, so a close waits for it rather than closing storage under it.
189
+ await state.startMigration().finally(() => settled.next());
190
+ stopIfClosing();
191
+ const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data as RxMigrationStatus | undefined;
192
+ stopIfClosing();
193
+ // RxDB deletes the older version's collection record only after every order has moved.
194
+ const oldMeta = status?.status === 'DONE' ? await getOldCollectionMeta(state) : undefined;
195
+ stopIfClosing();
196
+ if (status?.status === 'DONE' && !oldMeta) return collection;
197
+ throw newRxError('DM4', { collection: collection.name, error: status?.error });
198
+ } catch (error) {
199
+ 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
201
+ // `ReferenceError: context is not defined`. The internal store's reads are locked runs, which the
202
+ // close's idle waits cover (rx-storage-helper.js:486), and no test reaches this; but once the close
203
+ // has given up, any failure means the open was closed under, so it gets the coded error.
204
+ throw closing() ? new PosOrderOpenClosedError(db.name) : error;
205
+ }
206
+ }
@@ -0,0 +1,26 @@
1
+ import type { PosOrder } from './types';
2
+
3
+ type SaleContent = Pick<PosOrder, 'totalMinor' | 'currency' | 'lines' | 'payments'>;
4
+
5
+ /**
6
+ * Whether two orders carry the same money-bearing content: `totalMinor` and `currency`; each line's
7
+ * `id`, `quantity` and `netMinor`, in order; each payment's `method` and `amountMinor`, in order.
8
+ * An order `id` is minted once per sale (`finalizeOrder`), so the same `id` with other content is a defect.
9
+ */
10
+ export function sameSale(stored: SaleContent, retried: SaleContent): boolean {
11
+ return stored.totalMinor === retried.totalMinor && stored.currency === retried.currency
12
+ && stored.lines.length === retried.lines.length
13
+ && stored.lines.every((line, i) => { const other = retried.lines[i];
14
+ return line.id === other.id && line.quantity === other.quantity && line.netMinor === other.netMinor; })
15
+ && stored.payments.length === retried.payments.length
16
+ && stored.payments.every((payment, i) => payment.method === retried.payments[i].method
17
+ && payment.amountMinor === retried.payments[i].amountMinor);
18
+ }
19
+
20
+ /** A stored order has this order's `id` but other money-bearing content (see `sameSale`): never treated as stored. */
21
+ export class OrderContentMismatchError extends Error {
22
+ constructor(readonly orderId: string) {
23
+ super(`Order ${orderId} is already stored with different content`);
24
+ this.name = 'OrderContentMismatchError';
25
+ }
26
+ }