@tallyui/pos 2.0.0 → 3.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +321 -61
- package/dist/index.js +1391 -185
- package/package.json +8 -8
- package/src/index.ts +15 -7
- package/src/order/index.ts +3 -0
- package/src/order/order-builder.ts +23 -7
- package/src/order/order-manager.ts +3 -1
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +9 -3
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +5 -4
- package/src/outbox/index.ts +2 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +331 -27
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +17 -3
- package/src/outbox/use-order-outbox.ts +20 -6
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +99 -11
- package/src/pos-order/finalize.ts +145 -7
- package/src/pos-order/index.ts +2 -2
- package/src/pos-order/needs-attention.ts +9 -4
- package/src/pos-order/open.ts +100 -37
- package/src/pos-order/schema.ts +35 -5
- package/src/pos-order/types.ts +31 -5
- package/src/product/search-products.ts +5 -2
- package/src/receipt/build-receipt-data.ts +5 -3
- package/src/receipt/types.ts +1 -0
- package/src/register/closure-document.ts +7 -0
- package/src/register/index.ts +5 -3
- package/src/register/movement-input.ts +1 -1
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-document.ts +26 -1
- package/src/register/session-store.ts +41 -12
- package/src/register/use-register-session.ts +46 -2
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +97 -40
- package/src/store-settings/map-store-settings.ts +7 -2
- package/src/store-settings/use-store-settings.ts +22 -7
- package/src/tax/exact.ts +74 -11
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +26 -4
- package/src/tax/types.ts +6 -0
package/src/sale/use-sale.ts
CHANGED
|
@@ -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:
|
|
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 [
|
|
53
|
-
|
|
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 [
|
|
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 =
|
|
127
|
+
const subscription = rendered.order$.subscribe(setOrder);
|
|
95
128
|
return () => subscription.unsubscribe();
|
|
96
|
-
}, [
|
|
97
|
-
// New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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())
|
|
201
|
-
remove(lineId: string) { if (!locked())
|
|
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())
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* `<TaxProvider>`'s props from the same settings, so its tax inclusivity and rounding match the store's. `custom`
|
|
12
|
+
* rounding passes none, the default (#287). An explicit `rounding` prop after the spread still wins, since JSX
|
|
13
|
+
* applies props in order; so `<TaxProvider {...taxProviderProps(s)} rounding={undefined}>` wipes the store's rounding.
|
|
14
|
+
*/
|
|
11
15
|
export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
|
|
12
|
-
|
|
16
|
+
const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
|
|
17
|
+
return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }) };
|
|
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
|
|
|
@@ -13,10 +13,22 @@ export type StoreSettingsState =
|
|
|
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
|
+
// The store's taxRounding (#324): the context's capabilities, else one read of the connector's. A failure or no
|
|
17
|
+
// value leaves the field out, the default rounding; so does a connector without `capabilities`, with no read.
|
|
18
|
+
function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): Promise<TaxRounding | undefined> {
|
|
19
|
+
if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
|
|
20
|
+
if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(undefined);
|
|
21
|
+
const read = connector.capabilities;
|
|
22
|
+
return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => undefined);
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Resolves the store settings on mount, and again when `connector` or `context` changes by
|
|
18
27
|
* identity (a store switch), so memoise both; the last request wins. `loadChoice` and
|
|
19
28
|
* `saveChoice` are read when a request starts, so inline functions don't trigger a re-resolve.
|
|
29
|
+
* The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
|
|
30
|
+
* The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
|
|
31
|
+
* rounding already known: no later change of `settings` holds a sale.
|
|
20
32
|
*
|
|
21
33
|
* ```tsx
|
|
22
34
|
* const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
|
|
@@ -36,18 +48,21 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
|
|
|
36
48
|
|
|
37
49
|
// `from`, when given, is the request that created the `choose`/`retry` calling this: a call
|
|
38
50
|
// 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
|
-
|
|
51
|
+
// pick to the new store. `known`, when given, is the rounding that request already read, so
|
|
52
|
+
// a pick doesn't read it again.
|
|
53
|
+
const resolve = useCallback((choice?: StoreSettingsChoice, from?: number, known?: Promise<TaxRounding | undefined>) => {
|
|
41
54
|
if (from !== undefined && from !== requestRef.current) return;
|
|
42
55
|
const request = ++requestRef.current;
|
|
43
56
|
const current = () => request === requestRef.current;
|
|
44
57
|
setState(LOADING);
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
const rounding = known ?? readTaxRounding(optionsRef.current);
|
|
59
|
+
Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
|
|
60
|
+
([result, taxRounding]) => {
|
|
47
61
|
if (!current()) return;
|
|
48
|
-
if (result.status === 'ready')
|
|
62
|
+
if (result.status === 'ready')
|
|
63
|
+
setState({ state: 'ready', settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
|
|
49
64
|
else if (result.status === 'choose')
|
|
50
|
-
setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
|
|
65
|
+
setState({ state: 'choose', choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request, rounding) });
|
|
51
66
|
else setState({ state: 'unsupported' });
|
|
52
67
|
},
|
|
53
68
|
// A retry keeps the choice that failed: a new pick is saved only once it resolves.
|
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
|
-
/**
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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';
|
package/src/tax/tax-provider.tsx
CHANGED
|
@@ -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
|
|
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
|
}
|