@tallyui/pos 3.2.1 → 3.4.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 +42 -8
- package/dist/index.js +325 -53
- package/package.json +6 -6
- package/src/index.ts +1 -1
- package/src/order/order-builder.ts +62 -20
- package/src/order/order-drafts.ts +1 -0
- package/src/order/types.ts +11 -3
- package/src/order/woocommerce-tax.ts +68 -0
- package/src/outbox/index.ts +1 -1
- package/src/outbox/order-outbox.ts +5 -3
- package/src/outbox/types.ts +8 -1
- package/src/pos-order/command.ts +21 -6
- package/src/pos-order/finalize.ts +37 -6
- package/src/pos-order/schema.ts +41 -5
- package/src/pos-order/types.ts +9 -4
- package/src/receipt/build-receipt-data.ts +3 -1
- package/src/register/closure-document.ts +1 -1
- package/src/register/session-store.ts +5 -3
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +3 -0
- package/src/store-settings/map-store-settings.ts +5 -1
- package/src/tax/exact.ts +3 -1
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +9 -4
- package/src/tax/types.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,20 +27,20 @@
|
|
|
27
27
|
"directory": "packages/pos"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "^3.
|
|
30
|
+
"@tallyui/core": "^3.4.0",
|
|
31
31
|
"react": ">=18",
|
|
32
32
|
"rxdb": "~17.5.0",
|
|
33
33
|
"rxjs": ">=7"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@tallyui/core": "3.
|
|
36
|
+
"@tallyui/core": "3.4.0",
|
|
37
37
|
"@types/react": ">=18",
|
|
38
38
|
"react": "^19.2.4",
|
|
39
39
|
"rxdb": "17.5.0",
|
|
40
40
|
"rxjs": "7.8.2",
|
|
41
|
-
"@tallyui/connector-medusa": "3.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.
|
|
43
|
-
"@tallyui/database": "3.
|
|
41
|
+
"@tallyui/connector-medusa": "3.4.0",
|
|
42
|
+
"@tallyui/connector-woocommerce": "3.4.0",
|
|
43
|
+
"@tallyui/database": "3.4.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsup",
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export { createRegisterOutbox, useRegisterOutbox } from './outbox';
|
|
|
68
68
|
export type { RegisterOutboxOptions, RegisterOutbox, UseRegisterOutboxOptions, UseRegisterOutboxResult } from './outbox';
|
|
69
69
|
export { createBackendNotFound } from './outbox/backend-not-found';
|
|
70
70
|
export type { BackendNotFound } from './outbox/backend-not-found';
|
|
71
|
-
export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
|
|
71
|
+
export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OrderTransportContext, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
|
|
72
72
|
export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender';
|
|
73
73
|
export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender';
|
|
74
74
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BehaviorSubject, type Observable } from 'rxjs';
|
|
2
|
-
import { resolvePrice, type ProductTraits, type TaxRounding } from '@tallyui/core';
|
|
2
|
+
import { resolvePrice, woocommerceTax, type ProductTraits, type TaxRounding } from '@tallyui/core';
|
|
3
3
|
import type { TaxContext } from '../tax/types';
|
|
4
4
|
import { taxMicros, roundMicrosToMinor, roundedTaxByRate } from '../tax/exact';
|
|
5
5
|
import { taxLogger } from '../tax/tax-provider';
|
|
6
6
|
import { allocateOrderDiscount } from './allocate-order-discount';
|
|
7
|
+
import { woocommerceLine, woocommerceTotals } from './woocommerce-tax';
|
|
7
8
|
import type {
|
|
8
9
|
Order,
|
|
9
10
|
LineItem,
|
|
@@ -96,6 +97,14 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
96
97
|
const { taxContext } = options;
|
|
97
98
|
const currency = options.currency.toUpperCase();
|
|
98
99
|
const orderId = options.id ?? uid();
|
|
100
|
+
const woo = taxContext.rounding?.granularity === 'woocommerce' && taxContext.getTaxRates ? taxContext.rounding : undefined;
|
|
101
|
+
const wooLine = (amount: number, taxClass: string | undefined, inclusive: boolean, untaxed = false, shipping = false) =>
|
|
102
|
+
woocommerceLine(amount, untaxed ? [] : taxContext.getTaxRates!(taxClass, { shipping }), inclusive, currency, woo!.roundAtSubtotal, taxContext.pricesIncludeTax);
|
|
103
|
+
const displayAmount = (line: LineItem, amount: number) => {
|
|
104
|
+
if (!woo || line.taxInclusive === taxContext.pricesIncludeTax) return toDisplayMode(line, amount, taxContext.pricesIncludeTax);
|
|
105
|
+
const item = wooLine(amount, line.taxClass, line.taxInclusive, line.taxStatus === 'none');
|
|
106
|
+
return taxContext.pricesIncludeTax ? item.totalMinor : item.netMinor;
|
|
107
|
+
};
|
|
99
108
|
|
|
100
109
|
let lineItems: LineItem[] = [];
|
|
101
110
|
let fees: FeeLine[] = [];
|
|
@@ -124,6 +133,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
124
133
|
const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
125
134
|
const discountMinor = lineDiscountMinor + orderDiscountMinor;
|
|
126
135
|
const netMinor = grossMinor - discountMinor;
|
|
136
|
+
if (woo) return { ...line, discounts: recalcedDiscounts, discountMinor, orderDiscountMinor,
|
|
137
|
+
...wooLine(netMinor, line.taxClass, line.taxInclusive, line.taxStatus === 'none') };
|
|
127
138
|
const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
128
139
|
// Tax in the line's own mode, from its own unit price × quantity: never re-tax a rounded base.
|
|
129
140
|
const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
|
|
@@ -166,7 +177,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
166
177
|
// parity). A fixed discount is computed on what remains, as before. Every discount is then capped at what
|
|
167
178
|
// remains, which decreases as each is applied, and the total is allocated to the lines, which are then
|
|
168
179
|
// taxed on what is left.
|
|
169
|
-
const lineAmounts = lineItems.map((li) => li.netMinor);
|
|
180
|
+
const lineAmounts = lineItems.map((li) => woo ? li.unitPriceMinor * li.quantity - li.discountMinor : li.netMinor);
|
|
170
181
|
const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
|
|
171
182
|
let remaining = base;
|
|
172
183
|
const recalcedOrderDiscounts = orderDiscounts.map((d) => {
|
|
@@ -177,11 +188,13 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
177
188
|
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
178
189
|
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
179
190
|
|
|
180
|
-
|
|
191
|
+
if (woo) shipping = shipping.map((charge) => ({ ...charge, ...recalculateCharge(charge.id, charge, true) }));
|
|
192
|
+
const wooTotals = woo ? woocommerceTotals(lines, fees, shipping, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : undefined;
|
|
193
|
+
const { subtotalMinor } = wooTotals ?? sumLines(lines, taxContext.rounding);
|
|
181
194
|
const charges = [...fees, ...shipping];
|
|
182
195
|
const taxedLines = [...lines, ...charges.map((charge) => ({ ...charge, taxInclusive: taxContext.pricesIncludeTax,
|
|
183
196
|
taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString() }))];
|
|
184
|
-
const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
|
|
197
|
+
const { taxMinor, totalMinor } = wooTotals ?? sumLines(taxedLines, taxContext.rounding);
|
|
185
198
|
if (taxContext.rounding?.granularity === 'per_rate_group_items' && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
|
|
186
199
|
perRateGroupWarned.add(taxContext);
|
|
187
200
|
taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
|
|
@@ -198,11 +211,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
198
211
|
// with no rows: its capped "discounts" are the negative gross cancelled, not money off.
|
|
199
212
|
const returned = li.unitPriceMinor < 0;
|
|
200
213
|
const discounts = returned ? [] : li.discounts.map((d) => ({
|
|
201
|
-
discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor:
|
|
214
|
+
discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: displayAmount(li, d.amountMinor),
|
|
202
215
|
}));
|
|
203
|
-
const shareMinor =
|
|
204
|
-
const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
|
|
205
|
-
const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
|
|
216
|
+
const shareMinor = displayAmount(li, li.orderDiscountMinor);
|
|
217
|
+
const remainingMinor = woo ? taxInclusive ? li.totalMinor! : li.netMinor : toDisplayMode(li, li.netMinor, taxInclusive);
|
|
218
|
+
const amountMinor = !woo && li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
|
|
206
219
|
: discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
|
|
207
220
|
return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
|
|
208
221
|
});
|
|
@@ -210,9 +223,20 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
210
223
|
const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
|
|
211
224
|
const displayDiscount = displayLines.reduce(
|
|
212
225
|
(sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
const
|
|
226
|
+
const chargeRows = (items: ChargeLine[]) => items.map(({ id, name, amountMinor, netMinor, totalMinor }) =>
|
|
227
|
+
({ id, name, amountMinor: woo ? taxInclusive ? totalMinor! : netMinor : amountMinor }));
|
|
228
|
+
const displayFees = chargeRows(fees), displayShipping = chargeRows(shipping);
|
|
229
|
+
let displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
|
|
230
|
+
- [...displayFees, ...displayShipping].reduce((sum, charge) => sum + charge.amountMinor, 0);
|
|
231
|
+
let residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
|
|
232
|
+
if (woo) {
|
|
233
|
+
// ADR-076: the last shipping row, else fee, else product takes either sign of residue.
|
|
234
|
+
const charge = displayShipping.at(-1) ?? displayFees.at(-1);
|
|
235
|
+
const last = charge ?? displayLines.at(-1);
|
|
236
|
+
if (last) last.amountMinor += residue;
|
|
237
|
+
if (charge) displaySubtotal -= residue;
|
|
238
|
+
residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
|
|
239
|
+
}
|
|
216
240
|
const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
|
|
217
241
|
const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
|
|
218
242
|
...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
|
|
@@ -235,7 +259,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
235
259
|
if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
|
|
236
260
|
for (const { line, shareMinor, returned } of figures) {
|
|
237
261
|
if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
|
|
238
|
-
if (returned) continue;
|
|
262
|
+
if (returned || woo) continue;
|
|
239
263
|
const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
|
|
240
264
|
if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
|
|
241
265
|
throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
|
|
@@ -244,8 +268,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
244
268
|
const display = {
|
|
245
269
|
taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
|
|
246
270
|
lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
|
|
247
|
-
...(fees.length ? { fees:
|
|
248
|
-
...(shipping.length ? { shipping:
|
|
271
|
+
...(fees.length ? { fees: displayFees } : {}),
|
|
272
|
+
...(shipping.length ? { shipping: displayShipping } : {}),
|
|
249
273
|
};
|
|
250
274
|
const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
|
|
251
275
|
const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
|
|
@@ -281,11 +305,25 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
281
305
|
subject.next(buildOrder());
|
|
282
306
|
}
|
|
283
307
|
|
|
284
|
-
function recalculateCharge(id: string, input: ChargeInput): ChargeLine {
|
|
308
|
+
function recalculateCharge(id: string, input: ChargeInput, shipping = false): ChargeLine {
|
|
285
309
|
if (!Number.isInteger(input.amountMinor)) throw new RangeError('Amount must be integer minor units');
|
|
286
310
|
if (input.amountMinor < 0) throw new RangeError('Amount must be >= 0');
|
|
287
311
|
if (!input.name.trim()) throw new RangeError('Name must not be empty');
|
|
288
312
|
const taxStatus = input.taxStatus ?? 'taxable';
|
|
313
|
+
if (woo) {
|
|
314
|
+
const taxClass = shipping
|
|
315
|
+
? taxContext.shippingTaxClass === undefined || taxContext.shippingTaxClass === 'inherit'
|
|
316
|
+
? woocommerceTax.resolveInheritedShippingTaxClass(lineItems.map((line) => ({
|
|
317
|
+
tax_class: line.taxClass, taxable: line.taxStatus !== 'none',
|
|
318
|
+
})), taxContext.taxClassSlugs ?? [])
|
|
319
|
+
: taxContext.shippingTaxClass
|
|
320
|
+
: input.taxClass;
|
|
321
|
+
const untaxed = shipping ? taxClass === woocommerceTax.NO_SHIPPING_TAX : taxStatus === 'none';
|
|
322
|
+
const line = wooLine(input.amountMinor, taxClass === woocommerceTax.NO_SHIPPING_TAX ? undefined : taxClass,
|
|
323
|
+
taxContext.pricesIncludeTax, untaxed, shipping);
|
|
324
|
+
return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
|
|
325
|
+
...line };
|
|
326
|
+
}
|
|
289
327
|
const code = taxContext.getTaxRateCode?.(input.taxClass);
|
|
290
328
|
const line = recalculateLine({
|
|
291
329
|
id, productId: id, name: input.name, sku: '', unitPriceMinor: input.amountMinor, quantity: 1,
|
|
@@ -295,7 +333,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
295
333
|
taxInclusive: taxContext.pricesIncludeTax,
|
|
296
334
|
});
|
|
297
335
|
return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
|
|
298
|
-
taxLines: line.taxLines, netMinor: line.netMinor, taxMicros:
|
|
336
|
+
taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: line.taxMicros };
|
|
299
337
|
}
|
|
300
338
|
|
|
301
339
|
function addLine(input: AddLineInput): string {
|
|
@@ -305,13 +343,15 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
305
343
|
if (!Number.isInteger(unitPrice.amount)) throw new RangeError('Price must be integer minor units');
|
|
306
344
|
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
|
|
307
345
|
// A rate from the tax class carries the backend's name for it when one is mapped (#287, #288).
|
|
308
|
-
const code = input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
|
|
309
|
-
const taxRates =
|
|
346
|
+
const code = woo || input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
|
|
347
|
+
const taxRates = woo ? wooLine(unitPrice.amount * quantity, input.taxClass, unitPrice.taxInclusive ?? taxContext.pricesIncludeTax, input.taxStatus === 'none').taxLines
|
|
348
|
+
: input.taxStatus === 'none' ? [{ ...(code !== undefined ? { code } : {}), ratePpm: 0 }]
|
|
310
349
|
: input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
311
350
|
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
312
351
|
|
|
313
352
|
const existing = lineItems.find(
|
|
314
353
|
(li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
|
|
354
|
+
&& (!woo || li.taxClass === input.taxClass && (li.taxStatus === 'none') === (input.taxStatus === 'none'))
|
|
315
355
|
&& li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
|
|
316
356
|
&& li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
|
|
317
357
|
);
|
|
@@ -327,6 +367,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
327
367
|
const lineId = uid();
|
|
328
368
|
const line: LineItem = {
|
|
329
369
|
id: lineId,
|
|
370
|
+
...((input.custom ? input.taxClass !== undefined : woo) ? { taxClass: input.taxClass } : {}),
|
|
330
371
|
productId,
|
|
331
372
|
...(input.custom ? { custom: true as const } : {}),
|
|
332
373
|
...(input.taxStatus === 'none' ? { taxStatus: 'none' as const } : {}),
|
|
@@ -377,14 +418,14 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
377
418
|
fees = fees.filter((entry) => entry.id !== id); emit();
|
|
378
419
|
},
|
|
379
420
|
addShipping(input) {
|
|
380
|
-
const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
|
|
421
|
+
const charge = { ...recalculateCharge(uid(), input, true), methodId: input.methodId };
|
|
381
422
|
shipping = [...shipping, charge]; emit(); return charge.id;
|
|
382
423
|
},
|
|
383
424
|
updateShipping(id, patch) {
|
|
384
425
|
const charge = shipping.find((entry) => entry.id === id);
|
|
385
426
|
if (!charge) throw new Error(`Unknown shipping ${id}`);
|
|
386
427
|
const input = { ...charge, ...patch };
|
|
387
|
-
const updated = { ...recalculateCharge(id, input), methodId: input.methodId };
|
|
428
|
+
const updated = { ...recalculateCharge(id, input, true), methodId: input.methodId };
|
|
388
429
|
shipping = shipping.map((entry) => entry.id === id ? updated : entry); emit();
|
|
389
430
|
},
|
|
390
431
|
removeShipping(id) {
|
|
@@ -410,6 +451,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
410
451
|
unitPrice,
|
|
411
452
|
quantity: opts?.quantity,
|
|
412
453
|
taxClass: traits.getTaxClass?.(doc, opts?.variantId),
|
|
454
|
+
...(traits.getTaxStatus?.(doc, opts?.variantId) === 'none' ? { taxStatus: 'none' as const } : {}),
|
|
413
455
|
});
|
|
414
456
|
},
|
|
415
457
|
|
|
@@ -67,6 +67,7 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
|
|
|
67
67
|
productId: line.productId,
|
|
68
68
|
custom: line.custom,
|
|
69
69
|
taxStatus: line.taxStatus,
|
|
70
|
+
...(taxContext.rounding?.granularity === 'woocommerce' ? { taxClass: line.taxClass } : {}),
|
|
70
71
|
variantId: line.variantId,
|
|
71
72
|
name: line.name,
|
|
72
73
|
sku: line.sku,
|
package/src/order/types.ts
CHANGED
|
@@ -49,12 +49,16 @@ export interface DisplayTotals {
|
|
|
49
49
|
/** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
|
|
50
50
|
export interface DisplayLine {
|
|
51
51
|
lineId: string;
|
|
52
|
-
amountMinor: number; // quantity × unit price
|
|
52
|
+
amountMinor: number; // quantity × unit price, or WooCommerce's line figure; a residue recipient may adjust it
|
|
53
53
|
discounts: { discountId: string; label?: string; amountMinor: number }[]; // this line's own discounts, each converted on its own
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface LineItem {
|
|
57
57
|
id: string;
|
|
58
|
+
taxClass?: string;
|
|
59
|
+
/** WooCommerce's stored net at rounding precision, in integer micro-minor units, and its rounded gross. */
|
|
60
|
+
netMicros?: string;
|
|
61
|
+
totalMinor?: number;
|
|
58
62
|
custom?: true;
|
|
59
63
|
taxStatus?: 'none';
|
|
60
64
|
productId: string;
|
|
@@ -68,7 +72,7 @@ export interface LineItem {
|
|
|
68
72
|
discounts: AppliedDiscount[];
|
|
69
73
|
discountMinor: number; // line discounts + orderDiscountMinor, in the line's own mode
|
|
70
74
|
orderDiscountMinor: number; // this line's allocated share of the order discounts (ADR-062)
|
|
71
|
-
netMinor: number; // unitPriceMinor × quantity − discountMinor; the
|
|
75
|
+
netMinor: number; // unitPriceMinor × quantity − discountMinor; under WooCommerce, the rounded exclusive net
|
|
72
76
|
taxMicros: string; // Σ taxLines[].taxMicros, decimal string of a bigint
|
|
73
77
|
taxInclusive: boolean; // the price's own tax mode; the order's when the price has none
|
|
74
78
|
/** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
|
|
@@ -77,13 +81,15 @@ export interface LineItem {
|
|
|
77
81
|
|
|
78
82
|
export interface ChargeLine {
|
|
79
83
|
id: string;
|
|
84
|
+
netMicros?: string;
|
|
85
|
+
totalMinor?: number;
|
|
80
86
|
name: string;
|
|
81
87
|
amountMinor: number; // integer >= 0, in the order's tax mode (ADR-075)
|
|
82
88
|
taxClass?: string;
|
|
83
89
|
taxStatus: 'taxable' | 'none';
|
|
84
90
|
taxLines: LineItem['taxLines'];
|
|
85
91
|
netMinor: number;
|
|
86
|
-
taxMicros:
|
|
92
|
+
taxMicros: string;
|
|
87
93
|
}
|
|
88
94
|
export interface FeeLine extends ChargeLine {}
|
|
89
95
|
export interface ShippingLine extends ChargeLine { methodId?: string }
|
|
@@ -91,6 +97,8 @@ export interface ChargeInput { name: string; amountMinor: number; taxClass?: str
|
|
|
91
97
|
|
|
92
98
|
export interface LineTaxLine {
|
|
93
99
|
code?: string;
|
|
100
|
+
rateId?: number;
|
|
101
|
+
compound?: boolean;
|
|
94
102
|
ratePpm: number;
|
|
95
103
|
taxMicros: string; // exact, decimal string of a bigint
|
|
96
104
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { minorUnitDigits, woocommerceTax } from '@tallyui/core';
|
|
2
|
+
import type { WooRate } from '../tax/types';
|
|
3
|
+
import type { RateTaxLine } from '../tax/exact';
|
|
4
|
+
import type { ChargeLine, LineItem, LineTaxLine } from './types';
|
|
5
|
+
|
|
6
|
+
/** ADR-076: all WooCommerce float arithmetic stays here; only integer money leaves this module. */
|
|
7
|
+
export function woocommerceLine(amountMinor: number, rates: readonly WooRate[], inclusive: boolean, currency: string,
|
|
8
|
+
roundAtSubtotal: boolean, pricesIncludeTax = inclusive) {
|
|
9
|
+
const dp = minorUnitDigits(currency), factor = 10 ** dp;
|
|
10
|
+
const precision = woocommerceTax.getRoundingPrecision(dp);
|
|
11
|
+
const input = { amount: amountMinor / factor, rates: rates.map((rate) => ({ ...rate, order: rate.priority })),
|
|
12
|
+
amountIncludesTax: inclusive, dp, perRatePrecision: precision };
|
|
13
|
+
let result = woocommerceTax.calculateTaxes(input);
|
|
14
|
+
const net = woocommerceTax.roundHalfUp(input.amount - (inclusive ? result.total : 0), precision);
|
|
15
|
+
// WC_Order_Item_Product::calculate_taxes re-derives from the stored net (#2333 B).
|
|
16
|
+
if (inclusive && rates.length) result = woocommerceTax.calculateTaxes({ ...input, amount: net, amountIncludesTax: false });
|
|
17
|
+
const taxLines: LineTaxLine[] = result.taxes.map((tax) => {
|
|
18
|
+
const rate = rates.find((rate) => rate.id === tax.id)!;
|
|
19
|
+
return { code: rate.code, rateId: rate.id, compound: rate.compound, ratePpm: Math.round(Number(rate.rate) * 10000),
|
|
20
|
+
taxMicros: String(Math.round(tax.total * factor * 1e6)) };
|
|
21
|
+
});
|
|
22
|
+
const tax = result.taxes.reduce((sum, rate) => sum + (roundAtSubtotal ? rate.total
|
|
23
|
+
: woocommerceTax.roundTaxTotal(rate.total, dp, pricesIncludeTax)), 0);
|
|
24
|
+
return { netMinor: Math.round(woocommerceTax.roundHalfUp(net, dp) * factor), netMicros: String(Math.round(net * factor * 1e6)),
|
|
25
|
+
totalMinor: Math.round(woocommerceTax.roundHalfUp(net + tax, dp) * factor), taxLines,
|
|
26
|
+
taxMicros: taxLines.reduce((sum, rate) => sum + BigInt(rate.taxMicros), 0n).toString() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** calculateOrderTotals: products round conditionally, fees stay raw, shipping always rounds per item. */
|
|
30
|
+
export function woocommerceTotals(lines: readonly LineItem[], fees: readonly ChargeLine[], shipping: readonly ChargeLine[],
|
|
31
|
+
currency: string, roundAtSubtotal: boolean, inclusive: boolean) {
|
|
32
|
+
const dp = minorUnitDigits(currency), factor = 10 ** dp;
|
|
33
|
+
const net = (line: LineItem | ChargeLine) => Number(line.netMicros) / (factor * 1e6);
|
|
34
|
+
const productNet = lines.reduce((sum, line) => sum + (roundAtSubtotal ? net(line) : woocommerceTax.roundHalfUp(net(line), dp)), 0);
|
|
35
|
+
const amount = productNet + fees.reduce((sum, line) => sum + net(line), 0)
|
|
36
|
+
+ shipping.reduce((sum, line) => sum + woocommerceTax.roundHalfUp(net(line), dp), 0);
|
|
37
|
+
const taxes = (items: readonly (LineItem | ChargeLine)[]) => woocommerceTax.roundHalfUp(items.reduce((sum, line) =>
|
|
38
|
+
line.taxLines.reduce((sum, tax) => sum + (roundAtSubtotal ? Number(tax.taxMicros) / (factor * 1e6)
|
|
39
|
+
: woocommerceTax.roundTaxTotal(Number(tax.taxMicros) / (factor * 1e6), dp, inclusive)), sum), 0), woocommerceTax.getRoundingPrecision(dp));
|
|
40
|
+
const tax = woocommerceTax.roundHalfUp(taxes([...lines, ...fees]) + taxes(shipping), 6);
|
|
41
|
+
return { subtotalMinor: Math.round(woocommerceTax.roundHalfUp(productNet, dp) * factor),
|
|
42
|
+
taxMinor: Math.round((roundAtSubtotal ? woocommerceTax.roundHalfUp(tax, dp) : woocommerceTax.roundTaxTotal(tax, dp, inclusive)) * factor),
|
|
43
|
+
totalMinor: Math.round(woocommerceTax.roundHalfUp(woocommerceTax.roundHalfUp(amount + tax, 6), dp) * factor) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Order-math's rate buckets, including zero rows; the last row takes the bounded order tax residue. */
|
|
47
|
+
export function woocommerceTaxByRate(lines: readonly { netMinor: number; taxInclusive: boolean; taxLines: readonly LineTaxLine[] }[],
|
|
48
|
+
roundAtSubtotal: boolean, taxLabels?: Record<number, string>, orderTaxMinor?: number): RateTaxLine[] {
|
|
49
|
+
const groups = new Map<string, RateTaxLine & { amount: number; inclusive: boolean }>();
|
|
50
|
+
for (const line of lines) for (const tax of line.taxLines) {
|
|
51
|
+
const key = JSON.stringify([tax.rateId, tax.code, tax.ratePpm]);
|
|
52
|
+
const row = groups.get(key) ?? { code: tax.code, ratePpm: tax.ratePpm, label: taxLabels?.[tax.ratePpm] ?? `Tax ${tax.ratePpm / 10000}%`,
|
|
53
|
+
netMinor: 0, amountMinor: 0, amount: 0, inclusive: line.taxInclusive };
|
|
54
|
+
const amount = Number(tax.taxMicros) / 1e6;
|
|
55
|
+
row.amount += roundAtSubtotal ? amount : woocommerceTax.roundTaxTotal(amount, 0, line.taxInclusive);
|
|
56
|
+
row.netMinor += line.netMinor;
|
|
57
|
+
groups.set(key, row);
|
|
58
|
+
}
|
|
59
|
+
const rows = [...groups.values()].map(({ amount, inclusive, ...row }) => ({ ...row,
|
|
60
|
+
amountMinor: woocommerceTax.roundTaxTotal(woocommerceTax.roundHalfUp(amount, 6), 0, inclusive) }));
|
|
61
|
+
if (orderTaxMinor !== undefined && rows.length) {
|
|
62
|
+
const residue = orderTaxMinor - rows.reduce((sum, row) => sum + row.amountMinor, 0);
|
|
63
|
+
const bound = Math.floor(rows.length / 2) + 1;
|
|
64
|
+
if (Math.abs(residue) > bound) throw new Error(`woocommerce tax by rate: residue ${residue} exceeds its bound ${bound} (ADR-076)`);
|
|
65
|
+
rows[rows.length - 1].amountMinor += residue;
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
package/src/outbox/index.ts
CHANGED
|
@@ -6,6 +6,6 @@ export { createRegisterOutbox } from './register-outbox';
|
|
|
6
6
|
export type { RegisterOutboxOptions, RegisterOutbox } from './register-outbox';
|
|
7
7
|
export { useRegisterOutbox } from './use-register-outbox';
|
|
8
8
|
export type { UseRegisterOutboxOptions, UseRegisterOutboxResult } from './use-register-outbox';
|
|
9
|
-
export type { TransportOutcome, CommandTransport, OutboxState } from './types';
|
|
9
|
+
export type { TransportOutcome, CommandTransport, OrderTransportContext, OutboxState } from './types';
|
|
10
10
|
export { useOrderOutbox, outboxLogger } from './use-order-outbox';
|
|
11
11
|
export type { UseOrderOutboxOptions, UseOrderOutboxResult } from './use-order-outbox';
|
|
@@ -268,6 +268,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
268
268
|
const firstSend = orders.some((order) => order.sentVersion === undefined);
|
|
269
269
|
const serverMax = (firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.())
|
|
270
270
|
.catch((cause) => { outboxLogger.warn('Failed to read capabilities', { cause }); }))) || 3;
|
|
271
|
+
const localOrders = new Map<string, PosOrder>();
|
|
271
272
|
const batch = await Promise.all(orders.map(async (order) => {
|
|
272
273
|
const frozen = freezeSentForm(order);
|
|
273
274
|
const attempt = (attempts.get(order.commandId) ?? 0) + 1;
|
|
@@ -276,9 +277,9 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
276
277
|
try {
|
|
277
278
|
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: order.sentVersion ?? serverMax });
|
|
278
279
|
} catch (cause) {
|
|
279
|
-
//
|
|
280
|
+
// Content that cannot downgrade goes at its required version: the server's refusal rejects it.
|
|
280
281
|
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
281
|
-
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: 3 });
|
|
282
|
+
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: cause.needed === 5 ? 5 : 3 });
|
|
282
283
|
}
|
|
283
284
|
// Bounds apply when frozen: persist older tills' sent forms before sending (Front desk, 2026-09-29). A first
|
|
284
285
|
// send's version is stored, awaited, before the batch request leaves, so a crash in between keeps it.
|
|
@@ -286,9 +287,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
286
287
|
await (await collection.findOne(order.id).exec())?.incrementalModify((data) =>
|
|
287
288
|
({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope.version }));
|
|
288
289
|
}
|
|
290
|
+
localOrders.set(envelope.id, frozen);
|
|
289
291
|
return envelope;
|
|
290
292
|
}));
|
|
291
|
-
let outcome = await transport.send(batch);
|
|
293
|
+
let outcome = await transport.send(batch, { local: { orders: localOrders } });
|
|
292
294
|
// A 404 answers the whole batch (the route is missing); any other answer resets the count. Offline changes nothing.
|
|
293
295
|
backendNotFound.record(outcome, now());
|
|
294
296
|
if (outcome.kind !== 'retry' || outcome.reason !== 'network') {
|
package/src/outbox/types.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { AnyCommandEnvelope, CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
|
|
2
|
+
import type { PosOrder } from '../pos-order';
|
|
3
|
+
|
|
4
|
+
/** Local-only context for a transport (ADR-076 amendment 1): never serialised, never part of a payload. */
|
|
5
|
+
export interface OrderTransportContext {
|
|
6
|
+
/** The stored (frozen) order of each envelope in the batch, keyed by the envelope's `id` (its commandId). */
|
|
7
|
+
local: { orders: ReadonlyMap<string, PosOrder> };
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export type TransportOutcome =
|
|
4
11
|
| { kind: 'results'; results: CommandResult[] }
|
|
@@ -9,7 +16,7 @@ export type TransportOutcome =
|
|
|
9
16
|
| { kind: 'retry'; reason: string; retryAfterMs?: number };
|
|
10
17
|
|
|
11
18
|
export interface CommandTransport<E extends AnyCommandEnvelope = CommandEnvelope<OrderCreatePayload>> {
|
|
12
|
-
send(batch: E[]): Promise<TransportOutcome>;
|
|
19
|
+
send(batch: E[], context?: OrderTransportContext): Promise<TransportOutcome>;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export interface OutboxState {
|
package/src/pos-order/command.ts
CHANGED
|
@@ -55,9 +55,10 @@ function netDiscountMinor(line: PosOrderLine, pricesIncludeTax: boolean): number
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The order.create version an order's stored content makes: 3 with ADR-065's figures, else 2 when its lines are
|
|
58
|
-
* discounted (ADR-062), else 1.
|
|
58
|
+
* discounted (ADR-062), else 1; fees, shipping or custom lines need 5. Also used by pos_orders v5's migration.
|
|
59
59
|
*/
|
|
60
|
-
export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines'>): 1 | 2 | 3 {
|
|
60
|
+
export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines' | 'fees' | 'shipping'>): 1 | 2 | 3 | 5 {
|
|
61
|
+
if (order.fees?.length || order.shipping?.length || order.lines.some((line) => line.custom)) return 5;
|
|
61
62
|
return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -73,11 +74,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
73
74
|
const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
74
75
|
const content = contentVersion(order);
|
|
75
76
|
const cap = options?.maxVersion === undefined ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
|
|
77
|
+
if (content === 5 && cap !== undefined && cap < 5) throw new UnsupportedOrderVersionError(5, cap);
|
|
76
78
|
if (grossMinor > 0 && cap !== undefined && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
|
|
77
79
|
// Version 4 only when the cap allows it (the server advertises 4, and no sentVersion holds the order lower).
|
|
78
80
|
const version = (content === 3 && cap !== undefined && cap >= 4 ? 4
|
|
79
81
|
: Math.min(content, cap ?? content)) as OrderCreateEnvelope['version'];
|
|
80
|
-
const lineDiscounts = order.lines.map((line) => version
|
|
82
|
+
const lineDiscounts = order.lines.map((line) => version >= 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
|
|
81
83
|
// The order's discount is the sum of its lines', on one basis, so the payload's two always agree.
|
|
82
84
|
const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
|
|
83
85
|
const email = order.customer?.email;
|
|
@@ -85,16 +87,26 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
85
87
|
// These are the pre-#222 customerId and sessionId checks, kept byte-exact so stored orders resend older tills' bytes; don't replace with sendable.
|
|
86
88
|
const customerId = typeof id === 'string' && id.length > 0 && id.length <= 64 ? id : undefined;
|
|
87
89
|
const sessionId = order.sessionId ?? order.lateSessionId;
|
|
90
|
+
const { fees: _fees, shipping: _shipping, ...storedDisplay } = { ...order.display! };
|
|
88
91
|
return {
|
|
89
92
|
id: order.commandId, type: 'order.create', version, createdAt: order.createdAt, deviceId, attempt,
|
|
90
93
|
payload: {
|
|
91
94
|
clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
|
|
92
95
|
lines: order.lines.map((line, i) => ({
|
|
93
|
-
clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
|
|
96
|
+
clientLineId: line.id, ...(line.custom ? { custom: { ...line.custom } } : { variantId: line.variantId ?? line.productId }), title: line.name,
|
|
94
97
|
quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
|
|
95
98
|
...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
|
|
96
99
|
...(lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}),
|
|
97
100
|
})),
|
|
101
|
+
...(version === 5 && order.fees?.length ? { fees: order.fees.map((fee) => ({
|
|
102
|
+
clientFeeId: fee.id, name: fee.name, amountMinor: fee.amountMinor, taxStatus: fee.taxStatus,
|
|
103
|
+
...(fee.taxClass !== undefined ? { taxClass: fee.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(fee.taxMicros)),
|
|
104
|
+
})) } : {}),
|
|
105
|
+
...(version === 5 && order.shipping?.length ? { shipping: order.shipping.map((charge) => ({
|
|
106
|
+
clientShippingId: charge.id, name: charge.name, ...(charge.methodId !== undefined ? { methodId: charge.methodId } : {}),
|
|
107
|
+
amountMinor: charge.amountMinor, taxStatus: charge.taxStatus,
|
|
108
|
+
...(charge.taxClass !== undefined ? { taxClass: charge.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(charge.taxMicros)),
|
|
109
|
+
})) } : {}),
|
|
98
110
|
payments: order.payments.map((payment) => ({
|
|
99
111
|
clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
|
|
100
112
|
...(payment.tenderedMinor !== undefined ? { tenderedMinor: payment.tenderedMinor } : {}),
|
|
@@ -105,9 +117,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
105
117
|
...(discountMinor > 0 ? { discountMinor } : {}),
|
|
106
118
|
taxMinor: order.taxMinor, totalMinor: order.totalMinor,
|
|
107
119
|
...(version >= 3 ? {
|
|
108
|
-
display: { ...
|
|
120
|
+
display: { ...storedDisplay, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
|
|
109
121
|
clientLineId: lineId, amountMinor, discounts,
|
|
110
|
-
}))
|
|
122
|
+
})),
|
|
123
|
+
...(version === 5 && order.display!.fees ? { fees: order.display!.fees.map(({ id, amountMinor }) => ({ clientFeeId: id, amountMinor })) } : {}),
|
|
124
|
+
...(version === 5 && order.display!.shipping ? { shipping: order.display!.shipping.map(({ id, amountMinor }) => ({ clientShippingId: id, amountMinor })) } : {}),
|
|
125
|
+
},
|
|
111
126
|
taxByRate: order.taxByRate!.map(({ ratePpm, code, netMinor, amountMinor, grossMinor }) => ({
|
|
112
127
|
ratePpm, ...(code !== undefined ? { code } : {}), netMinor, taxMinor: amountMinor, grossMinor,
|
|
113
128
|
})),
|