@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.2.1",
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.2.1",
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.2.1",
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.2.1",
42
- "@tallyui/connector-woocommerce": "3.2.1",
43
- "@tallyui/database": "3.2.1"
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
- const { subtotalMinor } = sumLines(lines, taxContext.rounding);
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: toDisplayMode(li, d.amountMinor, taxInclusive),
214
+ discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: displayAmount(li, d.amountMinor),
202
215
  }));
203
- const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
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 displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
214
- - charges.reduce((sum, charge) => sum + charge.amountMinor, 0);
215
- const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
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: fees.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
248
- ...(shipping.length ? { shipping: shipping.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
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: Number(line.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 = input.taxStatus === 'none' ? [{ ...(code !== undefined ? { code } : {}), ratePpm: 0 }]
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,
@@ -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 in the display mode; the last converted line also carries the rounding residue
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 taxed base
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: number;
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
+ }
@@ -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
- // A discounted order the server can take only at 1 goes as before (at most 3): the refusal rejects it.
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') {
@@ -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 {
@@ -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. The builder's, and pos_orders v5's migration's for a row with no `sentVersion`.
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 === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
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: { ...order.display!, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
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
  })),