@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,269 @@
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 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.
23
+ */
24
+ export const POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 10_000;
25
+
26
+ /**
27
+ * `addPosOrderCollection` stopped, before any further write, because its database is closing: it
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.
31
+ */
32
+ export class PosOrderOpenClosedError extends Error {
33
+ readonly code = 'POS_ORDER_OPEN_CLOSED';
34
+ constructor(readonly databaseName: string) {
35
+ super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
36
+ this.name = 'PosOrderOpenClosedError';
37
+ }
38
+ }
39
+
40
+ /** Every open per database so far, settled or not: one promise, so a close waits for all of them
41
+ * across DM4 retries with one handler, and a new open's reset waits for every earlier migration. */
42
+ const latestOpens = new WeakMap<RxDatabase, Promise<unknown>>();
43
+ /** Databases whose close stopped waiting (the limit passed), so storage is closing under the open. */
44
+ const closedUnderOpen = new WeakSet<RxDatabase>();
45
+
46
+ /** Resolves true once `promise` settles, or false after `ms`. */
47
+ function waitWithTimeout(promise: Promise<unknown>, ms: number): Promise<boolean> {
48
+ return new Promise((resolve) => {
49
+ const timer = setTimeout(() => resolve(false), ms);
50
+ promise.finally(() => { clearTimeout(timer); resolve(true); });
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Writes each order of the stored older version over its differing copy in the current version,
56
+ * through the same write RxDB's migration makes, and leaves orders without a copy (and any deleted
57
+ * one) to the migration. `from` is whichever older version is stored, 0, 1 or 2.
58
+ */
59
+ async function writeOverStaleCopies(collection: RxCollection, from: RxStorageInstance<any, any, any>, to: RxStorageInstance<any, any, any>,
60
+ stopIfClosing: () => void) {
61
+ const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
62
+ for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0;
63
+ page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
64
+ stopIfClosing();
65
+ const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
66
+ const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) =>
67
+ ({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
68
+ const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
69
+ // A write error (an older state the current version refuses) stops the run with DM4, as RxDB's own write does.
70
+ if (stale.length > 0) await handler.masterWrite(stale);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Adds `pos_orders` to `db` and resolves once no order of an older version is left to migrate. The one
76
+ * sanctioned way to open it (ADR-032 amendment 2). On DM4 it rejects after the migration has
77
+ * stopped and closes the collection, so the app can surface the error and open again; it never
78
+ * deletes an order.
79
+ *
80
+ * RxDB 17.5.0 trusts the status its last migration stored: a leftover `ERROR` rejects
81
+ * `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
82
+ * `DONE` left before a rollback resolves it before the older version's new orders have moved. So the
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.
88
+ *
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.
94
+ *
95
+ * `closeWaitMs` is for tests only; the first open on a database sets it for that database's close.
96
+ */
97
+ export async function addPosOrderCollection(db: RxDatabase, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS): Promise<RxCollection<PosOrder>> {
98
+ // The reset below runs before RxDB elects which tab migrates, so a second tab could reset a
99
+ // migration another tab is running. TallyUI is single-instance (ADR-061).
100
+ if (db.multiInstance) throw new Error('addPosOrderCollection: multiInstance databases are not supported (ADR-061)');
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.
103
+ if (db.closed || (db as unknown as { closePromise: unknown }).closePromise) throw new PosOrderOpenClosedError(db.name);
104
+ // Registered before the first await. RxDB's close reads `db.onClose` once, when the database is
105
+ // idle (rx-database.js:473-476), and creating or removing a store doesn't keep it busy: a handler
106
+ // added after an await missed that close, which then closed storage under the open (2026-09-27).
107
+ const previous = latestOpens.get(db);
108
+ if (!previous) {
109
+ db.onClose.push(() => waitWithTimeout(latestOpens.get(db)!, closeWaitMs)
110
+ .then((settled) => { if (!settled) closedUnderOpen.add(db); }));
111
+ }
112
+ const opening = openPosOrders(db, previous);
113
+ latestOpens.set(db, Promise.all([previous, opening.catch(() => undefined)]));
114
+ return opening;
115
+ }
116
+
117
+ async function openPosOrders(db: RxDatabase, previous: Promise<unknown> | undefined): Promise<RxCollection<PosOrder>> {
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;
122
+ const stopIfClosing = () => { if (closing()) throw new PosOrderOpenClosedError(db.name); };
123
+ const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
124
+ const state = collection.getMigrationState();
125
+ try {
126
+ stopIfClosing();
127
+ const mustMigrate = await state.mustMigrate;
128
+ stopIfClosing();
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
+ }
137
+ // Never reset a still-settling earlier attempt's checkpoint out from under it: each new
138
+ // migration on this database starts only once the one before it has fully settled.
139
+ await previous;
140
+ stopIfClosing();
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.
145
+ await state.updateStatus((status) => {
146
+ // RxDB writes only what the handler changes in place.
147
+ delete status.error;
148
+ status.status = 'RUNNING';
149
+ status.count = { total: 0, handled: 0, percent: 0 };
150
+ return status;
151
+ });
152
+ // RxDB bug 1, which still reproduces on 17.5.0 (repro 2026-09-29):
153
+ // A failed run leaves its checkpoint, which can be past an order it never copied (a queued
154
+ // batch that finds its docs taken by a failed one still stores its checkpoint). The next run
155
+ // would skip that order, then remove its storage. So every run starts from the first order,
156
+ // as RxDB's own first run does; an order already copied is found equal and skipped.
157
+ const old = (await state.oldCollectionMeta)!.data.schema;
158
+ stopIfClosing();
159
+ const checkpoint = await db.storage.createStorageInstance({ databaseName: db.name, collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
160
+ databaseInstanceToken: db.token, multiInstance: db.multiInstance, options: {}, password: db.password,
161
+ schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)), devMode: overwritable.isDevMode() });
162
+ await (closing() ? checkpoint.close() : checkpoint.remove());
163
+ stopIfClosing();
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
167
+ // shared across instances, as on memory storage), then writes its checkpoint into the store a later
168
+ // run removed (an unhandled `removed already`) and its conflicts into the older version. So that
169
+ // stream ends once the run has settled. And RxDB never overwrites a copy in the current version (it
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
172
+ // ever an earlier older-version state (the collection opens only once the older version is gone), so
173
+ // the newer state goes first. A rare exception: after a rollback, an older build's one-time carry-over
174
+ // (for example medusapos's Dexie import, run again from a leftover Dexie database, after a failed
175
+ // removal or from an old tab) can bulk-insert an older state of order A into an empty older version
176
+ // while the current version holds A as sent. This overwrite then makes A pending again, and it is sent
177
+ // twice; the server's commandId idempotency is what stops a double charge. Also, a deleted
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).
180
+ const settled = new Subject<void>();
181
+ // Once the close stops waiting it closes this version's store and the internal store while RxDB's
182
+ // migration runs on (rx-database.js:476-485). A write called on a closed SQLite instance throws
183
+ // inside its transaction and poisons every later write on the handle until restart (rxdb-premium
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`,
185
+ // openWriteCount$; reproduced). So from the moment the close gives up, before it closes any store,
186
+ // the run's reads and writes of those two stores stop here. A refused one of this version rejects
187
+ // the run's push, which RxDB catches as a replication error (upstream.js:372), so the run ends in
188
+ // ERROR. Status writes are dropped instead: RxDB never awaits the per-order ones
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
+ };
199
+ const gate = <T extends object>(target: T, closed: (key: 'bulkWrite' | 'findDocumentsById', first: any[]) => Promise<unknown>): T =>
200
+ new Proxy(target, { get: (t, key) => {
201
+ const value = Reflect.get(t, key);
202
+ return typeof value !== 'function' ? value : (...args: any[]) =>
203
+ (closing() && (key === 'bulkWrite' || key === 'findDocumentsById') ? closed(key, args[0]) : track(value.apply(t, args)));
204
+ } });
205
+ const internalStore = gate(db.internalStore, async (key, first) => {
206
+ const ids = key === 'bulkWrite' ? first.map((row: { document: { id: string } }) => row.document.id) : first;
207
+ posOrdersLogger.warn(`The database closed during the migration: dropped a ${key === 'bulkWrite' ? 'status write' : 'status read'}`,
208
+ { database: db.name, ids });
209
+ return key === 'bulkWrite' ? { error: [] } : [];
210
+ });
211
+ state.database = new Proxy(db, { get: (target, key) => (key === 'internalStore' ? internalStore : Reflect.get(target, key)) });
212
+ const migrateStorage = state.migrateStorage.bind(state);
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.
217
+ const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
218
+ if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
219
+ stopIfClosing();
220
+ return migrateStorage(new Proxy(from, { get: (target, key) => {
221
+ const value = key === 'changeStream' ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
222
+ return typeof value === 'function' ? value.bind(target) : value;
223
+ } }), to, batchSize);
224
+ };
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(); });
253
+ stopIfClosing();
254
+ const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data as RxMigrationStatus | undefined;
255
+ stopIfClosing();
256
+ // RxDB deletes the older version's collection record only after every order has moved.
257
+ const oldMeta = status?.status === 'DONE' ? await getOldCollectionMeta(state) : undefined;
258
+ stopIfClosing();
259
+ if (status?.status === 'DONE' && !oldMeta) return collection;
260
+ throw newRxError('DM4', { collection: collection.name, error: status?.error });
261
+ } catch (error) {
262
+ await collection.close();
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
264
+ // `ReferenceError: context is not defined`. The internal store's reads are locked runs, which the
265
+ // close's idle waits cover (rx-storage-helper.js:486), and no test reaches this; but once the close
266
+ // has given up, any failure means the open was closed under, so it gets the coded error.
267
+ throw closing() ? new PosOrderOpenClosedError(db.name) : error;
268
+ }
269
+ }
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ import { addRxPlugin, type MigrationStrategies, type RxJsonSchema } from 'rxdb';
2
+ import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
3
+ import { DEFAULT_TAX_ROUNDING } from '../tax/exact';
4
+ import { contentVersion } from './command';
5
+ import type { PosOrder } from './types';
6
+
7
+ /**
8
+ * Version 1 adds the optional `sessionId` (ADR-032); nothing else changed from version 0.
9
+ * Version 2 adds three optional fields and changes nothing else: `lateSessionId` (ADR-032, late
10
+ * sale), and ADR-065's `display` and `taxByRate`, declared ahead of the job that writes them so a
11
+ * till migrates once. Create the collection with `posOrderCollection()`, never with this schema
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.
21
+ */
22
+ export const posOrderSchema: RxJsonSchema<PosOrder> = {
23
+ version: 6, primaryKey: 'id', type: 'object', additionalProperties: false,
24
+ properties: {
25
+ id: { type: 'string', maxLength: 36 },
26
+ commandId: { type: 'string', maxLength: 36 },
27
+ createdAt: { type: 'string', maxLength: 40 },
28
+ updatedAt: { type: 'string' },
29
+ currency: { type: 'string' },
30
+ pricesIncludeTax: { type: 'boolean' },
31
+ subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' },
32
+ taxMinor: { type: 'integer' }, totalMinor: { type: 'integer' },
33
+ syncStatus: { type: 'string', enum: ['pending', 'applied', 'rejected'], maxLength: 10 },
34
+ note: { type: 'string' }, registerId: { type: 'string' }, sessionId: { type: 'string', maxLength: 36 }, cashierRef: { type: 'string' },
35
+ lines: { type: 'array', items: {
36
+ type: 'object', properties: {
37
+ id: { type: 'string', maxLength: 36 }, productId: { type: 'string' }, variantId: { type: 'string' },
38
+ name: { type: 'string' }, sku: { type: 'string' }, quantity: { type: 'integer' },
39
+ unitPriceMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, netMinor: { type: 'integer' },
40
+ taxLines: { type: 'array', items: {
41
+ type: 'object', properties: { code: { type: 'string' }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' } },
42
+ required: ['ratePpm', 'taxMicros'],
43
+ } },
44
+ },
45
+ required: ['id', 'productId', 'name', 'sku', 'quantity', 'unitPriceMinor', 'discountMinor', 'netMinor', 'taxLines'],
46
+ } },
47
+ payments: { type: 'array', items: {
48
+ type: 'object', properties: {
49
+ id: { type: 'string', maxLength: 36 }, method: { type: 'string', enum: ['cash', 'external'] },
50
+ amountMinor: { type: 'integer' }, tenderedMinor: { type: 'integer' }, changeMinor: { type: 'integer' }, reference: { type: 'string' },
51
+ }, required: ['id', 'method', 'amountMinor'],
52
+ } },
53
+ customer: { type: ['object', 'null'], properties: { id: { type: 'string' }, name: { type: 'string' }, email: { type: 'string' } } },
54
+ serverRefs: { type: 'object', properties: {
55
+ orderId: { type: 'string' }, displayId: { type: 'string' }, totalMinor: { type: 'integer' },
56
+ }, required: ['orderId', 'totalMinor'] },
57
+ warnings: { type: 'array', items: {
58
+ type: 'object', properties: { code: { type: 'string', maxLength: 64 } },
59
+ required: ['code'], additionalProperties: true,
60
+ } },
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'] },
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 },
75
+ // The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
76
+ display: { type: 'object', additionalProperties: false, properties: {
77
+ currency: { type: 'string' }, exponent: { type: 'integer' }, taxInclusive: { type: 'boolean' },
78
+ subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, taxMinor: { type: 'integer' },
79
+ totalMinor: { type: 'integer' }, orderDiscountMinor: { type: 'integer' },
80
+ lines: { type: 'array', items: {
81
+ type: 'object', additionalProperties: false, properties: {
82
+ lineId: { type: 'string' }, amountMinor: { type: 'integer' },
83
+ discounts: { type: 'array', items: {
84
+ type: 'object', additionalProperties: false,
85
+ properties: { discountId: { type: 'string' }, label: { type: 'string' }, amountMinor: { type: 'integer' } },
86
+ required: ['discountId', 'amountMinor'],
87
+ } },
88
+ }, required: ['lineId', 'amountMinor', 'discounts'],
89
+ } },
90
+ }, required: ['currency', 'exponent', 'taxInclusive', 'subtotalMinor', 'discountMinor', 'taxMinor', 'totalMinor', 'orderDiscountMinor', 'lines'] },
91
+ taxByRate: { type: 'array', items: {
92
+ type: 'object', additionalProperties: false, properties: {
93
+ ratePpm: { type: 'integer' }, code: { type: 'string' }, label: { type: 'string' }, netMinor: { type: 'integer' },
94
+ amountMinor: { type: 'integer' }, grossMinor: { type: 'integer' },
95
+ }, required: ['ratePpm', 'netMinor', 'amountMinor', 'grossMinor'],
96
+ } },
97
+ },
98
+ required: ['id', 'createdAt', 'currency', 'pricesIncludeTax', 'lines', 'subtotalMinor', 'discountMinor', 'taxMinor',
99
+ 'totalMinor', 'payments', 'customer', 'syncStatus', 'commandId', 'updatedAt', 'taxRounding'],
100
+ indexes: ['createdAt', 'syncStatus', ['syncStatus', 'createdAt'], 'sessionId'],
101
+ };
102
+
103
+ /**
104
+ * The `pos_orders` collection config, with its migration strategies. Open the collection with
105
+ * `addPosOrderCollection(db)`, which uses this and settles the migration safely; adding this config
106
+ * directly leaves RxDB's own open path. `pos_orders` holds sales not yet sent, so no step may drop a
107
+ * document: versions 1 and 2 only add optional fields, so every order from version 0 or 1 passes
108
+ * unchanged.
109
+ *
110
+ * Within one run, RxDB 16.21 keeps an order that fails the new schema's validation: with a
111
+ * validating storage the migration stops with DM4 and the order stays in the older version's
112
+ * storage; without one it is copied as is. **Across runs, RxDB's own open path can lose it**: a
113
+ * failed run can leave its checkpoint past that order, and the next run then removes the older
114
+ * version's storage without copying it. `addPosOrderCollection` resets that checkpoint, so use it
115
+ * (`migration.test.ts`).
116
+ */
117
+ export function posOrderCollection(): { schema: RxJsonSchema<PosOrder>; migrationStrategies: MigrationStrategies } {
118
+ // addRxPlugin ignores a plugin it already has.
119
+ addRxPlugin(RxDBMigrationSchemaPlugin);
120
+ const identity = (doc: PosOrder) => doc;
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 } };
130
+ }
@@ -0,0 +1,93 @@
1
+ import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind, TaxRounding } from '@tallyui/core';
2
+ import type { DisplayTotals } from '../order/types';
3
+
4
+ export type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
5
+
6
+ export type PosOrderLocalWarning = { code: 'customer_omitted'; field: 'email' | 'id' }
7
+ | { code: 'payment_reference_dropped'; paymentId: string };
8
+ /** The outbox's server-failure state for a pending order, kept so a restart restores it. */
9
+ export interface PosOrderServerFailures {
10
+ /** The stuck clock's start in ms: the outbox's virtual start, which leaves out offline gaps. */
11
+ since: number;
12
+ /** The latest server-answered failure reason. */
13
+ reason: string;
14
+ /** The order failed when sent alone (as a probe or while isolated), so it is retried alone. */
15
+ isolated: boolean;
16
+ }
17
+
18
+ export interface PosOrderLine {
19
+ id: string;
20
+ productId: string;
21
+ variantId?: string;
22
+ name: string;
23
+ sku: string;
24
+ quantity: number;
25
+ unitPriceMinor: number;
26
+ /** Line discounts plus the allocated order-discount share, in the line's own tax mode (ADR-062). */
27
+ discountMinor: number;
28
+ netMinor: number;
29
+ taxLines: Array<{ code?: string; ratePpm: number; taxMicros: string }>;
30
+ /** This line's own tax mode; set only when it was converted from the store's (ADR-038 amendment). */
31
+ taxInclusive?: boolean;
32
+ }
33
+
34
+ export interface PosOrderPayment {
35
+ id: string;
36
+ method: PaymentMethodKind;
37
+ amountMinor: number;
38
+ tenderedMinor?: number;
39
+ changeMinor?: number;
40
+ reference?: string;
41
+ }
42
+
43
+ export interface PosOrder {
44
+ id: string;
45
+ createdAt: string;
46
+ currency: string;
47
+ pricesIncludeTax: boolean;
48
+ lines: PosOrderLine[];
49
+ subtotalMinor: number;
50
+ discountMinor: number;
51
+ taxMinor: number;
52
+ totalMinor: number;
53
+ payments: PosOrderPayment[];
54
+ customer: { id?: string; name?: string; email?: string } | null;
55
+ note?: string;
56
+ registerId?: string;
57
+ /**
58
+ * The register session the sale was taken in (ADR-032): its closure counts this order.
59
+ * Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
60
+ */
61
+ sessionId?: string;
62
+ /**
63
+ * Set only by `useSale`'s late-sale path (ADR-032): the session the sale was taken for, which
64
+ * refused the stamp after the money was taken. Such an order has no `sessionId`, so no closure
65
+ * counts it. Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
66
+ */
67
+ lateSessionId?: string;
68
+ /**
69
+ * The order.create version every attempt under this `commandId` goes out at: the outbox records it before the first
70
+ * send, and lowers it only on a downgrade (ADR-065 amendment). Absent means not sent yet (or requeued).
71
+ */
72
+ sentVersion?: 1 | 2 | 3 | 4;
73
+ /** The version first tried, before the downgrade (the order's audit). */
74
+ downgradedFrom?: 1 | 2 | 3 | 4;
75
+ /** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. */
76
+ display?: DisplayTotals & { currency: string; exponent: number };
77
+ /** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). */
78
+ taxByRate?: Array<{ ratePpm: number; code?: string; label?: string; netMinor: number; amountMinor: number; grossMinor: number }>;
79
+ /**
80
+ * The tax rounding the figures were computed with (#287), frozen at finalize; the till's own record, never sent.
81
+ * Version 6's migration sets the default on every older row.
82
+ */
83
+ taxRounding: TaxRounding;
84
+ cashierRef?: string;
85
+ syncStatus: PosOrderSyncStatus;
86
+ commandId: string;
87
+ serverRefs?: CommandServerRefs;
88
+ warnings?: CommandWarning[];
89
+ localWarnings?: PosOrderLocalWarning[];
90
+ serverFailures?: PosOrderServerFailures;
91
+ error?: CommandError;
92
+ updatedAt: string;
93
+ }
@@ -0,0 +1,54 @@
1
+ function format(ms: number, bytes: Uint8Array): string {
2
+ let timestamp = BigInt(ms);
3
+ for (let i = 5; i >= 0; i--) {
4
+ bytes[i] = Number(timestamp & 0xffn);
5
+ timestamp >>= 8n;
6
+ }
7
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
8
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
9
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
10
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
11
+ }
12
+
13
+ export function createUuidv7(options?: {
14
+ now?: () => number;
15
+ randomBytes?: (n: number) => Uint8Array;
16
+ }): () => string {
17
+ let lastMs = -Infinity;
18
+ let lastRand = 0n;
19
+ return () => {
20
+ const randomBytes = options?.randomBytes;
21
+ if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error('uuidv7: no random source');
22
+ let ms = Math.max((options?.now ?? Date.now)(), lastMs);
23
+ let rand = lastRand + 1n;
24
+ if (ms === lastMs && rand === 2n ** 74n) ms = lastMs + 1;
25
+ if (ms !== lastMs) {
26
+ const bytes = randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16));
27
+ rand = (BigInt(bytes[6] & 0x0f) << 8n) | BigInt(bytes[7]);
28
+ rand = (rand << 6n) | BigInt(bytes[8] & 0x3f);
29
+ for (let i = 9; i < 16; i++) rand = (rand << 8n) | BigInt(bytes[i]);
30
+ }
31
+ lastMs = ms;
32
+ lastRand = rand;
33
+ const bytes = new Uint8Array(16);
34
+ for (let i = 15; i >= 9; i--) {
35
+ bytes[i] = Number(rand & 0xffn);
36
+ rand >>= 8n;
37
+ }
38
+ bytes[8] = Number(rand & 0x3fn);
39
+ rand >>= 6n;
40
+ bytes[7] = Number(rand & 0xffn);
41
+ bytes[6] = Number(rand >> 8n);
42
+ return format(ms, bytes);
43
+ };
44
+ }
45
+
46
+ const monotonic = createUuidv7();
47
+
48
+ /** RFC 9562 UUIDv7: monotonic with no arguments; stateless with an explicit timestamp or random source. */
49
+ export function uuidv7(now?: number, randomBytes?: (n: number) => Uint8Array): string {
50
+ if (now === undefined && randomBytes === undefined) return monotonic();
51
+ const ms = now ?? Date.now();
52
+ if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error('uuidv7: no random source');
53
+ return format(ms, randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16)));
54
+ }
@@ -0,0 +1,2 @@
1
+ export { searchProducts } from './search-products';
2
+ export { withStockOverlay, getProductStock, stockOverlay$, stockOverlayAsOf$ } from './stock';
@@ -0,0 +1,32 @@
1
+ import type { ProductTraits } from '@tallyui/core';
2
+
3
+ /**
4
+ * Filters product documents by a cashier's search term, through traits, so
5
+ * it works the same on every backend.
6
+ *
7
+ * Every word of the term must appear in the product name, SKU or barcode
8
+ * (case-insensitive, any order). A term that exactly equals a barcode or SKU
9
+ * returns just those products, so a scanner hit is never buried among name
10
+ * matches. An empty term returns the input unchanged.
11
+ */
12
+ export function searchProducts<Doc>(docs: Doc[], term: string, traits: ProductTraits<Doc>): Doc[] {
13
+ const query = term.trim().toLowerCase();
14
+ if (!query) return docs;
15
+
16
+ const codes = (doc: Doc) =>
17
+ [...new Set([
18
+ traits.getSku(doc), traits.getBarcode(doc),
19
+ ...(traits.getVariants?.(doc) ?? []).flatMap((variant) => [variant.sku, variant.barcode]),
20
+ ]
21
+ .filter((code): code is string => Boolean(code))
22
+ .map((code) => code.toLowerCase()))];
23
+
24
+ const exact = docs.filter((doc) => codes(doc).includes(query));
25
+ if (exact.length) return exact;
26
+
27
+ const words = query.split(/\s+/);
28
+ return docs.filter((doc) => {
29
+ const haystack = [traits.getName(doc).toLowerCase(), ...codes(doc)].join(' ');
30
+ return words.every((word) => haystack.includes(word));
31
+ });
32
+ }
@@ -0,0 +1,25 @@
1
+ import type { RxCollection } from 'rxdb';
2
+ import { map, type Observable } from 'rxjs';
3
+
4
+ import { STOCK_LEVELS_LAST_PASS } from '@tallyui/core';
5
+
6
+ // The pure helpers live in core so components can use them without RxDB.
7
+ export { withStockOverlay, getProductStock } from '@tallyui/core';
8
+
9
+ /** The `stock_levels` collection as a live map of key to stock value. */
10
+ export function stockOverlay$(collection: RxCollection): Observable<Map<string, unknown>> {
11
+ return collection.find().$.pipe(
12
+ map((rows) => new Map(rows.map((row) => [row.primary, row.get('value')] as [string, unknown]))),
13
+ );
14
+ }
15
+
16
+ /**
17
+ * When the overlay was last confirmed by a successful pass (ISO 8601), or
18
+ * undefined before any pass. Read from the collection's local document, so a
19
+ * tab or screen that does not hold the runner can show it too.
20
+ */
21
+ export function stockOverlayAsOf$(collection: RxCollection): Observable<string | undefined> {
22
+ return collection.getLocal$<{ completedAt: string }>(STOCK_LEVELS_LAST_PASS).pipe(
23
+ map((doc) => doc?.get('completedAt') as string | undefined),
24
+ );
25
+ }