@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,250 @@
1
+ import type { CommandResult, RegisterCommandEnvelope } from '@tallyui/core';
2
+ import type { RxCollection } from 'rxdb';
3
+ import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
4
+ import { registerCommandsLogger, type RegisterCommand } from '../register/register-commands';
5
+ import { countFresh, readFresh } from '../rxdb';
6
+ import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
7
+ import { STUCK_AFTER_MS } from './order-outbox';
8
+ import type { CommandTransport, OutboxState } from './types';
9
+
10
+ export interface RegisterOutboxOptions {
11
+ collection: RxCollection<RegisterCommand>;
12
+ transport: CommandTransport<RegisterCommandEnvelope>;
13
+ deviceId: string;
14
+ /** Checked at the start of every run; false sends nothing. */
15
+ isEnabled?: () => boolean;
16
+ /** Runs before marking, so a crash resends; a throw is logged and marking continues. */
17
+ onResult?: (command: RegisterCommand, result: CommandResult) => Promise<void> | void;
18
+ batchSize?: number;
19
+ initialBackoffMs?: number;
20
+ maxBackoffMs?: number;
21
+ random?: () => number;
22
+ now?: () => number;
23
+ /** Tests only: overrides STUCK_AFTER_MS. */
24
+ stuckAfterMs?: number;
25
+ /** Counts 404s toward OutboxState.backendMissing; pass the order outbox's too, so either one's 404s show one notice. */
26
+ backendNotFound?: BackendNotFound;
27
+ }
28
+ export interface RegisterOutbox {
29
+ flush(): Promise<void>;
30
+ start(): void;
31
+ stop(): void;
32
+ state$: Observable<OutboxState>;
33
+ }
34
+
35
+ function plainCommand({ key, registerId, seq, commandId, type, version, payload, createdAt, syncStatus,
36
+ error, result, updatedAt }: RegisterCommand): RegisterCommand {
37
+ return structuredClone({ key, registerId, seq, commandId, type, version, payload, createdAt, syncStatus,
38
+ ...(error ? { error } : {}), ...(result ? { result } : {}), updatedAt });
39
+ }
40
+
41
+ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox {
42
+ const { collection, transport, deviceId } = options;
43
+ const size = options.batchSize;
44
+ const batchSize = Math.max(1, Math.min(typeof size === 'number' && Number.isFinite(size) ? Math.floor(size) : 10, 10));
45
+ const initialBackoff = options.initialBackoffMs ?? 1000;
46
+ const maxBackoff = options.maxBackoffMs ?? 60000;
47
+ const random = options.random ?? Math.random;
48
+ const now = options.now ?? Date.now;
49
+ const state$ = new BehaviorSubject<OutboxState>({ pending: 0, sending: false });
50
+ const backendNotFound = options.backendNotFound ?? createBackendNotFound();
51
+ let missingSubscription: Subscription | undefined; // stop() lets go of the (maybe shared) tracker; start() and flush() take it up again
52
+ const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
53
+ if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
54
+ });
55
+ watchBackendMissing();
56
+ const attempts = new Map<string, number>();
57
+ let backoff = initialBackoff;
58
+ let unauthorizedSinceAccepted = 0;
59
+ let running: Promise<void> | undefined;
60
+ let timer: ReturnType<typeof setTimeout> | undefined;
61
+ let subscription: Subscription | undefined;
62
+ let stopped = false;
63
+ let insertedDuringRun = false;
64
+ let stoppedDuringRun = false;
65
+ const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
66
+ // Each command's stuck clock counts only answered time, by the order outbox's rules. It starts when a batch that
67
+ // sent the command fails with a failure the store answered (a `retry` other than `network`, where `timeout` counts
68
+ // like a 503, or `no_progress`); a running clock keeps its `since` and takes the latest reason. A `network` failure
69
+ // pauses every clock (`pausedAt`), and the next answer of any kind resumes them, moving `since` on by the offline
70
+ // gap, so `since` is now minus the answered time. A clock clears when its command is no longer pending under its
71
+ // commandId (applied or rejected), or when a rejected command comes ahead of it in its ledger (it is not being
72
+ // sent: Front desk, 2026-09-30); commands never sent have none. Nothing is isolated: a stuck command at the head
73
+ // of a register's ledger holds up the commands behind it, by design, since register facts apply in `seq` order.
74
+ // In memory only: a restart starts with no clocks, and the first answered failure after it starts them afresh
75
+ // (accepted: register commands are few, and a restart re-sends at once).
76
+ const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
77
+ let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
78
+
79
+ function stuckState(): OutboxState['stuck'] {
80
+ const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
81
+ if (!stuck.length) return undefined;
82
+ return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
83
+ reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
84
+ orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
85
+ }
86
+ function serverFailed(commands: RegisterCommand[], reason: string) {
87
+ const at = now();
88
+ for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
89
+ }
90
+
91
+ async function updateState(patch: Partial<OutboxState> = {}) {
92
+ const pending = await countFresh(collection, { syncStatus: 'pending' });
93
+ // Only commands in a register's sendable prefix (pending, before its first rejected command) keep a clock.
94
+ const sendable = new Set<string>();
95
+ const blocked = new Set<string>();
96
+ if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
97
+ sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
98
+ if (command.syncStatus === 'rejected') blocked.add(command.registerId);
99
+ else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
100
+ }
101
+ for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
102
+ state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
103
+ }
104
+ function scheduleRetry(reason: string, retryAfterMs = 0) {
105
+ state$.next({ ...state$.value, lastRetryReason: reason, stuck: stuckState() });
106
+ if (stopped) { stoppedDuringRun = true; return; }
107
+ const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
108
+ backoff = Math.min(backoff * 2, maxBackoff);
109
+ timer = setTimeout(() => { timer = undefined; flush().catch(() => {}); }, delay);
110
+ state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
111
+ }
112
+
113
+ async function run() {
114
+ stoppedDuringRun = false;
115
+ while (!stopped) try {
116
+ await updateState({ sending: true, nextAttemptAt: undefined });
117
+ insertedDuringRun = false;
118
+ if (options.isEnabled?.() === false) return;
119
+ const pending = await readFresh(collection, { selector: { syncStatus: 'pending' } });
120
+ const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
121
+ let sent = false;
122
+ for (const registerId of registerIds) {
123
+ const ledger = await readFresh(collection, {
124
+ selector: { registerId, syncStatus: { $in: ['pending', 'rejected'] } },
125
+ sort: [{ seq: 'asc' }, { key: 'asc' }],
126
+ });
127
+ const commands: RegisterCommand[] = [];
128
+ for (const command of ledger) {
129
+ if (command.syncStatus === 'rejected') break;
130
+ commands.push(command);
131
+ if (commands.length >= batchSize) break;
132
+ }
133
+ if (stopped) { stoppedDuringRun = true; return; }
134
+ if (!commands.length) continue;
135
+ sent = true;
136
+ const batch: RegisterCommandEnvelope[] = commands.map((doc) => {
137
+ const attempt = (attempts.get(doc.commandId) ?? 0) + 1;
138
+ attempts.set(doc.commandId, attempt);
139
+ return { id: doc.commandId, type: doc.type, version: doc.version, payload: doc.payload,
140
+ createdAt: doc.createdAt, deviceId, attempt };
141
+ });
142
+ const outcome = await transport.send(batch);
143
+ backendNotFound.record(outcome, now());
144
+ const at = now();
145
+ if (outcome.kind === 'retry' && outcome.reason === 'network') for (const clock of clocks.values()) clock.pausedAt ??= at;
146
+ else for (const clock of clocks.values()) if (clock.pausedAt !== undefined) {
147
+ clock.since += at - clock.pausedAt;
148
+ clock.pausedAt = undefined;
149
+ }
150
+ if (outcome.kind === 'retry') {
151
+ if (outcome.reason !== 'network') serverFailed(commands, outcome.reason);
152
+ return scheduleRetry(outcome.reason, outcome.retryAfterMs);
153
+ }
154
+ if (outcome.kind === 'unauthorized') {
155
+ unauthorizedSinceAccepted++;
156
+ if (unauthorizedSinceAccepted < 3) return scheduleRetry('unauthorized');
157
+ state$.next({ ...state$.value, authRequired: true, lastRetryReason: 'unauthorized',
158
+ sending: false, nextAttemptAt: undefined });
159
+ return;
160
+ }
161
+ if (outcome.kind === 'refused') {
162
+ unauthorizedSinceAccepted = 0;
163
+ state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
164
+ authRequired: false, lastRetryReason: 'refused', sending: false, nextAttemptAt: undefined });
165
+ return;
166
+ }
167
+ unauthorizedSinceAccepted = 0;
168
+ state$.next({ ...state$.value, authRequired: false, refused: undefined });
169
+ let progressed = false;
170
+ for (const command of commands) {
171
+ const result = outcome.results.find((entry) => entry.id === command.commandId);
172
+ if (!result) continue;
173
+ // Skip the query cache and ignore a command changed while its send was in flight.
174
+ const [stored] = await collection.storageInstance.findDocumentsById([command.key], false);
175
+ if (stored?.syncStatus !== 'pending' || stored.commandId !== command.commandId) continue;
176
+ const current = await collection.findOne(command.key).exec();
177
+ if (!current) continue;
178
+ const error = result.error && { code: result.error.code, message: result.error.message,
179
+ ...(result.error.data ? { data: result.error.data } : {}) };
180
+ const normalized: CommandResult = result.status === 'duplicate' && error
181
+ ? { id: result.id, status: 'rejected', error }
182
+ : { ...result, ...(error ? { error } : {}) };
183
+ try { await options.onResult?.(plainCommand(stored), normalized); }
184
+ catch (cause) { registerCommandsLogger.warn('Failed to apply register command result', { commandId: command.commandId, cause }); }
185
+ const updatedAt = new Date(now()).toISOString();
186
+ await current.incrementalModify((row) => {
187
+ if (row.syncStatus !== 'pending' || row.commandId !== command.commandId) return row;
188
+ return Object.assign(row, normalized.status === 'rejected'
189
+ ? { syncStatus: 'rejected', error: normalized.error, updatedAt }
190
+ : { syncStatus: 'applied', ...(normalized.register ? { result: normalized.register } : {}), updatedAt });
191
+ });
192
+ attempts.delete(command.commandId);
193
+ progressed = true;
194
+ await updateState();
195
+ }
196
+ if (!progressed) {
197
+ serverFailed(commands, 'no_progress');
198
+ return scheduleRetry('no_progress');
199
+ }
200
+ backoff = initialBackoff;
201
+ state$.next({ ...state$.value, lastRetryReason: undefined });
202
+ }
203
+ if (!sent) return;
204
+ } catch (error) {
205
+ scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
206
+ return;
207
+ }
208
+ stoppedDuringRun = true;
209
+ }
210
+
211
+ function flush(): Promise<void> {
212
+ if (running) return running;
213
+ stopped = false;
214
+ watchBackendMissing();
215
+ clearTimeout(timer);
216
+ timer = undefined;
217
+ running = Promise.resolve().then(run).finally(async () => {
218
+ try { await updateState({ sending: false }); } catch {}
219
+ running = undefined;
220
+ if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === undefined) flush().catch(() => {});
221
+ });
222
+ return running;
223
+ }
224
+ updateState().catch(() => {});
225
+ return {
226
+ state$,
227
+ flush,
228
+ start() {
229
+ stopped = false;
230
+ watchBackendMissing();
231
+ if (!subscription) subscription = collection.$.subscribe((event) => {
232
+ if (event.documentData?.syncStatus === 'pending' &&
233
+ (event.operation === 'INSERT' || event.operation === 'UPDATE')) {
234
+ insertedDuringRun = true;
235
+ flush().catch(() => {});
236
+ }
237
+ });
238
+ if (!stopped) flush().catch(() => {});
239
+ },
240
+ stop() {
241
+ stopped = true;
242
+ clearTimeout(timer);
243
+ timer = undefined;
244
+ subscription?.unsubscribe();
245
+ subscription = undefined;
246
+ missingSubscription?.unsubscribe(); missingSubscription = undefined;
247
+ state$.next({ ...state$.value, nextAttemptAt: undefined });
248
+ },
249
+ };
250
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+ import type { AnyCommandEnvelope, CommandEnvelope, OrderCreateEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
3
+ import type { CommandTransport } from './types';
4
+
5
+ describe('CommandTransport', () => {
6
+ it('defaults to order.create envelopes', () => {
7
+ expectTypeOf<Parameters<CommandTransport['send']>[0]>().toEqualTypeOf<CommandEnvelope<OrderCreatePayload>[]>();
8
+ const transport: CommandTransport = { send: async () => ({ kind: 'results', results: [] }) };
9
+ const batch: CommandEnvelope<OrderCreatePayload>[] = [];
10
+ transport.send(batch);
11
+ });
12
+
13
+ it('the wide transport is accepted where the order outbox wants the order transport', () => {
14
+ expectTypeOf<CommandTransport<AnyCommandEnvelope>>().toMatchTypeOf<CommandTransport<OrderCreateEnvelope>>();
15
+ });
16
+
17
+ it('the wide transport accepts register command envelopes', () => {
18
+ const transport: CommandTransport<AnyCommandEnvelope> = { send: async () => ({ kind: 'results', results: [] }) };
19
+ const batch: RegisterCommandEnvelope[] = [];
20
+ transport.send(batch);
21
+ });
22
+ });
@@ -0,0 +1,36 @@
1
+ import type { AnyCommandEnvelope, CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
2
+
3
+ export type TransportOutcome =
4
+ | { kind: 'results'; results: CommandResult[] }
5
+ | { kind: 'unauthorized' }
6
+ | { kind: 'refused'; status: number; reason: string }
7
+ /** `reason` is `network` when the store could not be reached, `timeout` when a sent request got no answer in time,
8
+ * else what the store answered (`status_503`, `bad_body`, ...). The order outbox counts every reason but `network`. */
9
+ | { kind: 'retry'; reason: string; retryAfterMs?: number };
10
+
11
+ export interface CommandTransport<E extends AnyCommandEnvelope = CommandEnvelope<OrderCreatePayload>> {
12
+ send(batch: E[]): Promise<TransportOutcome>;
13
+ }
14
+
15
+ export interface OutboxState {
16
+ pending: number;
17
+ /** The order outbox's count of `pos_orders` the store refused (`syncStatus: 'rejected'`), read with `pending`. Each
18
+ * stays until requeue() sends it again. The register outbox leaves it unset. */
19
+ rejected?: number;
20
+ sending: boolean;
21
+ lastRetryReason?: string;
22
+ nextAttemptAt?: number;
23
+ /** Set after 3 consecutive 401s; the app should ask the cashier to sign in, then call flush(). */
24
+ authRequired?: boolean;
25
+ /** Set when the server refused a whole batch (HTTP 400, 403, 413, 415, 422). No order is changed; sending pauses until the next flush(). */
26
+ refused?: { status: number; reason: string };
27
+ /** Set while the store (not offline) has kept failing some orders for 15 minutes, each on its own clock. Those
28
+ * orders stay pending and keep retrying. `orders` has each order's own entry: its `since` is when its clock
29
+ * would have started had there been no offline gaps (now minus its answered time; while offline, as of the
30
+ * moment the clock paused), and its latest `reason`. `since` is the earliest of theirs; `reason` the latest. */
31
+ stuck?: { commandIds: string[]; since: number; reason: string; orders: { commandId: string; since: number; reason: string }[] };
32
+ /** Set after 3 consecutive 404 answers (counted across the outboxes sharing one BackendNotFound): the store address
33
+ * may be wrong, or the store's plugin isn't installed or is switched off. The outbox keeps retrying; the next answer
34
+ * that isn't a 404 clears it (offline changes nothing). `since` is when the first of those 404s arrived. */
35
+ backendMissing?: { since: number };
36
+ }
@@ -0,0 +1,175 @@
1
+ import type { OrderCreateEnvelope } from '@tallyui/core';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import type { RxCollection, RxError } from 'rxdb';
4
+ import { outboxLogger } from './logger';
5
+ import { OrderContentMismatchError, sameSale, type PosOrder } from '../pos-order';
6
+ import { watchFresh } from '../rxdb';
7
+ import { createOrderOutbox } from './order-outbox';
8
+ import type { CommandTransport, OutboxState } from './types';
9
+
10
+ const idle: OutboxState = { pending: 0, sending: false };
11
+ const noneStuck: readonly string[] = [];
12
+ export { outboxLogger } from './logger';
13
+
14
+ export interface UseOrderOutboxOptions {
15
+ /** Which order store to use (medusapos: the backend's base URL); `null` means no store. A change reopens. */
16
+ storeKey: string | null;
17
+ /** Opens the order store for `storeKey`; `close()` is called when the key or device id changes, or on unmount. */
18
+ open(storeKey: string): Promise<{ orders: RxCollection<PosOrder>; close(): Promise<void> }>;
19
+ /** Builds the command transport for `storeKey` (the app's HTTP transport and auth headers). Read once per open. */
20
+ transport(storeKey: string): CommandTransport<OrderCreateEnvelope>;
21
+ /** The device id sent on every command (see `getDeviceId`). A change reopens. */
22
+ deviceId: string;
23
+ /** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
24
+ getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
25
+ /** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
26
+ refreshCapabilities?: () => Promise<void>;
27
+ /** Called with `state.sending`, and with `false` on cleanup (medusapos: live-tab's `markBusy('outbox', …)`). */
28
+ onBusy?(busy: boolean): void;
29
+ /** Called when `open` rejects, even if the key has changed since (medusapos: reports storage worker failures). */
30
+ onOpenError?(error: unknown): void;
31
+ }
32
+
33
+ export interface UseOrderOutboxResult {
34
+ /** The open orders collection, or `null` until the current store is ready. */
35
+ orders: RxCollection<PosOrder> | null;
36
+ /** The outbox state, or idle until the current store is ready. */
37
+ state: OutboxState;
38
+ /** The newest 50 orders, newest first; empty until the current store is ready. */
39
+ recent: PosOrder[];
40
+ /**
41
+ * Stores a finalized order, then flushes. Throws the opening error, or "Orders are not ready.", before the store is ready.
42
+ * Recording an order whose `id` is already stored, not deleted, with the same money-bearing content (`sameSale`;
43
+ * a retried `complete()`) counts as stored, never overwrites it, and still flushes, whatever the stored `commandId`
44
+ * (a requeue mints a new one; the difference is logged at warn). Other content rejects with `OrderContentMismatchError`.
45
+ */
46
+ record(posOrder: PosOrder): Promise<void>;
47
+ /**
48
+ * Whether the current store holds this order `id`, not deleted, with the same money-bearing content (`sameSale`),
49
+ * whatever its `commandId`. A primary-key read on the storage instance, past RxDB's query cache. False before the
50
+ * store is ready; a content mismatch is false and logged at error.
51
+ */
52
+ isStored(order: PosOrder): Promise<boolean>;
53
+ /** Sends pending orders; does nothing before the current store is ready. */
54
+ flush(): Promise<void>;
55
+ /** Moves rejected orders back to pending (see `OrderOutbox.requeue`); resolves to 0 before the current store is ready. */
56
+ requeue(orderIds?: string[]): Promise<number>;
57
+ /**
58
+ * The count of `record()` calls not yet settled (resolved or rejected). An app holds sign-out
59
+ * (closing the store) while this is above 0, because a close that lands under a write stuck in
60
+ * storage can wait forever, and TallyUI deliberately doesn't bound that close (#155).
61
+ */
62
+ savesInFlight: number;
63
+ /** The command ids in `state.stuck`: pending orders the store keeps failing. Pass them to `needsAttention` and
64
+ * `OrdersList`. Empty when none, and before the current store is ready. */
65
+ stuckCommandIds: readonly string[];
66
+ }
67
+
68
+ /** Opens the order store for `storeKey`, runs its outbox and watches the recent orders (lifted from medusapos/app, ADR-052). */
69
+ export function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxResult {
70
+ const { storeKey, deviceId } = options;
71
+ const latest = useRef(options);
72
+ latest.current = options;
73
+ const current = useRef<{
74
+ storeKey: string; orders: RxCollection<PosOrder>; outbox: ReturnType<typeof createOrderOutbox>;
75
+ } | null>(null);
76
+ const openingError = useRef<unknown>(null);
77
+ // Order ids already logged for a content mismatch (see `isStored`), for the hook's lifetime: a hung
78
+ // save's poll re-asks isStored every few seconds, and a mismatch shouldn't get a fresh error each time.
79
+ const loggedMismatches = useRef(new Set<string>());
80
+ const [orders, setOrders] = useState<RxCollection<PosOrder> | null>(null);
81
+ const [state, setState] = useState<OutboxState>(idle);
82
+ const [recent, setRecent] = useState<PosOrder[]>([]);
83
+ const savesInFlight = useRef(0);
84
+ const [savesInFlightCount, setSavesInFlightCount] = useState(0);
85
+
86
+ useEffect(() => {
87
+ let active = true;
88
+ let dispose: (() => void) | undefined;
89
+ openingError.current = null;
90
+ setOrders(null); setState(idle); setRecent([]);
91
+ if (!storeKey) return;
92
+ void latest.current.open(storeKey).then(async (store) => {
93
+ if (!active) { await store.close(); return; }
94
+ const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey),
95
+ getMaxOrderCreateVersion: latest.current.getMaxOrderCreateVersion ? () => latest.current.getMaxOrderCreateVersion?.() : undefined,
96
+ refreshCapabilities: latest.current.refreshCapabilities ? async () => { await latest.current.refreshCapabilities?.(); } : undefined });
97
+ current.current = { storeKey, orders: store.orders, outbox };
98
+ const status = outbox.state$.subscribe(setState);
99
+ // watchFresh: find().$ can leave this stale forever, hiding a new order (RxDB 16.21.1 bug 4).
100
+ const history = watchFresh(store.orders, { sort: [{ createdAt: 'desc' }], limit: 50 }).subscribe(setRecent);
101
+ dispose = () => {
102
+ outbox.stop(); status.unsubscribe(); history.unsubscribe();
103
+ void store.close();
104
+ };
105
+ setOrders(store.orders);
106
+ outbox.start();
107
+ }).catch((error: unknown) => {
108
+ if (active) openingError.current = error;
109
+ latest.current.onOpenError?.(error);
110
+ });
111
+ return () => { active = false; current.current = null; dispose?.(); };
112
+ }, [storeKey, deviceId]);
113
+
114
+ useEffect(() => {
115
+ latest.current.onBusy?.(state.sending);
116
+ return () => latest.current.onBusy?.(false);
117
+ }, [state.sending]);
118
+
119
+ const ready = current.current?.storeKey === storeKey && !!storeKey;
120
+ // Each state update carries a new `stuck`; the ids keep their identity while they are unchanged.
121
+ const stuckKey = ready ? state.stuck?.commandIds.join(',') ?? '' : '';
122
+ const stuckCommandIds = useMemo(() => (stuckKey ? stuckKey.split(',') : noneStuck), [stuckKey]);
123
+ return { orders: ready ? orders : null, state: ready ? state : idle, recent: ready ? recent : [],
124
+ async flush() {
125
+ const opened = current.current;
126
+ if (opened && opened.storeKey === storeKey) await opened.outbox.flush();
127
+ },
128
+ async requeue(orderIds) {
129
+ const opened = current.current;
130
+ return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
131
+ },
132
+ savesInFlight: savesInFlightCount,
133
+ stuckCommandIds,
134
+ async record(posOrder) {
135
+ savesInFlight.current++;
136
+ setSavesInFlightCount(savesInFlight.current);
137
+ try {
138
+ const opened = current.current;
139
+ if (!opened || opened.storeKey !== storeKey) throw openingError.current ?? new Error('Orders are not ready.');
140
+ try {
141
+ await opened.orders.insert(posOrder);
142
+ } catch (error) {
143
+ // useSale's retry hands over the order a failed save already stored (complete() is idempotent,
144
+ // ADR-052). RxDB 16's insert throws RxError code 'CONFLICT' for an existing primary key, with the
145
+ // stored document in `parameters.writeError.documentInDb`. The same id and content means it is stored, even
146
+ // under another commandId: a requeue mints one, and requiring it stuck the tender on Retry (medusapos #79).
147
+ const stored = (error as RxError)?.code === 'CONFLICT' ? (error as RxError).parameters.writeError : undefined;
148
+ const inDb = stored?.status === 409 ? stored.documentInDb : undefined;
149
+ if (!inDb || inDb._deleted) throw error;
150
+ if (!sameSale(inDb, posOrder)) throw new OrderContentMismatchError(posOrder.id);
151
+ if (inDb.commandId !== posOrder.commandId) {
152
+ outboxLogger.warn('Recorded an order stored under another commandId', { orderId: posOrder.id,
153
+ storedCommandId: inDb.commandId, recordedCommandId: posOrder.commandId });
154
+ }
155
+ }
156
+ if (current.current === opened) void opened.outbox.flush();
157
+ } finally {
158
+ savesInFlight.current--;
159
+ setSavesInFlightCount(savesInFlight.current);
160
+ }
161
+ },
162
+ async isStored(order) {
163
+ const opened = current.current;
164
+ if (!opened || opened.storeKey !== storeKey) return false;
165
+ const [stored] = await opened.orders.storageInstance.findDocumentsById([order.id], false);
166
+ if (!stored || stored._deleted) return false;
167
+ if (sameSale(stored, order)) return true;
168
+ if (!loggedMismatches.current.has(order.id)) {
169
+ loggedMismatches.current.add(order.id);
170
+ outboxLogger.error('A stored order has this id with different content', { orderId: order.id });
171
+ }
172
+ return false;
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,97 @@
1
+ {
2
+ "id": "019f6d2e-7800-7000-8000-000000000001",
3
+ "type": "order.create",
4
+ "version": 3,
5
+ "createdAt": "2026-09-28T10:00:00.000Z",
6
+ "deviceId": "device_golden",
7
+ "attempt": 1,
8
+ "payload": {
9
+ "clientOrderId": "019f6d2e-7800-7000-8000-000000000002",
10
+ "createdAt": "2026-09-28T10:00:00.000Z",
11
+ "currency": "EUR",
12
+ "pricesIncludeTax": true,
13
+ "lines": [
14
+ {
15
+ "clientLineId": "line_inclusive",
16
+ "variantId": "variant_inclusive",
17
+ "title": "Inclusive item",
18
+ "quantity": 2,
19
+ "unitPriceMinor": 1200,
20
+ "discountMinor": 203
21
+ },
22
+ {
23
+ "clientLineId": "line_exclusive",
24
+ "variantId": "variant_exclusive",
25
+ "title": "Exclusive item",
26
+ "quantity": 1,
27
+ "unitPriceMinor": 1000,
28
+ "taxInclusive": false,
29
+ "discountMinor": 37
30
+ }
31
+ ],
32
+ "payments": [
33
+ {
34
+ "clientPaymentId": "payment_cash",
35
+ "method": "cash",
36
+ "amountMinor": 3256,
37
+ "tenderedMinor": 4000,
38
+ "changeMinor": 744,
39
+ "reference": "cash_receipt_1"
40
+ }
41
+ ],
42
+ "subtotalMinor": 2794,
43
+ "discountMinor": 240,
44
+ "taxMinor": 462,
45
+ "totalMinor": 3256,
46
+ "display": {
47
+ "currency": "EUR",
48
+ "exponent": 2,
49
+ "taxInclusive": true,
50
+ "subtotalMinor": 3500,
51
+ "discountMinor": 244,
52
+ "taxMinor": 462,
53
+ "totalMinor": 3256,
54
+ "lines": [
55
+ {
56
+ "clientLineId": "line_inclusive",
57
+ "amountMinor": 2400,
58
+ "discounts": [
59
+ {
60
+ "discountId": "discount_line",
61
+ "label": "Line discount",
62
+ "amountMinor": 120
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "clientLineId": "line_exclusive",
68
+ "amountMinor": 1100,
69
+ "discounts": []
70
+ }
71
+ ],
72
+ "orderDiscountMinor": 124
73
+ },
74
+ "taxByRate": [
75
+ {
76
+ "ratePpm": 200000,
77
+ "code": "VAT20",
78
+ "netMinor": 1831,
79
+ "taxMinor": 366,
80
+ "grossMinor": 2197
81
+ },
82
+ {
83
+ "ratePpm": 100000,
84
+ "netMinor": 963,
85
+ "taxMinor": 96,
86
+ "grossMinor": 1059
87
+ }
88
+ ],
89
+ "customer": {
90
+ "email": "buyer@example.com",
91
+ "customerId": "cus_golden_v3"
92
+ },
93
+ "registerId": "register_golden",
94
+ "cashierRef": "cashier_golden",
95
+ "sessionId": "019f6d2e-7800-7000-8000-000000000003"
96
+ }
97
+ }