@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +270 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- 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
|
+
}
|
package/src/tax/exact.ts
ADDED
|
@@ -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 {
|
|
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 {
|
|
5
|
+
export type { TaxRateMap, TaxContext } from './types';
|