@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,394 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
3
+ import { createOrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
4
+ import { finalizeOrder, type PosOrder } from '../pos-order';
5
+ import { MESSAGE_NAME_MAX, referenceError, referenceReason, withSentForm } from '../pos-order/finalize';
6
+ import { CUSTOMER_REFUSALS, customerRefusal, cutText, PAYLOAD_STRING_MAX } from '../pos-order/command';
7
+ import { useTax } from '../tax';
8
+ import { recordRegisterFact, stampSession, type RegisterSessionCollection } from '../register';
9
+ import { createLogger } from '../logging';
10
+ import type { CatalogueEntry } from './catalogue';
11
+ import { addEntryToCart, CartError } from './cart';
12
+
13
+ /** Logs a throw from onSaleCompleted for a confirmed pending completion newSale() has abandoned (Continue): nothing else would ever surface it. */
14
+ export const saleLogger = createLogger('sale');
15
+
16
+ export type SaleStage = { kind: 'cart' } | { kind: 'tender'; method: 'cash' | 'external' }
17
+ | { kind: 'receipt'; order: SentOrder; posOrder: PosOrder };
18
+ /** TallyUI finalizeOrder's refusal below order.create v2 (c19a203), shown when the discount is applied; finalize stays the backstop. */
19
+ export const DISCOUNTS_UNSUPPORTED = 'finalize: discounts are not supported by the server yet (order.create v2)';
20
+ /** Every sale change refuses with this while a completion is pending (see `complete()`). */
21
+ export const SALE_SAVING = 'This sale is being saved. Retry to finish it.';
22
+ /**
23
+ * How often a hung save re-asks `isStored`, while the order is built and its save is still in flight
24
+ * and unconfirmed: an app whose tender has no New sale control while saving (the Front desk,
25
+ * 2026-09-28) never re-asks otherwise, so the cashier could only wait for a hung post-insert step.
26
+ * `useSale`'s `hungSaveCheckMs` overrides this; that option is tests only.
27
+ */
28
+ export const HUNG_SAVE_CHECK_MS = 5000;
29
+
30
+ /** Call under a `TaxProvider`: its tax context and the settings' currency price every sale. */
31
+ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
32
+ registerId: string; cashierRef: string; capabilities?: ServerCapabilities;
33
+ /**
34
+ * When set, `complete()` stamps the finalized order with this session before `onSaleCompleted`.
35
+ * `complete()` runs after the money is taken, so a refused stamp (the session closed or went
36
+ * missing) never stops the sale: it goes on to `onSaleCompleted` and the receipt with
37
+ * `lateSessionId` instead of `sessionId`, and a `late-sale` register fact is recorded (ADR-032).
38
+ * The session stamped is the one in force when the tender started (`startTender`), pinned for that
39
+ * tender: this option going undefined mid-tender (the session closed) doesn't skip the stamp. A
40
+ * tender that pinned none, with a session here at `complete()`, stamps it and logs a warning.
41
+ */
42
+ session?: { id: string; sessions: RegisterSessionCollection };
43
+ onSaleCompleted?: (posOrder: PosOrder) => Promise<void> | void;
44
+ /**
45
+ * Asked after `onSaleCompleted` throws, or on a `newSale()` refused mid-save: whether that order is confirmed stored
46
+ * (medusapos: `useOrderOutbox`'s `isStored`). Only a true answer sets `canContinue`; without it, a failed save offers Retry only.
47
+ */
48
+ isStored?: (posOrder: PosOrder) => Promise<boolean>;
49
+ /** Tests only: overrides HUNG_SAVE_CHECK_MS, so a test can shrink the hung-save poll's interval. */
50
+ hungSaveCheckMs?: number;
51
+ }) {
52
+ const taxContext = useTax();
53
+ const madeWith = useRef({ taxContext, currency: settings.currency });
54
+ const [rendered, setBuilder] = useState(() => createOrderBuilder({ currency: settings.currency, taxContext }));
55
+ // The live builder, which every mutator reads (#301): newSale() sets it before setBuilder(next) renders, so a
56
+ // call from an older render never reaches a discarded builder. The state only moves the `order` subscription.
57
+ const builderNow = useRef(rendered);
58
+ const [order, setOrder] = useState(() => rendered.getSnapshot());
59
+ const [stage, setStage] = useState<SaleStage>({ kind: 'cart' });
60
+ const [saleError, setError] = useState<string | null>(null);
61
+ // App configuration is checked on every render (so on mount and on each change), by finalize's own rule, which
62
+ // stays the backstop: while it's bad, `error` shows it and complete() refuses, before the first sale's money.
63
+ const configError = referenceError('cashierRef', opts.cashierRef) ?? referenceError('registerId', opts.registerId);
64
+ // The pending completion: the order complete() built for this tender attempt. The ref is read
65
+ // synchronously by complete() and the lock; `saving` mirrors it (true from complete()'s entry) for rendering.
66
+ const pending = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
67
+ const [saving, setSaving] = useState(false);
68
+ // While a completion is pending (a failed save), its own error comes first: Retry still delivers it.
69
+ const error = saving && saleError ? saleError : configError ?? saleError;
70
+ // The session pinned by startTender for this tender (`undefined` inside: none); null until a tender starts.
71
+ const tenderSession = useRef<{ session: typeof opts.session } | null>(null);
72
+ // The id, at the tender, of the payment whose terminal reference setTender dropped; null when none was dropped.
73
+ const droppedReference = useRef<string | null>(null);
74
+ // The pending completion isStored confirmed stored after its save failed; `canContinue` mirrors it for
75
+ // rendering. `attempts` counts complete() attempts, so a confirmation that lands after a new one is dropped.
76
+ const confirmed = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
77
+ const [canContinue, setCanContinue] = useState(false);
78
+ const attempts = useRef(0);
79
+ // The hung-save poll (at most one at a time): deliver() arms it while a completion's save is in
80
+ // flight, and it, confirm() and unmount all clear it.
81
+ const hungSaveTimer = useRef<ReturnType<typeof setInterval> | null>(null);
82
+ // Guards overlapping isStored checks (#161 review), per completion: a tick skips only while its own
83
+ // completion's check is already in flight, so an isStored slower than the interval never has more
84
+ // than one check running at once for that completion. Scoped to the completion (not a bare boolean)
85
+ // so a completion whose isStored never settles can never block a later sale's own poll (#161 follow-up).
86
+ const checking = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
87
+ function clearHungSaveTimer() {
88
+ if (hungSaveTimer.current !== null) { clearInterval(hungSaveTimer.current); hungSaveTimer.current = null; }
89
+ }
90
+ function confirm(completion: { order: Order; posOrder: PosOrder } | null) {
91
+ confirmed.current = completion;
92
+ setCanContinue(!!completion);
93
+ clearHungSaveTimer();
94
+ }
95
+ // The complete() call in flight, and the stage as of the last render or receipt, both read synchronously.
96
+ const inFlight = useRef<Promise<void> | null>(null);
97
+ const stageNow = useRef(stage);
98
+ stageNow.current = stage;
99
+ /** True, with SALE_SAVING shown, from complete()'s entry (inFlight) through a pending retry: the sale can't change. */
100
+ function locked() {
101
+ const active = !!(inFlight.current || pending.current);
102
+ if (active) setError(SALE_SAVING);
103
+ return active;
104
+ }
105
+ // newSale()'s body (see its doc comment for the guards): `newSale()` is `resetSale(null)`.
106
+ function resetSale(customer: CustomerSummary | null) {
107
+ if (inFlight.current && !pending.current) return setError(SALE_SAVING);
108
+ if (pending.current && confirmed.current !== pending.current) {
109
+ if (inFlight.current) checkStored(pending.current);
110
+ return setError(SALE_SAVING);
111
+ }
112
+ confirm(null); pending.current = null;
113
+ inFlight.current = null;
114
+ tenderSession.current = null;
115
+ droppedReference.current = null;
116
+ setSaving(false);
117
+ madeWith.current = { taxContext, currency: settings.currency };
118
+ const next = createOrderBuilder({ currency: settings.currency, taxContext });
119
+ if (customer !== null) next.setCustomer(customer);
120
+ builderNow.current = next;
121
+ setBuilder(next);
122
+ setOrder(next.getSnapshot());
123
+ setStage({ kind: 'cart' });
124
+ setError(null);
125
+ }
126
+ useEffect(() => {
127
+ const subscription = rendered.order$.subscribe(setOrder);
128
+ return () => subscription.unsubscribe();
129
+ }, [rendered]);
130
+ // New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale keeping its customer:
131
+ // a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
132
+ const idle = stage.kind === 'cart' && !order.lineItems.length;
133
+ // A layout effect, on live idleness (#301): it runs inside the settings' commit, before any tap can be handled, and
134
+ // the rendered `idle` would miss a line added since this render (it would then be dropped with the old builder).
135
+ useLayoutEffect(() => {
136
+ const live = builderNow.current.getSnapshot();
137
+ const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !inFlight.current && !pending.current;
138
+ if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
139
+ });
140
+ // A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
141
+ useEffect(() => () => {
142
+ clearHungSaveTimer();
143
+ if (pending.current || inFlight.current) {
144
+ saleLogger.error('useSale unmounted with a save pending or in flight',
145
+ { orderId: pending.current?.posOrder.id, stage: stageNow.current.kind });
146
+ }
147
+ }, []);
148
+
149
+ function setTender(tender: { method: 'cash' | 'external'; amountMinor: number; reference?: string } | null) {
150
+ if (locked()) return;
151
+ // A terminal reference finalize would refuse is dropped as it's entered, never the payment (the money is
152
+ // taken): the tender applies without it, with a message that doesn't block complete() and a
153
+ // localWarnings entry on the stored order naming the payment whose reference was dropped.
154
+ const reason = referenceReason(tender?.reference);
155
+ const dropped = reason && (reason === 'nul' ? 'it contains a NUL character' : `it is over ${PAYLOAD_STRING_MAX} characters`);
156
+ const kept = tender && dropped ? { method: tender.method, amountMinor: tender.amountMinor } : tender;
157
+ const builder = builderNow.current;
158
+ const previous = builder.getSnapshot().payments[0];
159
+ if (previous) builder.removePayment(previous.id);
160
+ const paymentId = kept ? builder.addPayment(kept) : null;
161
+ droppedReference.current = dropped ? paymentId : null;
162
+ setError(dropped && `The terminal's payment reference couldn't be kept (${dropped}); the payment is recorded without it.`);
163
+ try {
164
+ if (dropped) saleLogger.warn("the terminal's payment reference was dropped", { reason: dropped, method: tender!.method });
165
+ } catch { /* a failing sink must never lose the tender */ }
166
+ }
167
+
168
+ /** Asks `isStored`; Continue is offered only once it confirms, for this attempt, with the completion still pending. */
169
+ function checkStored(completion: { order: Order; posOrder: PosOrder }) {
170
+ const attempt = attempts.current;
171
+ const isStored = opts.isStored; // a throw, even a synchronous one, counts as not stored
172
+ if (!isStored) return;
173
+ checking.current = completion;
174
+ void Promise.resolve(completion.posOrder).then(isStored).catch(() => false).then((stored) => {
175
+ if (stored === true && attempts.current === attempt && pending.current === completion) confirm(completion);
176
+ }).finally(() => { if (checking.current === completion) checking.current = null; });
177
+ }
178
+
179
+ /** Hands a pending completion to `onSaleCompleted`; the outcome applies only if it wasn't abandoned meanwhile. */
180
+ async function deliver(completion: { order: Order; posOrder: PosOrder }) {
181
+ clearHungSaveTimer();
182
+ // Re-asks isStored every HUNG_SAVE_CHECK_MS while this save is in flight and unconfirmed, so a hung
183
+ // post-insert step (no throw, no resolve) still offers Continue with no user action. Its own
184
+ // `timer` handle is cleared below only while it's still the current one, so a late settle from an
185
+ // attempt a newer sale has already superseded can never cut off that newer sale's poll (#161 review).
186
+ const timer = opts.isStored ? setInterval(() => {
187
+ if (checking.current === completion) return;
188
+ if (pending.current === completion && inFlight.current && confirmed.current !== completion) checkStored(completion);
189
+ else if (hungSaveTimer.current === timer) clearHungSaveTimer();
190
+ }, opts.hungSaveCheckMs ?? HUNG_SAVE_CHECK_MS) : null;
191
+ hungSaveTimer.current = timer;
192
+ try {
193
+ await opts.onSaleCompleted?.(completion.posOrder);
194
+ } catch (error) {
195
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ // Logged whatever the mount state and whether or not it's still pending (#150 review): a screen
198
+ // unmounted mid-save (medusapos Sign out) must never lose a throw silently. Skipped only once the
199
+ // order is confirmed stored, since a later throw on a known-safe order needs no fresh alarm.
200
+ if (confirmed.current !== completion) {
201
+ saleLogger.error('onSaleCompleted failed', { orderId: completion.posOrder.id, error: message });
202
+ }
203
+ if (pending.current === completion) {
204
+ setError(`The sale could not be saved: ${message}`);
205
+ checkStored(completion);
206
+ }
207
+ return;
208
+ }
209
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
210
+ if (pending.current !== completion) return;
211
+ pending.current = null;
212
+ droppedReference.current = null;
213
+ confirm(null);
214
+ setSaving(false);
215
+ setError(null);
216
+ stageNow.current = { kind: 'receipt', order: withSentForm(completion.order, completion.posOrder), posOrder: completion.posOrder };
217
+ setStage(stageNow.current);
218
+ }
219
+
220
+ /**
221
+ * Runs one complete() attempt, unless one is in flight or the receipt shows. The in-flight promise is
222
+ * set before the attempt's first await, so a second call (a double tap) shares it and never builds a
223
+ * second order. `.finally` clears `inFlight` only if it still holds this attempt's own promise.
224
+ */
225
+ function once(attempt: () => Promise<void>): Promise<void> {
226
+ if (!inFlight.current && stageNow.current.kind !== 'receipt') {
227
+ const mine: Promise<void> = attempt().finally(() => { if (inFlight.current === mine) inFlight.current = null; });
228
+ inFlight.current = mine;
229
+ }
230
+ return inFlight.current ?? Promise.resolve();
231
+ }
232
+
233
+ const result = {
234
+ order, stage, error, idle,
235
+ /** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
236
+ saving,
237
+ /** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
238
+ canContinue,
239
+ add(entry: CatalogueEntry<any>, traits: ProductTraits<any>) {
240
+ if (locked()) return;
241
+ const builder = builderNow.current;
242
+ try {
243
+ const known = new Set(builder.getSnapshot().lineItems.map((line) => line.id));
244
+ const lineId = addEntryToCart(builder, entry, traits, madeWith.current.currency);
245
+ // A new line whose id or v3 tax code finalize would refuse is taken off at once, in finalize's
246
+ // message shape; finalize stays the backstop.
247
+ const line = builder.getSnapshot().lineItems.find((item) => item.id === lineId && !known.has(item.id));
248
+ const name = line && `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
249
+ const taxLines = line && (opts.capabilities?.orderCreate ?? 1) >= 3 ? line.taxLines : [];
250
+ const refused = line && [line.variantId !== undefined ? referenceError(`${name}: the variant id`, line.variantId)
251
+ : referenceError(`${name}: the product id`, line.productId),
252
+ ...taxLines.map((tax) => referenceError(`${name}: the tax code`, tax.code))].find((message) => message !== null);
253
+ if (refused) { builder.removeItem(lineId); return setError(refused); }
254
+ setError(null);
255
+ } catch (error) {
256
+ if (!(error instanceof CartError)) throw error;
257
+ setError(error.message);
258
+ }
259
+ },
260
+ setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
261
+ remove(lineId: string) { if (!locked()) builderNow.current.removeItem(lineId); },
262
+ /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
263
+ applyDiscount(lineId: string | null, discount: Discount): string | null {
264
+ if (locked()) return SALE_SAVING;
265
+ if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
266
+ const builder = builderNow.current;
267
+ const applied = (snapshot: Order) => lineId === null ? snapshot.discounts
268
+ : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
269
+ const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
270
+ if (lineId === null) builder.applyOrderDiscount(discount);
271
+ else builder.applyLineDiscount(lineId, discount);
272
+ // TallyUI caps a fixed amount at what is left; refuse it instead of showing more off than comes off.
273
+ const added = applied(builder.getSnapshot()).find((entry) => !before.has(entry.id));
274
+ if (added?.type === 'fixed' && added.amountMinor < added.value) {
275
+ builder.removeDiscount(added.id);
276
+ return `The discount is more than the ${lineId === null ? 'order' : 'line'}`;
277
+ }
278
+ return null;
279
+ },
280
+ removeDiscount(id: string) { if (!locked()) builderNow.current.removeDiscount(id); },
281
+ /** the picked customer reaches the server as order.create v3's customer.customerId */
282
+ setCustomer(customer: CustomerSummary | null) {
283
+ if (locked()) return;
284
+ // A searched customer's email or id the server would refuse never reaches the sale; the customer stays as it was.
285
+ const refused = customer && customerRefusal(customer);
286
+ if (refused) return setError(refused);
287
+ builderNow.current.setCustomer(customer);
288
+ setError((current) => current !== null && CUSTOMER_REFUSALS.includes(current) ? null : current); // any other error stays
289
+ },
290
+ /**
291
+ * Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
292
+ * session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
293
+ * opened just before (#170 race). A repeat call mid-tender keeps the first pin.
294
+ */
295
+ startTender(method: 'cash' | 'external', options?: { session?: { id: string; sessions: RegisterSessionCollection } }) {
296
+ if (locked()) return;
297
+ const current = builderNow.current.getSnapshot();
298
+ if (!current.lineItems.length) return;
299
+ tenderSession.current ??= { session: options?.session ?? opts.session }; // a repeat call mid-tender keeps the pin
300
+ setTender(method === 'external' ? { method, amountMinor: current.totalMinor } : null);
301
+ setStage({ kind: 'tender', method });
302
+ },
303
+ setTender,
304
+ cancelTender() {
305
+ if (locked()) return;
306
+ setTender(null);
307
+ tenderSession.current = null;
308
+ setStage({ kind: 'cart' });
309
+ },
310
+ /**
311
+ * Finalizes the tender, stamps the session (see `session`), hands the order to `onSaleCompleted`,
312
+ * then shows the receipt. `saving` turns true and the sale locks (every change sets SALE_SAVING)
313
+ * from this call's entry, not only once the order is built; a refused finalize unlocks it again,
314
+ * with the refusal's error. Idempotent for one tender attempt (DECISIONS, ADR-052): once the order
315
+ * is built, that order is the sale, kept as the pending completion, because `onSaleCompleted` may
316
+ * already have stored it before failing. If `onSaleCompleted` throws, the error is set and the tender stays;
317
+ * calling `complete()` again reuses the pending completion exactly (the same `id`, `commandId`
318
+ * and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
319
+ * again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
320
+ * pending completion is cleared once `onSaleCompleted` resolves, or by `newSale()`. A call while
321
+ * another is in flight returns that call's promise, and a call on the receipt does nothing. While
322
+ * `cashierRef` or `registerId` is out of bounds (shown as `error`), a call does nothing either,
323
+ * unless it's the Retry of a pending completion, which was built with the options as they were.
324
+ */
325
+ complete: () => configError && !pending.current && !inFlight.current ? Promise.resolve() : once(async () => {
326
+ attempts.current++;
327
+ confirm(null);
328
+ if (pending.current) return deliver(pending.current);
329
+ setSaving(true);
330
+ const current = builderNow.current.getSnapshot();
331
+ let posOrder: PosOrder;
332
+ try {
333
+ posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities,
334
+ ...(droppedReference.current ? { localWarnings: [{ code: 'payment_reference_dropped', paymentId: droppedReference.current }] } : {}) });
335
+ } catch (error) {
336
+ setSaving(false);
337
+ setError((error as Error).message);
338
+ return;
339
+ }
340
+ // The tender's pinned session; opts.session only for a complete() with no startTender (setTender from the cart),
341
+ // or when the tender pinned none but a session has rendered since (the backstop, warned: see startTender).
342
+ const session = tenderSession.current?.session ?? opts.session;
343
+ if (tenderSession.current && !tenderSession.current.session && session) {
344
+ try {
345
+ saleLogger.warn('the tender started before its session rendered; pass the confirmed session to startTender',
346
+ { orderId: posOrder.id, sessionId: session.id });
347
+ } catch {
348
+ // A failing sink must never lose the sale.
349
+ }
350
+ }
351
+ if (session) {
352
+ try {
353
+ posOrder = await stampSession(posOrder, session.id, session.sessions);
354
+ } catch {
355
+ // The money is taken: keep the sale, outside every closure (ADR-032, late sale).
356
+ const { sessionId: _unstamped, ...unstamped } = posOrder;
357
+ posOrder = { ...unstamped, lateSessionId: session.id };
358
+ try {
359
+ // useSale knows the cashier only by ref, so the actor carries no display name.
360
+ recordRegisterFact({ kind: 'late-sale', orderId: posOrder.id, sessionId: session.id, registerId: opts.registerId,
361
+ actor: { id: opts.cashierRef, name: '' } });
362
+ } catch {
363
+ // The logger calls the app's sinks unguarded; a failing sink must never lose the sale.
364
+ }
365
+ }
366
+ }
367
+ pending.current = { order: current, posOrder };
368
+ return deliver(pending.current);
369
+ }),
370
+ /**
371
+ * Starts a new, empty sale. Refused with SALE_SAVING, changing nothing, while a save is still
372
+ * building or stamping (nothing yet to ask `isStored` about), and while a pending completion
373
+ * exists and isn't confirmed stored — the ways out are Retry (`complete()`) or Continue, once
374
+ * `canContinue` (the Front desk, 2026-09-27; #149 review). So an app that doesn't pass `isStored`
375
+ * gets Retry only after a failed save. A refusal while a save is still delivering its order asks
376
+ * `isStored` afresh, so a hung save whose order is stored can still offer Continue. Past those, it
377
+ * abandons a confirmed pending completion (never handed to `onSaleCompleted` again: it's stored; a
378
+ * save still in flight carries on in the background, a throw logged at error) and starts the new
379
+ * sale outright — from the receipt, or an idle cart, there's nothing pending to abandon. Abandoning
380
+ * clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
381
+ * updates or requeues `pos_orders` itself.
382
+ */
383
+ newSale() { resetSale(null); },
384
+ /**
385
+ * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
386
+ * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
387
+ * its receipt is not shown. Otherwise does nothing.
388
+ */
389
+ continueSale() {
390
+ if (pending.current && confirmed.current === pending.current) result.newSale();
391
+ },
392
+ };
393
+ return result;
394
+ }
@@ -0,0 +1,5 @@
1
+ export { resolveStoreSettings } from './resolve-store-settings';
2
+ export type { StoreSettingsResolution, ResolveStoreSettingsOptions } from './resolve-store-settings';
3
+ export { withPricingContext, taxProviderProps } from './map-store-settings';
4
+ export { useStoreSettings } from './use-store-settings';
5
+ export type { StoreSettingsState } from './use-store-settings';
@@ -0,0 +1,18 @@
1
+ import type { StoreSettings, SyncContext } from '@tallyui/core';
2
+ import type { TaxProviderProps } from '../tax';
3
+
4
+ /** The replication's context with the settings' opaque pricing context; the key is left out when the settings have none. */
5
+ export function withPricingContext(context: SyncContext, settings: StoreSettings): SyncContext {
6
+ const { pricingContext: _previous, ...rest } = context;
7
+ return settings.pricingContext ? { ...rest, pricingContext: settings.pricingContext } : rest;
8
+ }
9
+
10
+ /**
11
+ * `<TaxProvider>`'s props from the same settings, so its tax inclusivity and rounding match the store's. `custom`
12
+ * rounding passes none, the default (#287). An explicit `rounding` prop after the spread still wins, since JSX
13
+ * applies props in order; so `<TaxProvider {...taxProviderProps(s)} rounding={undefined}>` wipes the store's rounding.
14
+ */
15
+ export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
16
+ const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
17
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }) };
18
+ }
@@ -0,0 +1,65 @@
1
+ import { StoreSettingsError } from '@tallyui/core';
2
+ import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices, SyncContext, TallyConnector } from '@tallyui/core';
3
+
4
+ export type StoreSettingsResolution =
5
+ | { status: 'ready'; settings: StoreSettings; choice?: StoreSettingsChoice }
6
+ | { status: 'choose'; choices: StoreSettingsChoices; initial?: StoreSettingsChoice }
7
+ | { status: 'unsupported' }; // the connector has no storeSettings: the app uses its own configuration
8
+
9
+ export interface ResolveStoreSettingsOptions {
10
+ connector: TallyConnector;
11
+ context: SyncContext;
12
+ /** The app's stored choice for this store (its own settings; TallyUI never stores it). */
13
+ loadChoice: () => StoreSettingsChoice | undefined | Promise<StoreSettingsChoice | undefined>;
14
+ saveChoice: (choice: StoreSettingsChoice) => void | Promise<void>;
15
+ /** A save that throws after a successful resolve: the pick is asked again next launch. */
16
+ onSaveError?: (error: unknown) => void;
17
+ }
18
+
19
+ /**
20
+ * Narrows `tried` to what `choices` actually offers: a field is dropped only when its own list
21
+ * is offered and no longer contains it (a deleted region, say); a field whose list isn't
22
+ * offered passes through unresolved, since only the other parts were ambiguous
23
+ * (`StoreSettingsChoiceScreen`, #96, relies on that to resubmit it unchanged). A deleted region
24
+ * can't loop forever: it's dropped once regions are offered, and otherwise the connector's own
25
+ * default already resolved it.
26
+ */
27
+ function narrowInitial(tried: StoreSettingsChoice, choices: StoreSettingsChoices): StoreSettingsChoice | undefined {
28
+ const region = !choices.regions || choices.regions.some((r) => r.id === tried.region) ? tried.region : undefined;
29
+ const country = !choices.countries || (tried.country !== undefined && choices.countries.includes(tried.country)) ? tried.country : undefined;
30
+ const channel = !choices.channels || choices.channels.some((c) => c.id === tried.channel) ? tried.channel : undefined;
31
+ const initial = { ...(region !== undefined && { region }), ...(country !== undefined && { country }), ...(channel !== undefined && { channel }) };
32
+ return Object.keys(initial).length > 0 ? initial : undefined;
33
+ }
34
+
35
+ /**
36
+ * Reads the store settings with the app's choice (TV4). A `choice` argument (a new pick from
37
+ * the choice screen) wins over the stored one and is saved only once it resolves; a stored
38
+ * choice is never re-saved. `choice_required` (including a stale stored choice) gives
39
+ * `choose`, with the tried choice narrowed to what's offered as `initial`, so the screen
40
+ * pre-selects what still matches. Any other error, `failed` included, is rethrown unchanged.
41
+ */
42
+ export async function resolveStoreSettings(
43
+ options: ResolveStoreSettingsOptions,
44
+ choice?: StoreSettingsChoice,
45
+ ): Promise<StoreSettingsResolution> {
46
+ const { connector, context, loadChoice, saveChoice, onSaveError } = options;
47
+ if (!connector.storeSettings) return { status: 'unsupported' };
48
+
49
+ // A throw, sync or async, is no stored choice; it never bricks the till.
50
+ const tried = choice ?? (await Promise.resolve().then(loadChoice).catch(() => undefined));
51
+ let settings: StoreSettings;
52
+ try {
53
+ settings = await connector.storeSettings(context, tried);
54
+ } catch (error) {
55
+ if (error instanceof StoreSettingsError && error.code === 'choice_required') {
56
+ const offered = error.choices ?? {};
57
+ const initial = tried && narrowInitial(tried, offered);
58
+ return { status: 'choose', choices: offered, ...(initial && { initial }) };
59
+ }
60
+ throw error;
61
+ }
62
+ // A save that fails doesn't block the till either: the settings already resolved.
63
+ if (choice) await Promise.resolve().then(() => saveChoice(choice)).catch((error) => onSaveError?.(error));
64
+ return { status: 'ready', settings, ...(tried && { choice: tried }) };
65
+ }
@@ -0,0 +1,84 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices, TaxRounding } from '@tallyui/core';
3
+ import { resolveStoreSettings } from './resolve-store-settings';
4
+ import type { ResolveStoreSettingsOptions } from './resolve-store-settings';
5
+
6
+ export type StoreSettingsState =
7
+ | { state: 'loading' }
8
+ | { state: 'ready'; settings: StoreSettings; choice?: StoreSettingsChoice }
9
+ | { state: 'choose'; choices: StoreSettingsChoices; initial?: StoreSettingsChoice; choose: (choice: StoreSettingsChoice) => void }
10
+ | { state: 'error'; error: unknown; retry: () => void }
11
+ | { state: 'unsupported' };
12
+
13
+ // One object, so re-entering loading while already loading doesn't re-render.
14
+ const LOADING: StoreSettingsState = { state: 'loading' };
15
+
16
+ // The store's taxRounding (#324): the context's capabilities, else one read of the connector's. A failure or no
17
+ // value leaves the field out, the default rounding; so does a connector without `capabilities`, with no read.
18
+ function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): Promise<TaxRounding | undefined> {
19
+ if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
20
+ if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(undefined);
21
+ const read = connector.capabilities;
22
+ return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => undefined);
23
+ }
24
+
25
+ /**
26
+ * Resolves the store settings on mount, and again when `connector` or `context` changes by
27
+ * identity (a store switch), so memoise both; the last request wins. `loadChoice` and
28
+ * `saveChoice` are read when a request starts, so inline functions don't trigger a re-resolve.
29
+ * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
30
+ * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
31
+ * rounding already known: no later change of `settings` holds a sale.
32
+ *
33
+ * ```tsx
34
+ * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
35
+ * if (store.state === 'choose')
36
+ * return <StoreSettingsChoiceScreen choices={store.choices} initial={store.initial} onSubmit={store.choose} />;
37
+ * if (store.state !== 'ready') return <Loading />; // or an error with store.retry, or the app's own config when unsupported
38
+ * const syncContext = withPricingContext(context, store.settings); // for the replication
39
+ * return <TaxProvider {...taxProviderProps(store.settings)}>{children}</TaxProvider>;
40
+ * ```
41
+ */
42
+ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSettingsState {
43
+ const { connector, context } = options;
44
+ const optionsRef = useRef(options);
45
+ optionsRef.current = options;
46
+ const requestRef = useRef(0);
47
+ const [state, setState] = useState<StoreSettingsState>(LOADING);
48
+
49
+ // `from`, when given, is the request that created the `choose`/`retry` calling this: a call
50
+ // arriving after a store switch made that request stale is ignored, so it can't apply an old
51
+ // pick to the new store. `known`, when given, is the rounding that request already read, so
52
+ // a pick doesn't read it again.
53
+ const resolve = useCallback((choice?: StoreSettingsChoice, from?: number, known?: Promise<TaxRounding | undefined>) => {
54
+ if (from !== undefined && from !== requestRef.current) return;
55
+ const request = ++requestRef.current;
56
+ const current = () => request === requestRef.current;
57
+ setState(LOADING);
58
+ const rounding = known ?? readTaxRounding(optionsRef.current);
59
+ Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
60
+ ([result, taxRounding]) => {
61
+ if (!current()) return;
62
+ if (result.status === 'ready')
63
+ setState({ state: 'ready', settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
64
+ else if (result.status === 'choose')
65
+ setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request, rounding) });
66
+ else setState({ state: 'unsupported' });
67
+ },
68
+ // A retry keeps the choice that failed: a new pick is saved only once it resolves.
69
+ (error: unknown) => {
70
+ if (current()) setState({ state: 'error', error, retry: () => resolve(choice, request) });
71
+ },
72
+ );
73
+ }, []);
74
+
75
+ useEffect(() => {
76
+ resolve();
77
+ // Discards the in-flight result on a store switch or unmount.
78
+ return () => {
79
+ requestRef.current += 1;
80
+ };
81
+ }, [connector, context, resolve]);
82
+
83
+ return state;
84
+ }