@tallyui/pos 2.0.0 → 3.0.0-next.1

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 (44) hide show
  1. package/dist/index.d.ts +323 -61
  2. package/dist/index.js +1423 -186
  3. package/package.json +8 -8
  4. package/src/index.ts +15 -7
  5. package/src/order/index.ts +3 -0
  6. package/src/order/order-builder.ts +23 -7
  7. package/src/order/order-manager.ts +3 -1
  8. package/src/order/tax-figures.ts +23 -0
  9. package/src/order/types.ts +9 -3
  10. package/src/outbox/backend-not-found.ts +27 -0
  11. package/src/outbox/http-transport.ts +5 -4
  12. package/src/outbox/index.ts +2 -0
  13. package/src/outbox/logger.ts +3 -0
  14. package/src/outbox/order-outbox.ts +336 -27
  15. package/src/outbox/register-outbox.ts +250 -0
  16. package/src/outbox/types.test-d.ts +22 -0
  17. package/src/outbox/types.ts +17 -3
  18. package/src/outbox/use-order-outbox.ts +20 -6
  19. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  20. package/src/pos-order/command.ts +99 -11
  21. package/src/pos-order/finalize.ts +145 -7
  22. package/src/pos-order/index.ts +2 -2
  23. package/src/pos-order/needs-attention.ts +9 -4
  24. package/src/pos-order/open.ts +100 -37
  25. package/src/pos-order/schema.ts +35 -5
  26. package/src/pos-order/types.ts +31 -5
  27. package/src/product/search-products.ts +5 -2
  28. package/src/receipt/build-receipt-data.ts +5 -3
  29. package/src/receipt/types.ts +1 -0
  30. package/src/register/closure-document.ts +7 -0
  31. package/src/register/index.ts +5 -3
  32. package/src/register/movement-input.ts +1 -1
  33. package/src/register/register-commands.ts +147 -0
  34. package/src/register/register-document.ts +26 -1
  35. package/src/register/session-store.ts +41 -12
  36. package/src/register/use-register-session.ts +46 -2
  37. package/src/sale/cart.ts +1 -0
  38. package/src/sale/use-sale.ts +97 -40
  39. package/src/store-settings/map-store-settings.ts +7 -2
  40. package/src/store-settings/use-store-settings.ts +55 -9
  41. package/src/tax/exact.ts +74 -11
  42. package/src/tax/index.ts +1 -1
  43. package/src/tax/tax-provider.tsx +26 -4
  44. package/src/tax/types.ts +6 -0
@@ -1,7 +1,9 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
3
- import { createOrderBuilder, type Discount, type Order } from '../order';
3
+ import { createOrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
4
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';
5
7
  import { useTax } from '../tax';
6
8
  import { recordRegisterFact, stampSession, type RegisterSessionCollection } from '../register';
7
9
  import { createLogger } from '../logging';
@@ -12,7 +14,7 @@ import { addEntryToCart, CartError } from './cart';
12
14
  export const saleLogger = createLogger('sale');
13
15
 
14
16
  export type SaleStage = { kind: 'cart' } | { kind: 'tender'; method: 'cash' | 'external' }
15
- | { kind: 'receipt'; order: Order; posOrder: PosOrder };
17
+ | { kind: 'receipt'; order: SentOrder; posOrder: PosOrder };
16
18
  /** TallyUI finalizeOrder's refusal below order.create v2 (c19a203), shown when the discount is applied; finalize stays the backstop. */
17
19
  export const DISCOUNTS_UNSUPPORTED = 'finalize: discounts are not supported by the server yet (order.create v2)';
18
20
  /** Every sale change refuses with this while a completion is pending (see `complete()`). */
@@ -49,16 +51,26 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
49
51
  }) {
50
52
  const taxContext = useTax();
51
53
  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 [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());
54
59
  const [stage, setStage] = useState<SaleStage>({ kind: 'cart' });
55
- const [error, setError] = useState<string | null>(null);
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);
56
64
  // The pending completion: the order complete() built for this tender attempt. The ref is read
57
65
  // synchronously by complete() and the lock; `saving` mirrors it (true from complete()'s entry) for rendering.
58
66
  const pending = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
59
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;
60
70
  // The session pinned by startTender for this tender (`undefined` inside: none); null until a tender starts.
61
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);
62
74
  // The pending completion isStored confirmed stored after its save failed; `canContinue` mirrors it for
63
75
  // rendering. `attempts` counts complete() attempts, so a confirmation that lands after a new one is dropped.
64
76
  const confirmed = useRef<{ order: Order; posOrder: PosOrder } | null>(null);
@@ -90,15 +102,40 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
90
102
  if (active) setError(SALE_SAVING);
91
103
  return active;
92
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
+ }
93
126
  useEffect(() => {
94
- const subscription = builder.order$.subscribe(setOrder);
127
+ const subscription = rendered.order$.subscribe(setOrder);
95
128
  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:
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:
98
131
  // a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
99
132
  const idle = stage.kind === 'cart' && !order.lineItems.length;
100
- useEffect(() => {
101
- if (idle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) result.newSale();
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);
102
139
  });
103
140
  // A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
104
141
  useEffect(() => () => {
@@ -111,10 +148,21 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
111
148
 
112
149
  function setTender(tender: { method: 'cash' | 'external'; amountMinor: number; reference?: string } | null) {
113
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;
114
158
  const previous = builder.getSnapshot().payments[0];
115
159
  if (previous) builder.removePayment(previous.id);
116
- if (tender) builder.addPayment(tender);
117
- setError(null);
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 */ }
118
166
  }
119
167
 
120
168
  /** Asks `isStored`; Continue is offered only once it confirms, for this attempt, with the completion still pending. */
@@ -161,10 +209,11 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
161
209
  if (hungSaveTimer.current === timer) clearHungSaveTimer();
162
210
  if (pending.current !== completion) return;
163
211
  pending.current = null;
212
+ droppedReference.current = null;
164
213
  confirm(null);
165
214
  setSaving(false);
166
215
  setError(null);
167
- stageNow.current = { kind: 'receipt', order: completion.order, posOrder: completion.posOrder };
216
+ stageNow.current = { kind: 'receipt', order: withSentForm(completion.order, completion.posOrder), posOrder: completion.posOrder };
168
217
  setStage(stageNow.current);
169
218
  }
170
219
 
@@ -189,20 +238,32 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
189
238
  canContinue,
190
239
  add(entry: CatalogueEntry<any>, traits: ProductTraits<any>) {
191
240
  if (locked()) return;
241
+ const builder = builderNow.current;
192
242
  try {
193
- addEntryToCart(builder, entry, traits, madeWith.current.currency);
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); }
194
254
  setError(null);
195
255
  } catch (error) {
196
256
  if (!(error instanceof CartError)) throw error;
197
257
  setError(error.message);
198
258
  }
199
259
  },
200
- setQuantity(lineId: string, quantity: number) { if (!locked()) builder.updateQuantity(lineId, quantity); },
201
- remove(lineId: string) { if (!locked()) builder.removeItem(lineId); },
260
+ setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
261
+ remove(lineId: string) { if (!locked()) builderNow.current.removeItem(lineId); },
202
262
  /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
203
263
  applyDiscount(lineId: string | null, discount: Discount): string | null {
204
264
  if (locked()) return SALE_SAVING;
205
265
  if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
266
+ const builder = builderNow.current;
206
267
  const applied = (snapshot: Order) => lineId === null ? snapshot.discounts
207
268
  : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
208
269
  const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
@@ -216,7 +277,16 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
216
277
  }
217
278
  return null;
218
279
  },
219
- removeDiscount(id: string) { if (!locked()) builder.removeDiscount(id); },
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
+ },
220
290
  /**
221
291
  * Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
222
292
  * session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
@@ -224,7 +294,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
224
294
  */
225
295
  startTender(method: 'cash' | 'external', options?: { session?: { id: string; sessions: RegisterSessionCollection } }) {
226
296
  if (locked()) return;
227
- const current = builder.getSnapshot();
297
+ const current = builderNow.current.getSnapshot();
228
298
  if (!current.lineItems.length) return;
229
299
  tenderSession.current ??= { session: options?.session ?? opts.session }; // a repeat call mid-tender keeps the pin
230
300
  setTender(method === 'external' ? { method, amountMinor: current.totalMinor } : null);
@@ -248,17 +318,20 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
248
318
  * and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
249
319
  * again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
250
320
  * 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.
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.
252
324
  */
253
- complete: () => once(async () => {
325
+ complete: () => configError && !pending.current && !inFlight.current ? Promise.resolve() : once(async () => {
254
326
  attempts.current++;
255
327
  confirm(null);
256
328
  if (pending.current) return deliver(pending.current);
257
329
  setSaving(true);
258
- const current = builder.getSnapshot();
330
+ const current = builderNow.current.getSnapshot();
259
331
  let posOrder: PosOrder;
260
332
  try {
261
- posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities });
333
+ posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities,
334
+ ...(droppedReference.current ? { localWarnings: [{ code: 'payment_reference_dropped', paymentId: droppedReference.current }] } : {}) });
262
335
  } catch (error) {
263
336
  setSaving(false);
264
337
  setError((error as Error).message);
@@ -307,23 +380,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
307
380
  * clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
308
381
  * updates or requeues `pos_orders` itself.
309
382
  */
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
- },
383
+ newSale() { resetSale(null); },
327
384
  /**
328
385
  * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
329
386
  * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
@@ -7,7 +7,12 @@ export function withPricingContext(context: SyncContext, settings: StoreSettings
7
7
  return settings.pricingContext ? { ...rest, pricingContext: settings.pricingContext } : rest;
8
8
  }
9
9
 
10
- /** `<TaxProvider>`'s props from the same settings, so its tax inclusivity matches the connector's. */
10
+ /**
11
+ * `<TaxProvider>`'s props from the same settings, so its tax inclusivity, rounding and rate codes 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
+ */
11
15
  export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
12
- return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax };
16
+ const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
17
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes }) };
13
18
  }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices } from '@tallyui/core';
2
+ import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices, TaxRounding } from '@tallyui/core';
3
3
  import { resolveStoreSettings } from './resolve-store-settings';
4
4
  import type { ResolveStoreSettingsOptions } from './resolve-store-settings';
5
5
 
@@ -7,16 +7,45 @@ export type StoreSettingsState =
7
7
  | { state: 'loading' }
8
8
  | { state: 'ready'; settings: StoreSettings; choice?: StoreSettingsChoice }
9
9
  | { state: 'choose'; choices: StoreSettingsChoices; initial?: StoreSettingsChoice; choose: (choice: StoreSettingsChoice) => void }
10
- | { state: 'error'; error: unknown; retry: () => void }
10
+ | { state: 'error'; error: unknown; retry: () => void; nextRetryAt?: number }
11
11
  | { state: 'unsupported' };
12
12
 
13
13
  // One object, so re-entering loading while already loading doesn't re-render.
14
14
  const LOADING: StoreSettingsState = { state: 'loading' };
15
15
 
16
+ export class StoreCapabilitiesUnavailableError extends Error {
17
+ constructor(cause?: unknown) {
18
+ super('Store capabilities unavailable', { cause });
19
+ this.name = 'StoreCapabilitiesUnavailableError';
20
+ }
21
+ }
22
+
23
+ // Exponential retry for unknown rounding: 5 seconds, doubling up to 5 minutes.
24
+ const INITIAL_RETRY_MS = 5_000;
25
+ const MAX_RETRY_MS = 5 * 60_000;
26
+
27
+ // The store's taxRounding (#324): the context's capabilities, else one read of the connector's.
28
+ function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): Promise<TaxRounding | undefined> {
29
+ if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
30
+ if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(undefined);
31
+ const read = connector.capabilities;
32
+ return Promise.resolve().then(() => read(context)).then(
33
+ (capabilities) => {
34
+ if (!capabilities) throw new StoreCapabilitiesUnavailableError();
35
+ return capabilities.taxRounding;
36
+ },
37
+ (cause) => { throw new StoreCapabilitiesUnavailableError(cause); },
38
+ );
39
+ }
40
+
16
41
  /**
17
42
  * Resolves the store settings on mount, and again when `connector` or `context` changes by
18
43
  * identity (a store switch), so memoise both; the last request wins. `loadChoice` and
19
44
  * `saveChoice` are read when a request starts, so inline functions don't trigger a re-resolve.
45
+ * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
46
+ * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
47
+ * rounding already known: no later change of `settings` holds a sale.
48
+ * Unknown rounding keeps settings unresolved and retries by itself; show "Can't reach the store's settings yet. Retrying…" while `nextRetryAt` is set.
20
49
  *
21
50
  * ```tsx
22
51
  * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
@@ -32,27 +61,42 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
32
61
  const optionsRef = useRef(options);
33
62
  optionsRef.current = options;
34
63
  const requestRef = useRef(0);
64
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
65
+ const retryDelayRef = useRef(INITIAL_RETRY_MS);
35
66
  const [state, setState] = useState<StoreSettingsState>(LOADING);
36
67
 
37
68
  // `from`, when given, is the request that created the `choose`/`retry` calling this: a call
38
69
  // 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) => {
70
+ // pick to the new store. `known`, when given, is the rounding that request already read, so
71
+ // a pick doesn't read it again.
72
+ const resolve = useCallback((choice?: StoreSettingsChoice, from?: number, known?: Promise<TaxRounding | undefined>) => {
41
73
  if (from !== undefined && from !== requestRef.current) return;
74
+ clearTimeout(retryTimerRef.current);
42
75
  const request = ++requestRef.current;
43
76
  const current = () => request === requestRef.current;
44
77
  setState(LOADING);
45
- resolveStoreSettings(optionsRef.current, choice).then(
46
- (result) => {
78
+ const rounding = known ?? readTaxRounding(optionsRef.current);
79
+ Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
80
+ ([result, taxRounding]) => {
47
81
  if (!current()) return;
48
- if (result.status === 'ready') setState({ state: 'ready', settings: result.settings, choice: result.choice });
82
+ retryDelayRef.current = INITIAL_RETRY_MS;
83
+ if (result.status === 'ready')
84
+ setState({ state: 'ready', settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
49
85
  else if (result.status === 'choose')
50
- setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
86
+ setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request, rounding) });
51
87
  else setState({ state: 'unsupported' });
52
88
  },
53
89
  // A retry keeps the choice that failed: a new pick is saved only once it resolves.
54
90
  (error: unknown) => {
55
- if (current()) setState({ state: 'error', error, retry: () => resolve(choice, request) });
91
+ if (!current()) return;
92
+ const retry = () => resolve(choice, request);
93
+ if (error instanceof StoreCapabilitiesUnavailableError) {
94
+ const delay = retryDelayRef.current;
95
+ const nextRetryAt = Date.now() + delay;
96
+ retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
97
+ retryTimerRef.current = setTimeout(retry, delay);
98
+ setState({ state: 'error', error, retry, nextRetryAt });
99
+ } else setState({ state: 'error', error, retry });
56
100
  },
57
101
  );
58
102
  }, []);
@@ -61,6 +105,8 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
61
105
  resolve();
62
106
  // Discards the in-flight result on a store switch or unmount.
63
107
  return () => {
108
+ clearTimeout(retryTimerRef.current);
109
+ retryDelayRef.current = INITIAL_RETRY_MS;
64
110
  requestRef.current += 1;
65
111
  };
66
112
  }, [connector, context, resolve]);
package/src/tax/exact.ts CHANGED
@@ -1,6 +1,11 @@
1
+ import type { TaxRounding } from '@tallyui/core';
2
+
1
3
  /** Micro-minor-units per minor unit. */
2
4
  export const MICROS_PER_MINOR = 1_000_000n;
3
5
 
6
+ /** What an absent `TaxRounding` means (#287): the till's only rounding before #287a. */
7
+ export const DEFAULT_TAX_ROUNDING = { granularity: 'per_order', mode: 'half_away_from_zero' } as const satisfies TaxRounding;
8
+
4
9
  /** Converts a plain decimal percentage with at most four fractional digits to safe integer ppm. */
5
10
  export function ratePpmFromPercent(percent: number | string): number {
6
11
  const value = String(percent);
@@ -32,10 +37,19 @@ export function taxMicros(amountMinor: number, ratePpm: number, pricesIncludeTax
32
37
  return sign * ((sign * numerator + denominator / 2n) / denominator);
33
38
  }
34
39
 
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);
40
+ /** #287: `half_up` is Math.round's rule, Vendure's DefaultMoneyStrategy (default-money-strategy.js:19-21). */
41
+ export type RoundingMode = 'half_away_from_zero' | 'half_up';
42
+
43
+ /** `n / d` (d > 0) rounded to an integer in `mode`, exactly; the modes differ only on exact negative halves. */
44
+ export function roundRatio(n: bigint, d: bigint, mode: RoundingMode = 'half_away_from_zero'): bigint {
45
+ if (mode === 'half_away_from_zero' && n < 0n) return -roundRatio(-n, d, mode);
46
+ const a = 2n * n + d, b = 2n * d; // floor(n / d + 1/2)
47
+ return a / b - (a < 0n && a % b !== 0n ? 1n : 0n);
48
+ }
49
+
50
+ /** Rounds micro-minor-units to integer minor units, half away from zero unless `mode` says otherwise. */
51
+ export function roundMicrosToMinor(micros: bigint, mode?: RoundingMode): number {
52
+ const minor = roundRatio(micros, MICROS_PER_MINOR, mode);
39
53
  if (minor < -BigInt(Number.MAX_SAFE_INTEGER) || minor > BigInt(Number.MAX_SAFE_INTEGER)) {
40
54
  throw new RangeError('Rounded amount must be a safe integer');
41
55
  }
@@ -108,6 +122,54 @@ export interface RateTaxLine {
108
122
  amountMinor: number;
109
123
  }
110
124
 
125
+ type TaxedLine = { netMinor: number; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
126
+
127
+ /**
128
+ * A `per_line_items` or `per_rate_group_items` store's figures (#287) as @vendure/core 3.7.3 computes them; undefined
129
+ * otherwise. `baseMinor` (the subtotal) is Σ each item's rounded net, `rates` the tax by `code`+`ratePpm`, summing to
130
+ * `taxMinor`; the total is their sum. Vendure's rounding is `half_up`. vendurepos scores the #38 set with this.
131
+ */
132
+ export function roundedTaxByRate(lines: readonly TaxedLine[], rounding?: TaxRounding) {
133
+ if (rounding?.granularity !== 'per_line_items' && rounding?.granularity !== 'per_rate_group_items') return undefined;
134
+ // Known gap (#287): Vendure's inclusive per_rate_group_items total can differ from the shelf prices, which the display
135
+ // can't show until it has a rounding row (#310), so an order with any inclusive line keeps per_order's figures and rows.
136
+ if (rounding.granularity === 'per_rate_group_items' && lines.some((line) => line.taxInclusive)) return undefined;
137
+ const { granularity, mode } = rounding;
138
+ const rates = new Map<string, { code?: string; ratePpm: number; netMinor: bigint; amountMinor: bigint }>();
139
+ let baseMinor = 0n;
140
+ // vendurepos posts each line undiscounted (A) and its discountMinor as its own −D surcharge in the line's mode with
141
+ // the line's tax lines (vendurepos/app order-create.service.ts:654-663); a return line's negative D is its cap, not an item.
142
+ const items = lines.flatMap((line) => (line.discountMinor ?? 0) > 0
143
+ ? [{ ...line, netMinor: line.netMinor + line.discountMinor! }, { ...line, netMinor: -line.discountMinor! }] : [line]);
144
+ for (const line of items) {
145
+ const net = BigInt(line.netMinor);
146
+ const ratePpm = BigInt(line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0));
147
+ // Each item's net is rounded first, at the sum of its rates (order-line.entity.js:121-122): an inclusive item's
148
+ // netPriceOf(gross) (:193-196, :249-251; surcharge.entity.js:33-38; tax-utils.js:16-17), an exclusive one's as it is.
149
+ const base = line.taxInclusive ? roundRatio(net * MICROS_PER_MINOR, MICROS_PER_MINOR + ratePpm, mode) : net;
150
+ // per_line_items: the item's tax is its rounded gross less that net (:201-204, :259-261; surcharge.entity.js:36-38;
151
+ // default-order-tax-calculation-strategy.js:22-29): round(net × r) exclusive, gross − net inclusive.
152
+ const lineTax = line.taxInclusive ? net - base : roundRatio(net * ratePpm, MICROS_PER_MINOR, mode);
153
+ let left = lineTax;
154
+ baseMinor += base;
155
+ line.taxLines.forEach((tax, index) => {
156
+ // Vendure's key is the rate's name and value (order-level-tax-calculation-strategy.js:103).
157
+ const key = JSON.stringify([tax.code ?? '', tax.ratePpm]);
158
+ const row = rates.get(key) ?? { code: tax.code, ratePpm: tax.ratePpm, netMinor: 0n, amountMinor: 0n };
159
+ // per_line_items, stacked rates: each rate's share of the item's tax, rounded (default-order-tax-calculation-strategy.js:51-66),
160
+ // the last rate taking the rest so the rows sum to the tax. per_rate_group_items: each rate takes the whole net, and
161
+ // a −D item joins its line's group (order-level-tax-calculation-strategy.js:88-91, :98-115).
162
+ const share = index === line.taxLines.length - 1 ? left : ratePpm === 0n ? 0n : roundRatio(lineTax * BigInt(tax.ratePpm), ratePpm, mode);
163
+ left -= share;
164
+ rates.set(key, { ...row, netMinor: row.netMinor + base, amountMinor: row.amountMinor + share });
165
+ });
166
+ }
167
+ const rows = [...rates.values()].map((row) => ({ ...row, netMinor: Number(row.netMinor), amountMinor: Number(
168
+ // per_rate_group_items: round(Σ net × r) once per group, and the order's tax is their sum (:35-49, :56).
169
+ granularity === 'per_rate_group_items' ? roundRatio(row.netMinor * BigInt(row.ratePpm), MICROS_PER_MINOR, mode) : row.amountMinor) }));
170
+ return { baseMinor: Number(baseMinor), taxMinor: rows.reduce((sum, row) => sum + row.amountMinor, 0), rates: rows };
171
+ }
172
+
111
173
  /**
112
174
  * Groups each line's stacked tax rates (ADR-040: each independently taxes the line's full
113
175
  * tax-free base) by `code`+`ratePpm`, floors each group's exact tax to minor units, then
@@ -115,17 +177,18 @@ export interface RateTaxLine {
115
177
  * so the rates sum to exactly `orderTaxMinor`. A line's `netMinor` is in its OWN tax mode
116
178
  * (`taxInclusive`): an inclusive line's net already contains its tax, so its tax-free base is
117
179
  * `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.
180
+ * Shared by a receipt's tax summary and a Z report's per-rate breakdown. That is `per_order`'s split; a `per_line_items` or
181
+ * `per_rate_group_items` order's rows are `roundedTaxByRate`'s (#287).
119
182
  */
120
183
  export function taxLinesByRate(
121
- lines: readonly {
122
- netMinor: number;
123
- taxInclusive: boolean;
124
- taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[];
125
- }[],
184
+ lines: readonly TaxedLine[],
126
185
  orderTaxMinor: number,
127
186
  taxLabels?: Record<number, string>,
187
+ rounding?: TaxRounding,
128
188
  ): RateTaxLine[] {
189
+ const label = (ratePpm: number) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`;
190
+ const rounded = roundedTaxByRate(lines, rounding);
191
+ if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
129
192
  const byRate = new Map<string, { code?: string; ratePpm: number; micros: bigint; netMinor: number }>();
130
193
  for (const line of lines) {
131
194
  const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
@@ -143,7 +206,7 @@ export function taxLinesByRate(
143
206
  const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
144
207
  const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
145
208
  return {
146
- line: { label: taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`, code, ratePpm, netMinor, amountMinor: Number(floor) },
209
+ line: { label: label(ratePpm), code, ratePpm, netMinor, amountMinor: Number(floor) },
147
210
  remainder: micros - floor * MICROS_PER_MINOR,
148
211
  };
149
212
  });
package/src/tax/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, computeOrderTax, taxLinesByRate } from './exact';
2
2
  export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './exact';
3
- export { TaxProvider, useTax } from './tax-provider';
3
+ export { TaxProvider, useTax, taxLogger } from './tax-provider';
4
4
  export type { TaxProviderProps } from './tax-provider';
5
5
  export type { TaxRateMap, TaxContext } from './types';
@@ -1,28 +1,50 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import type { ReactNode } from 'react';
3
+ import type { TaxRounding } from '@tallyui/core';
3
4
  import type { TaxContext } from './types';
5
+ import { createLogger } from '../logging';
6
+
7
+ export const taxLogger = createLogger('tax');
4
8
 
5
9
  const TaxCtx = createContext<TaxContext | null>(null);
6
10
 
7
11
  export interface TaxProviderProps {
8
12
  ratesPpm: Record<string, number>;
9
13
  pricesIncludeTax: boolean;
14
+ /** `ServerCapabilities.taxRounding` (#287); a change restarts an idle sale under it. */
15
+ rounding?: TaxRounding;
16
+ /** Tax class → the backend's rate name, for `per_rate_group_items`'s grouping (#287). */
17
+ rateCodes?: Record<string, string>;
10
18
  children: ReactNode;
11
19
  }
12
20
 
13
- export function TaxProvider({ ratesPpm, pricesIncludeTax, children }: TaxProviderProps) {
21
+ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }: TaxProviderProps) {
14
22
  const value = useMemo<TaxContext>(
15
23
  () => {
16
24
  if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
17
25
  throw new RangeError('TaxProvider: rates must be integer ppm');
18
26
  }
27
+ const unknown = new Set<string>();
19
28
  return {
20
- getTaxRatePpm: (taxClass) =>
21
- (taxClass === undefined ? undefined : ratesPpm[taxClass]) ?? ratesPpm.default ?? 0,
29
+ getTaxRatePpm: (taxClass) => {
30
+ if (taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
31
+ // A class the store's rates don't key is taxed at the default rate; warn once per class and rate map.
32
+ if (taxClass !== undefined && !unknown.has(taxClass)) {
33
+ unknown.add(taxClass);
34
+ taxLogger.warn('No tax rate for this tax class; using the default rate', { taxClass });
35
+ }
36
+ return ratesPpm.default ?? 0;
37
+ },
38
+ // The name of the rate getTaxRatePpm picks: the class's own, else the default's.
39
+ getTaxRateCode: (taxClass) => {
40
+ const key = taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass) ? taxClass : 'default';
41
+ return rateCodes && Object.hasOwn(rateCodes, key) ? rateCodes[key] : undefined;
42
+ },
22
43
  pricesIncludeTax,
44
+ ...(rounding ? { rounding } : {}),
23
45
  };
24
46
  },
25
- [ratesPpm, pricesIncludeTax],
47
+ [ratesPpm, pricesIncludeTax, rounding, rateCodes],
26
48
  );
27
49
 
28
50
  return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
package/src/tax/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { TaxRounding } from '@tallyui/core';
2
+
1
3
  /** Tax rates as integer parts per million (19% = 190000). */
2
4
  export type TaxRateMap = {
3
5
  default: number;
@@ -7,5 +9,9 @@ export type TaxRateMap = {
7
9
  export interface TaxContext {
8
10
  /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
9
11
  getTaxRatePpm(taxClass?: string): number;
12
+ /** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */
13
+ getTaxRateCode?(taxClass?: string): string | undefined;
10
14
  pricesIncludeTax: boolean;
15
+ /** The store's tax rounding strategy (#287); absent means `per_order`, half away from zero. */
16
+ rounding?: TaxRounding;
11
17
  }