@tallyui/pos 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,337 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
3
+ import { createOrderBuilder, type Discount, type Order } from '../order';
4
+ import { finalizeOrder, type PosOrder } from '../pos-order';
5
+ import { useTax } from '../tax';
6
+ import { recordRegisterFact, stampSession, type RegisterSessionCollection } from '../register';
7
+ import { createLogger } from '../logging';
8
+ import type { CatalogueEntry } from './catalogue';
9
+ import { addEntryToCart, CartError } from './cart';
10
+
11
+ /** Logs a throw from onSaleCompleted for a confirmed pending completion newSale() has abandoned (Continue): nothing else would ever surface it. */
12
+ export const saleLogger = createLogger('sale');
13
+
14
+ export type SaleStage = { kind: 'cart' } | { kind: 'tender'; method: 'cash' | 'external' }
15
+ | { kind: 'receipt'; order: Order; posOrder: PosOrder };
16
+ /** TallyUI finalizeOrder's refusal below order.create v2 (c19a203), shown when the discount is applied; finalize stays the backstop. */
17
+ export const DISCOUNTS_UNSUPPORTED = 'finalize: discounts are not supported by the server yet (order.create v2)';
18
+ /** Every sale change refuses with this while a completion is pending (see `complete()`). */
19
+ export const SALE_SAVING = 'This sale is being saved. Retry to finish it.';
20
+ /**
21
+ * How often a hung save re-asks `isStored`, while the order is built and its save is still in flight
22
+ * and unconfirmed: an app whose tender has no New sale control while saving (the Front desk,
23
+ * 2026-09-28) never re-asks otherwise, so the cashier could only wait for a hung post-insert step.
24
+ * `useSale`'s `hungSaveCheckMs` overrides this; that option is tests only.
25
+ */
26
+ export const HUNG_SAVE_CHECK_MS = 5000;
27
+
28
+ /** Call under a `TaxProvider`: its tax context and the settings' currency price every sale. */
29
+ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
30
+ registerId: string; cashierRef: string; capabilities?: ServerCapabilities;
31
+ /**
32
+ * When set, `complete()` stamps the finalized order with this session before `onSaleCompleted`.
33
+ * `complete()` runs after the money is taken, so a refused stamp (the session closed or went
34
+ * missing) never stops the sale: it goes on to `onSaleCompleted` and the receipt with
35
+ * `lateSessionId` instead of `sessionId`, and a `late-sale` register fact is recorded (ADR-032).
36
+ * The session stamped is the one in force when the tender started (`startTender`), pinned for that
37
+ * tender: this option going undefined mid-tender (the session closed) doesn't skip the stamp. A
38
+ * tender that pinned none, with a session here at `complete()`, stamps it and logs a warning.
39
+ */
40
+ session?: { id: string; sessions: RegisterSessionCollection };
41
+ onSaleCompleted?: (posOrder: PosOrder) => Promise<void> | void;
42
+ /**
43
+ * Asked after `onSaleCompleted` throws, or on a `newSale()` refused mid-save: whether that order is confirmed stored
44
+ * (medusapos: `useOrderOutbox`'s `isStored`). Only a true answer sets `canContinue`; without it, a failed save offers Retry only.
45
+ */
46
+ isStored?: (posOrder: PosOrder) => Promise<boolean>;
47
+ /** Tests only: overrides HUNG_SAVE_CHECK_MS, so a test can shrink the hung-save poll's interval. */
48
+ hungSaveCheckMs?: number;
49
+ }) {
50
+ const taxContext = useTax();
51
+ const madeWith = useRef({ taxContext, currency: settings.currency });
52
+ const [builder, setBuilder] = useState(() => createOrderBuilder({ currency: settings.currency, taxContext }));
53
+ const [order, setOrder] = useState(() => builder.getSnapshot());
54
+ const [stage, setStage] = useState<SaleStage>({ kind: 'cart' });
55
+ const [error, setError] = useState<string | null>(null);
56
+ // The pending completion: the order complete() built for this tender attempt. The ref is read
57
+ // synchronously by complete() and the lock; `saving` mirrors it (true from complete()'s entry) for rendering.
58
+ const pending = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
59
+ const [saving, setSaving] = useState(false);
60
+ // The session pinned by startTender for this tender (`undefined` inside: none); null until a tender starts.
61
+ const tenderSession = useRef<{ session: typeof opts.session } | null>(null);
62
+ // The pending completion isStored confirmed stored after its save failed; `canContinue` mirrors it for
63
+ // rendering. `attempts` counts complete() attempts, so a confirmation that lands after a new one is dropped.
64
+ const confirmed = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
65
+ const [canContinue, setCanContinue] = useState(false);
66
+ const attempts = useRef(0);
67
+ // The hung-save poll (at most one at a time): deliver() arms it while a completion's save is in
68
+ // flight, and it, confirm() and unmount all clear it.
69
+ const hungSaveTimer = useRef<ReturnType<typeof setInterval> | null>(null);
70
+ // Guards overlapping isStored checks (#161 review), per completion: a tick skips only while its own
71
+ // completion's check is already in flight, so an isStored slower than the interval never has more
72
+ // than one check running at once for that completion. Scoped to the completion (not a bare boolean)
73
+ // so a completion whose isStored never settles can never block a later sale's own poll (#161 follow-up).
74
+ const checking = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
75
+ function clearHungSaveTimer() {
76
+ if (hungSaveTimer.current !== null) { clearInterval(hungSaveTimer.current); hungSaveTimer.current = null; }
77
+ }
78
+ function confirm(completion: { order: Order; posOrder: PosOrder } | null) {
79
+ confirmed.current = completion;
80
+ setCanContinue(!!completion);
81
+ clearHungSaveTimer();
82
+ }
83
+ // The complete() call in flight, and the stage as of the last render or receipt, both read synchronously.
84
+ const inFlight = useRef<Promise<void> | null>(null);
85
+ const stageNow = useRef(stage);
86
+ stageNow.current = stage;
87
+ /** True, with SALE_SAVING shown, from complete()'s entry (inFlight) through a pending retry: the sale can't change. */
88
+ function locked() {
89
+ const active = !!(inFlight.current || pending.current);
90
+ if (active) setError(SALE_SAVING);
91
+ return active;
92
+ }
93
+ useEffect(() => {
94
+ const subscription = builder.order$.subscribe(setOrder);
95
+ return () => subscription.unsubscribe();
96
+ }, [builder]);
97
+ // New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale on them:
98
+ // a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
99
+ const idle = stage.kind === 'cart' && !order.lineItems.length;
100
+ useEffect(() => {
101
+ if (idle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) result.newSale();
102
+ });
103
+ // A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
104
+ useEffect(() => () => {
105
+ clearHungSaveTimer();
106
+ if (pending.current || inFlight.current) {
107
+ saleLogger.error('useSale unmounted with a save pending or in flight',
108
+ { orderId: pending.current?.posOrder.id, stage: stageNow.current.kind });
109
+ }
110
+ }, []);
111
+
112
+ function setTender(tender: { method: 'cash' | 'external'; amountMinor: number; reference?: string } | null) {
113
+ if (locked()) return;
114
+ const previous = builder.getSnapshot().payments[0];
115
+ if (previous) builder.removePayment(previous.id);
116
+ if (tender) builder.addPayment(tender);
117
+ setError(null);
118
+ }
119
+
120
+ /** Asks `isStored`; Continue is offered only once it confirms, for this attempt, with the completion still pending. */
121
+ function checkStored(completion: { order: Order; posOrder: PosOrder }) {
122
+ const attempt = attempts.current;
123
+ const isStored = opts.isStored; // a throw, even a synchronous one, counts as not stored
124
+ if (!isStored) return;
125
+ checking.current = completion;
126
+ void Promise.resolve(completion.posOrder).then(isStored).catch(() => false).then((stored) => {
127
+ if (stored === true && attempts.current === attempt && pending.current === completion) confirm(completion);
128
+ }).finally(() => { if (checking.current === completion) checking.current = null; });
129
+ }
130
+
131
+ /** Hands a pending completion to `onSaleCompleted`; the outcome applies only if it wasn't abandoned meanwhile. */
132
+ async function deliver(completion: { order: Order; posOrder: PosOrder }) {
133
+ clearHungSaveTimer();
134
+ // Re-asks isStored every HUNG_SAVE_CHECK_MS while this save is in flight and unconfirmed, so a hung
135
+ // post-insert step (no throw, no resolve) still offers Continue with no user action. Its own
136
+ // `timer` handle is cleared below only while it's still the current one, so a late settle from an
137
+ // attempt a newer sale has already superseded can never cut off that newer sale's poll (#161 review).
138
+ const timer = opts.isStored ? setInterval(() => {
139
+ if (checking.current === completion) return;
140
+ if (pending.current === completion && inFlight.current && confirmed.current !== completion) checkStored(completion);
141
+ else if (hungSaveTimer.current === timer) clearHungSaveTimer();
142
+ }, opts.hungSaveCheckMs ?? HUNG_SAVE_CHECK_MS) : null;
143
+ hungSaveTimer.current = timer;
144
+ try {
145
+ await opts.onSaleCompleted?.(completion.posOrder);
146
+ } catch (error) {
147
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ // Logged whatever the mount state and whether or not it's still pending (#150 review): a screen
150
+ // unmounted mid-save (medusapos Sign out) must never lose a throw silently. Skipped only once the
151
+ // order is confirmed stored, since a later throw on a known-safe order needs no fresh alarm.
152
+ if (confirmed.current !== completion) {
153
+ saleLogger.error('onSaleCompleted failed', { orderId: completion.posOrder.id, error: message });
154
+ }
155
+ if (pending.current === completion) {
156
+ setError(`The sale could not be saved: ${message}`);
157
+ checkStored(completion);
158
+ }
159
+ return;
160
+ }
161
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
162
+ if (pending.current !== completion) return;
163
+ pending.current = null;
164
+ confirm(null);
165
+ setSaving(false);
166
+ setError(null);
167
+ stageNow.current = { kind: 'receipt', order: completion.order, posOrder: completion.posOrder };
168
+ setStage(stageNow.current);
169
+ }
170
+
171
+ /**
172
+ * Runs one complete() attempt, unless one is in flight or the receipt shows. The in-flight promise is
173
+ * set before the attempt's first await, so a second call (a double tap) shares it and never builds a
174
+ * second order. `.finally` clears `inFlight` only if it still holds this attempt's own promise.
175
+ */
176
+ function once(attempt: () => Promise<void>): Promise<void> {
177
+ if (!inFlight.current && stageNow.current.kind !== 'receipt') {
178
+ const mine: Promise<void> = attempt().finally(() => { if (inFlight.current === mine) inFlight.current = null; });
179
+ inFlight.current = mine;
180
+ }
181
+ return inFlight.current ?? Promise.resolve();
182
+ }
183
+
184
+ const result = {
185
+ order, stage, error, idle,
186
+ /** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
187
+ saving,
188
+ /** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
189
+ canContinue,
190
+ add(entry: CatalogueEntry<any>, traits: ProductTraits<any>) {
191
+ if (locked()) return;
192
+ try {
193
+ addEntryToCart(builder, entry, traits, madeWith.current.currency);
194
+ setError(null);
195
+ } catch (error) {
196
+ if (!(error instanceof CartError)) throw error;
197
+ setError(error.message);
198
+ }
199
+ },
200
+ setQuantity(lineId: string, quantity: number) { if (!locked()) builder.updateQuantity(lineId, quantity); },
201
+ remove(lineId: string) { if (!locked()) builder.removeItem(lineId); },
202
+ /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
203
+ applyDiscount(lineId: string | null, discount: Discount): string | null {
204
+ if (locked()) return SALE_SAVING;
205
+ if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
206
+ const applied = (snapshot: Order) => lineId === null ? snapshot.discounts
207
+ : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
208
+ const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
209
+ if (lineId === null) builder.applyOrderDiscount(discount);
210
+ else builder.applyLineDiscount(lineId, discount);
211
+ // TallyUI caps a fixed amount at what is left; refuse it instead of showing more off than comes off.
212
+ const added = applied(builder.getSnapshot()).find((entry) => !before.has(entry.id));
213
+ if (added?.type === 'fixed' && added.amountMinor < added.value) {
214
+ builder.removeDiscount(added.id);
215
+ return `The discount is more than the ${lineId === null ? 'order' : 'line'}`;
216
+ }
217
+ return null;
218
+ },
219
+ removeDiscount(id: string) { if (!locked()) builder.removeDiscount(id); },
220
+ /**
221
+ * Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
222
+ * session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
223
+ * opened just before (#170 race). A repeat call mid-tender keeps the first pin.
224
+ */
225
+ startTender(method: 'cash' | 'external', options?: { session?: { id: string; sessions: RegisterSessionCollection } }) {
226
+ if (locked()) return;
227
+ const current = builder.getSnapshot();
228
+ if (!current.lineItems.length) return;
229
+ tenderSession.current ??= { session: options?.session ?? opts.session }; // a repeat call mid-tender keeps the pin
230
+ setTender(method === 'external' ? { method, amountMinor: current.totalMinor } : null);
231
+ setStage({ kind: 'tender', method });
232
+ },
233
+ setTender,
234
+ cancelTender() {
235
+ if (locked()) return;
236
+ setTender(null);
237
+ tenderSession.current = null;
238
+ setStage({ kind: 'cart' });
239
+ },
240
+ /**
241
+ * Finalizes the tender, stamps the session (see `session`), hands the order to `onSaleCompleted`,
242
+ * then shows the receipt. `saving` turns true and the sale locks (every change sets SALE_SAVING)
243
+ * from this call's entry, not only once the order is built; a refused finalize unlocks it again,
244
+ * with the refusal's error. Idempotent for one tender attempt (DECISIONS, ADR-052): once the order
245
+ * is built, that order is the sale, kept as the pending completion, because `onSaleCompleted` may
246
+ * already have stored it before failing. If `onSaleCompleted` throws, the error is set and the tender stays;
247
+ * calling `complete()` again reuses the pending completion exactly (the same `id`, `commandId`
248
+ * and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
249
+ * again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
250
+ * pending completion is cleared once `onSaleCompleted` resolves, or by `newSale()`. A call while
251
+ * another is in flight returns that call's promise, and a call on the receipt does nothing.
252
+ */
253
+ complete: () => once(async () => {
254
+ attempts.current++;
255
+ confirm(null);
256
+ if (pending.current) return deliver(pending.current);
257
+ setSaving(true);
258
+ const current = builder.getSnapshot();
259
+ let posOrder: PosOrder;
260
+ try {
261
+ posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities });
262
+ } catch (error) {
263
+ setSaving(false);
264
+ setError((error as Error).message);
265
+ return;
266
+ }
267
+ // The tender's pinned session; opts.session only for a complete() with no startTender (setTender from the cart),
268
+ // or when the tender pinned none but a session has rendered since (the backstop, warned: see startTender).
269
+ const session = tenderSession.current?.session ?? opts.session;
270
+ if (tenderSession.current && !tenderSession.current.session && session) {
271
+ try {
272
+ saleLogger.warn('the tender started before its session rendered; pass the confirmed session to startTender',
273
+ { orderId: posOrder.id, sessionId: session.id });
274
+ } catch {
275
+ // A failing sink must never lose the sale.
276
+ }
277
+ }
278
+ if (session) {
279
+ try {
280
+ posOrder = await stampSession(posOrder, session.id, session.sessions);
281
+ } catch {
282
+ // The money is taken: keep the sale, outside every closure (ADR-032, late sale).
283
+ const { sessionId: _unstamped, ...unstamped } = posOrder;
284
+ posOrder = { ...unstamped, lateSessionId: session.id };
285
+ try {
286
+ // useSale knows the cashier only by ref, so the actor carries no display name.
287
+ recordRegisterFact({ kind: 'late-sale', orderId: posOrder.id, sessionId: session.id, registerId: opts.registerId,
288
+ actor: { id: opts.cashierRef, name: '' } });
289
+ } catch {
290
+ // The logger calls the app's sinks unguarded; a failing sink must never lose the sale.
291
+ }
292
+ }
293
+ }
294
+ pending.current = { order: current, posOrder };
295
+ return deliver(pending.current);
296
+ }),
297
+ /**
298
+ * Starts a new, empty sale. Refused with SALE_SAVING, changing nothing, while a save is still
299
+ * building or stamping (nothing yet to ask `isStored` about), and while a pending completion
300
+ * exists and isn't confirmed stored — the ways out are Retry (`complete()`) or Continue, once
301
+ * `canContinue` (the Front desk, 2026-09-27; #149 review). So an app that doesn't pass `isStored`
302
+ * gets Retry only after a failed save. A refusal while a save is still delivering its order asks
303
+ * `isStored` afresh, so a hung save whose order is stored can still offer Continue. Past those, it
304
+ * abandons a confirmed pending completion (never handed to `onSaleCompleted` again: it's stored; a
305
+ * save still in flight carries on in the background, a throw logged at error) and starts the new
306
+ * sale outright — from the receipt, or an idle cart, there's nothing pending to abandon. Abandoning
307
+ * clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
308
+ * updates or requeues `pos_orders` itself.
309
+ */
310
+ newSale() {
311
+ if (inFlight.current && !pending.current) return setError(SALE_SAVING);
312
+ if (pending.current && confirmed.current !== pending.current) {
313
+ if (inFlight.current) checkStored(pending.current);
314
+ return setError(SALE_SAVING);
315
+ }
316
+ confirm(null); pending.current = null;
317
+ inFlight.current = null;
318
+ tenderSession.current = null;
319
+ setSaving(false);
320
+ madeWith.current = { taxContext, currency: settings.currency };
321
+ const next = createOrderBuilder({ currency: settings.currency, taxContext });
322
+ setBuilder(next);
323
+ setOrder(next.getSnapshot());
324
+ setStage({ kind: 'cart' });
325
+ setError(null);
326
+ },
327
+ /**
328
+ * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
329
+ * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
330
+ * its receipt is not shown. Otherwise does nothing.
331
+ */
332
+ continueSale() {
333
+ if (pending.current && confirmed.current === pending.current) result.newSale();
334
+ },
335
+ };
336
+ return result;
337
+ }
@@ -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,13 @@
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
+ /** `<TaxProvider>`'s props from the same settings, so its tax inclusivity matches the connector's. */
11
+ export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
12
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax };
13
+ }
@@ -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,69 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices } 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
+ /**
17
+ * Resolves the store settings on mount, and again when `connector` or `context` changes by
18
+ * identity (a store switch), so memoise both; the last request wins. `loadChoice` and
19
+ * `saveChoice` are read when a request starts, so inline functions don't trigger a re-resolve.
20
+ *
21
+ * ```tsx
22
+ * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
23
+ * if (store.state === 'choose')
24
+ * return <StoreSettingsChoiceScreen choices={store.choices} initial={store.initial} onSubmit={store.choose} />;
25
+ * if (store.state !== 'ready') return <Loading />; // or an error with store.retry, or the app's own config when unsupported
26
+ * const syncContext = withPricingContext(context, store.settings); // for the replication
27
+ * return <TaxProvider {...taxProviderProps(store.settings)}>{children}</TaxProvider>;
28
+ * ```
29
+ */
30
+ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSettingsState {
31
+ const { connector, context } = options;
32
+ const optionsRef = useRef(options);
33
+ optionsRef.current = options;
34
+ const requestRef = useRef(0);
35
+ const [state, setState] = useState<StoreSettingsState>(LOADING);
36
+
37
+ // `from`, when given, is the request that created the `choose`/`retry` calling this: a call
38
+ // arriving after a store switch made that request stale is ignored, so it can't apply an old
39
+ // pick to the new store.
40
+ const resolve = useCallback((choice?: StoreSettingsChoice, from?: number) => {
41
+ if (from !== undefined && from !== requestRef.current) return;
42
+ const request = ++requestRef.current;
43
+ const current = () => request === requestRef.current;
44
+ setState(LOADING);
45
+ resolveStoreSettings(optionsRef.current, choice).then(
46
+ (result) => {
47
+ if (!current()) return;
48
+ if (result.status === 'ready') setState({ state: 'ready', settings: result.settings, choice: result.choice });
49
+ else if (result.status === 'choose')
50
+ setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
51
+ else setState({ state: 'unsupported' });
52
+ },
53
+ // A retry keeps the choice that failed: a new pick is saved only once it resolves.
54
+ (error: unknown) => {
55
+ if (current()) setState({ state: 'error', error, retry: () => resolve(choice, request) });
56
+ },
57
+ );
58
+ }, []);
59
+
60
+ useEffect(() => {
61
+ resolve();
62
+ // Discards the in-flight result on a store switch or unmount.
63
+ return () => {
64
+ requestRef.current += 1;
65
+ };
66
+ }, [connector, context, resolve]);
67
+
68
+ return state;
69
+ }
@@ -0,0 +1,155 @@
1
+ /** Micro-minor-units per minor unit. */
2
+ export const MICROS_PER_MINOR = 1_000_000n;
3
+
4
+ /** Converts a plain decimal percentage with at most four fractional digits to safe integer ppm. */
5
+ export function ratePpmFromPercent(percent: number | string): number {
6
+ const value = String(percent);
7
+ const match = /^(\d+)(?:\.(\d{1,4}))?$/.exec(value);
8
+ if (!match || match[0] !== value) {
9
+ throw new RangeError('Percent must be a plain decimal with at most four fractional digits');
10
+ }
11
+ const ratePpm = BigInt(match[1]) * 10000n + BigInt((match[2] ?? '').padEnd(4, '0'));
12
+ if (ratePpm > BigInt(Number.MAX_SAFE_INTEGER)) {
13
+ throw new RangeError('Rate must be a safe integer');
14
+ }
15
+ return Number(ratePpm);
16
+ }
17
+
18
+ /**
19
+ * Exact exclusive tax, or inclusive tax rounded half away from zero to a micro-minor-unit.
20
+ * Throws RangeError unless amount and rate are safe integers and rate is non-negative.
21
+ */
22
+ export function taxMicros(amountMinor: number, ratePpm: number, pricesIncludeTax: boolean): bigint {
23
+ if (!Number.isSafeInteger(amountMinor) || !Number.isSafeInteger(ratePpm) || ratePpm < 0) {
24
+ throw new RangeError('Amount must be a safe integer and rate a non-negative safe integer');
25
+ }
26
+ const tax = BigInt(amountMinor) * BigInt(ratePpm);
27
+ if (!pricesIncludeTax) return tax;
28
+
29
+ const numerator = tax * MICROS_PER_MINOR;
30
+ const denominator = MICROS_PER_MINOR + BigInt(ratePpm);
31
+ const sign = numerator < 0n ? -1n : 1n;
32
+ return sign * ((sign * numerator + denominator / 2n) / denominator);
33
+ }
34
+
35
+ /** Rounds micro-minor-units to integer minor units, half away from zero. */
36
+ export function roundMicrosToMinor(micros: bigint): number {
37
+ const sign = micros < 0n ? -1n : 1n;
38
+ const minor = sign * ((sign * micros + MICROS_PER_MINOR / 2n) / MICROS_PER_MINOR);
39
+ if (minor < -BigInt(Number.MAX_SAFE_INTEGER) || minor > BigInt(Number.MAX_SAFE_INTEGER)) {
40
+ throw new RangeError('Rounded amount must be a safe integer');
41
+ }
42
+ return Number(minor);
43
+ }
44
+
45
+ export interface TaxLineInput {
46
+ unitPriceMinor: number; // integer, may be negative (returns)
47
+ quantity: number; // integer >= 1
48
+ ratePpm: number; // integer >= 0
49
+ }
50
+
51
+ export interface OrderTaxTotals {
52
+ subtotalMinor: number; // excl. tax
53
+ taxMinor: number; // rounded once
54
+ totalMinor: number; // incl. tax
55
+ lineTaxMicros: bigint[]; // exact, one per input line, same order
56
+ }
57
+
58
+ /**
59
+ * Sums tax on each unit price × quantity and rounds once for the order.
60
+ * Exclusive: total = subtotal + tax. Inclusive: subtotal = total − tax.
61
+ * Empty orders return zeros and an empty lineTaxMicros.
62
+ * Throws RangeError for unsafe integer inputs or totals, negative rates, or quantity < 1.
63
+ */
64
+ export function computeOrderTax(lines: TaxLineInput[], pricesIncludeTax: boolean): OrderTaxTotals {
65
+ let amountTotal = 0n;
66
+ let taxTotal = 0n;
67
+ const lineTaxMicros: bigint[] = [];
68
+
69
+ for (const line of lines) {
70
+ if (!Number.isSafeInteger(line.quantity) || line.quantity < 1) {
71
+ throw new RangeError('Quantity must be a safe integer >= 1');
72
+ }
73
+ let tax = taxMicros(line.unitPriceMinor, line.ratePpm, false) * BigInt(line.quantity);
74
+ const amount = BigInt(line.unitPriceMinor) * BigInt(line.quantity);
75
+ if (pricesIncludeTax) {
76
+ const numerator = tax * MICROS_PER_MINOR;
77
+ const denominator = MICROS_PER_MINOR + BigInt(line.ratePpm);
78
+ const sign = numerator < 0n ? -1n : 1n;
79
+ tax = sign * ((sign * numerator + denominator / 2n) / denominator);
80
+ }
81
+ amountTotal += amount;
82
+ taxTotal += tax;
83
+ lineTaxMicros.push(tax);
84
+ }
85
+
86
+ const taxMinor = taxTotal / MICROS_PER_MINOR
87
+ + BigInt(roundMicrosToMinor(taxTotal % MICROS_PER_MINOR));
88
+ const subtotalMinor = pricesIncludeTax ? amountTotal - taxMinor : amountTotal;
89
+ const totalMinor = pricesIncludeTax ? amountTotal : amountTotal + taxMinor;
90
+ for (const value of [subtotalMinor, taxMinor, totalMinor]) {
91
+ if (value < -BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
92
+ throw new RangeError('Order totals must be safe integers');
93
+ }
94
+ }
95
+ return {
96
+ subtotalMinor: Number(subtotalMinor),
97
+ taxMinor: Number(taxMinor),
98
+ totalMinor: Number(totalMinor),
99
+ lineTaxMicros,
100
+ };
101
+ }
102
+
103
+ export interface RateTaxLine {
104
+ label: string;
105
+ code?: string;
106
+ ratePpm: number;
107
+ netMinor: number;
108
+ amountMinor: number;
109
+ }
110
+
111
+ /**
112
+ * Groups each line's stacked tax rates (ADR-040: each independently taxes the line's full
113
+ * tax-free base) by `code`+`ratePpm`, floors each group's exact tax to minor units, then
114
+ * distributes `orderTaxMinor` minus that floor sum by largest remainder (ties to the higher rate)
115
+ * so the rates sum to exactly `orderTaxMinor`. A line's `netMinor` is in its OWN tax mode
116
+ * (`taxInclusive`): an inclusive line's net already contains its tax, so its tax-free base is
117
+ * `netMinor − roundMicrosToMinor(Σ its taxMicros)`; an exclusive line's base is `netMinor` as is.
118
+ * Shared by a receipt's tax summary and a Z report's per-rate breakdown.
119
+ */
120
+ export function taxLinesByRate(
121
+ lines: readonly {
122
+ netMinor: number;
123
+ taxInclusive: boolean;
124
+ taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[];
125
+ }[],
126
+ orderTaxMinor: number,
127
+ taxLabels?: Record<number, string>,
128
+ ): RateTaxLine[] {
129
+ const byRate = new Map<string, { code?: string; ratePpm: number; micros: bigint; netMinor: number }>();
130
+ for (const line of lines) {
131
+ const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
132
+ const base = line.taxInclusive ? line.netMinor - roundMicrosToMinor(lineTaxMicros) : line.netMinor;
133
+ for (const tax of line.taxLines) {
134
+ const key = JSON.stringify([tax.code ?? '', tax.ratePpm]);
135
+ const existing = byRate.get(key);
136
+ byRate.set(key, {
137
+ code: tax.code, ratePpm: tax.ratePpm,
138
+ micros: (existing?.micros ?? 0n) + BigInt(tax.taxMicros),
139
+ netMinor: (existing?.netMinor ?? 0) + base,
140
+ });
141
+ }
142
+ }
143
+ const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
144
+ const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
145
+ return {
146
+ line: { label: taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`, code, ratePpm, netMinor, amountMinor: Number(floor) },
147
+ remainder: micros - floor * MICROS_PER_MINOR,
148
+ };
149
+ });
150
+ const leftover = orderTaxMinor - groups.reduce((sum, group) => sum + group.line.amountMinor, 0);
151
+ const ranked = [...groups].sort((a, b) =>
152
+ a.remainder === b.remainder ? b.line.ratePpm - a.line.ratePpm : a.remainder > b.remainder ? -1 : 1);
153
+ for (const group of ranked.slice(0, leftover)) group.line.amountMinor += 1;
154
+ return groups.map((group) => group.line);
155
+ }
package/src/tax/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { calculateTax, extractTax, addTax } from './calculate';
1
+ export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, computeOrderTax, taxLinesByRate } from './exact';
2
+ export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './exact';
2
3
  export { TaxProvider, useTax } from './tax-provider';
3
4
  export type { TaxProviderProps } from './tax-provider';
4
- export type { TaxResult, TaxRateMap, TaxContext } from './types';
5
+ export type { TaxRateMap, TaxContext } from './types';