@tallyui/pos 3.2.0 → 3.3.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.0",
3
+ "version": "3.3.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.0",
30
+ "@tallyui/core": "^3.3.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.0",
36
+ "@tallyui/core": "3.3.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.0",
42
- "@tallyui/connector-woocommerce": "3.2.0",
43
- "@tallyui/database": "3.2.0"
41
+ "@tallyui/connector-medusa": "3.3.0",
42
+ "@tallyui/connector-woocommerce": "3.3.0",
43
+ "@tallyui/database": "3.3.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -37,6 +37,10 @@ export type {
37
37
  LineItem,
38
38
  LineTaxLine,
39
39
  AddLineInput,
40
+ ChargeLine,
41
+ ChargeInput,
42
+ FeeLine,
43
+ ShippingLine,
40
44
  Discount,
41
45
  AppliedDiscount,
42
46
  DisplayTotals,
@@ -64,7 +68,7 @@ export { createRegisterOutbox, useRegisterOutbox } from './outbox';
64
68
  export type { RegisterOutboxOptions, RegisterOutbox, UseRegisterOutboxOptions, UseRegisterOutboxResult } from './outbox';
65
69
  export { createBackendNotFound } from './outbox/backend-not-found';
66
70
  export type { BackendNotFound } from './outbox/backend-not-found';
67
- 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';
68
72
  export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender';
69
73
  export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender';
70
74
 
@@ -13,6 +13,10 @@ export type {
13
13
  LineItem,
14
14
  LineTaxLine,
15
15
  AddLineInput,
16
+ ChargeLine,
17
+ ChargeInput,
18
+ FeeLine,
19
+ ShippingLine,
16
20
  Discount,
17
21
  AppliedDiscount,
18
22
  DisplayTotals,
@@ -4,6 +4,7 @@ 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,
@@ -12,6 +13,7 @@ import type {
12
13
  Payment,
13
14
  CustomerSummary,
14
15
  AddLineInput,
16
+ ChargeInput, ChargeLine, FeeLine, ShippingLine,
15
17
  } from './types';
16
18
 
17
19
  /** Tax contexts already warned that per_rate_group_items fell back to per_order for inclusive lines (#287). */
@@ -27,7 +29,7 @@ function roundHalfAway(n: number): number {
27
29
  }
28
30
 
29
31
  /** The settlement totals of recalculated lines; the display figures derive from them (ADR-063). */
30
- function sumLines(lines: LineItem[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
32
+ function sumLines(lines: Pick<LineItem, 'netMinor' | 'taxMicros' | 'taxInclusive' | 'taxLines'>[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
31
33
  // per_line_items, per_rate_group_items (#287): total = Σ rounded nets + tax (order-level-tax-calculation-strategy.js:44-49, order.entity.js:88-89).
32
34
  const rounded = roundedTaxByRate(lines, rounding);
33
35
  if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
@@ -70,6 +72,12 @@ export interface OrderBuilder {
70
72
  order$: Observable<Order>;
71
73
  addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
72
74
  addLine(input: AddLineInput): string;
75
+ addFee(input: ChargeInput): string;
76
+ updateFee(id: string, patch: Partial<ChargeInput>): void;
77
+ removeFee(id: string): void;
78
+ addShipping(input: ChargeInput & { methodId?: string }): string;
79
+ updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): void;
80
+ removeShipping(id: string): void;
73
81
  updateQuantity(lineId: string, quantity: number): void;
74
82
  /** Sets a line's unit price (a till price edit), in minor units of the order's currency and the line's own tax mode; its discounts and tax are recomputed. */
75
83
  setUnitPrice(lineId: string, amountMinor: number): void;
@@ -89,8 +97,18 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
89
97
  const { taxContext } = options;
90
98
  const currency = options.currency.toUpperCase();
91
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
+ };
92
108
 
93
109
  let lineItems: LineItem[] = [];
110
+ let fees: FeeLine[] = [];
111
+ let shipping: ShippingLine[] = [];
94
112
  let orderDiscounts: AppliedDiscount[] = [];
95
113
  let payments: Payment[] = [];
96
114
  let customer: CustomerSummary | null = null;
@@ -115,6 +133,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
115
133
  const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
116
134
  const discountMinor = lineDiscountMinor + orderDiscountMinor;
117
135
  const netMinor = grossMinor - discountMinor;
136
+ if (woo) return { ...line, discounts: recalcedDiscounts, discountMinor, orderDiscountMinor,
137
+ ...wooLine(netMinor, line.taxClass, line.taxInclusive, line.taxStatus === 'none') };
118
138
  const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
119
139
  // Tax in the line's own mode, from its own unit price × quantity: never re-tax a rounded base.
120
140
  const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
@@ -157,7 +177,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
157
177
  // parity). A fixed discount is computed on what remains, as before. Every discount is then capped at what
158
178
  // remains, which decreases as each is applied, and the total is allocated to the lines, which are then
159
179
  // taxed on what is left.
160
- const lineAmounts = lineItems.map((li) => li.netMinor);
180
+ const lineAmounts = lineItems.map((li) => woo ? li.unitPriceMinor * li.quantity - li.discountMinor : li.netMinor);
161
181
  const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
162
182
  let remaining = base;
163
183
  const recalcedOrderDiscounts = orderDiscounts.map((d) => {
@@ -168,8 +188,13 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
168
188
  const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
169
189
  const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
170
190
 
171
- const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
172
- if (taxContext.rounding?.granularity === 'per_rate_group_items' && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
191
+ const wooTotals = woo ? woocommerceTotals(lines, fees, shipping, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : undefined;
192
+ const { subtotalMinor } = wooTotals ?? sumLines(lines, taxContext.rounding);
193
+ const charges = [...fees, ...shipping];
194
+ const taxedLines = [...lines, ...charges.map((charge) => ({ ...charge, taxInclusive: taxContext.pricesIncludeTax,
195
+ taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString() }))];
196
+ const { taxMinor, totalMinor } = wooTotals ?? sumLines(taxedLines, taxContext.rounding);
197
+ if (taxContext.rounding?.granularity === 'per_rate_group_items' && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
173
198
  perRateGroupWarned.add(taxContext);
174
199
  taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
175
200
  }
@@ -185,11 +210,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
185
210
  // with no rows: its capped "discounts" are the negative gross cancelled, not money off.
186
211
  const returned = li.unitPriceMinor < 0;
187
212
  const discounts = returned ? [] : li.discounts.map((d) => ({
188
- discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive),
213
+ discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: displayAmount(li, d.amountMinor),
189
214
  }));
190
- const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
191
- const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
192
- const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
215
+ const shareMinor = displayAmount(li, li.orderDiscountMinor);
216
+ const remainingMinor = woo ? taxInclusive ? li.totalMinor! : li.netMinor : toDisplayMode(li, li.netMinor, taxInclusive);
217
+ const amountMinor = !woo && li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
193
218
  : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
194
219
  return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
195
220
  });
@@ -197,8 +222,20 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
197
222
  const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
198
223
  const displayDiscount = displayLines.reduce(
199
224
  (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
200
- const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
201
- const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
225
+ const chargeRows = (items: ChargeLine[]) => items.map(({ id, name, amountMinor, netMinor, totalMinor }) =>
226
+ ({ id, name, amountMinor: woo ? taxInclusive ? totalMinor! : netMinor : amountMinor }));
227
+ const displayFees = chargeRows(fees), displayShipping = chargeRows(shipping);
228
+ let displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
229
+ - [...displayFees, ...displayShipping].reduce((sum, charge) => sum + charge.amountMinor, 0);
230
+ let residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
231
+ if (woo) {
232
+ // ADR-076: the last shipping row, else fee, else product takes either sign of residue.
233
+ const charge = displayShipping.at(-1) ?? displayFees.at(-1);
234
+ const last = charge ?? displayLines.at(-1);
235
+ if (last) last.amountMinor += residue;
236
+ if (charge) displaySubtotal -= residue;
237
+ residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
238
+ }
202
239
  const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
203
240
  const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
204
241
  ...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
@@ -221,7 +258,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
221
258
  if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
222
259
  for (const { line, shareMinor, returned } of figures) {
223
260
  if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
224
- if (returned) continue;
261
+ if (returned || woo) continue;
225
262
  const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
226
263
  if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
227
264
  throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
@@ -230,6 +267,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
230
267
  const display = {
231
268
  taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
232
269
  lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
270
+ ...(fees.length ? { fees: displayFees } : {}),
271
+ ...(shipping.length ? { shipping: displayShipping } : {}),
233
272
  };
234
273
  const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
235
274
  const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
@@ -239,6 +278,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
239
278
  id: orderId,
240
279
  status: 'draft',
241
280
  lineItems: lines,
281
+ ...(fees.length ? { fees: [...fees] } : {}),
282
+ ...(shipping.length ? { shipping: [...shipping] } : {}),
242
283
  discounts: recalcedOrderDiscounts,
243
284
  payments: [...payments],
244
285
  customer,
@@ -263,6 +304,28 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
263
304
  subject.next(buildOrder());
264
305
  }
265
306
 
307
+ function recalculateCharge(id: string, input: ChargeInput, shipping = false): ChargeLine {
308
+ if (!Number.isInteger(input.amountMinor)) throw new RangeError('Amount must be integer minor units');
309
+ if (input.amountMinor < 0) throw new RangeError('Amount must be >= 0');
310
+ if (!input.name.trim()) throw new RangeError('Name must not be empty');
311
+ const taxStatus = input.taxStatus ?? 'taxable';
312
+ if (woo) {
313
+ const line = wooLine(input.amountMinor, input.taxClass, taxContext.pricesIncludeTax, taxStatus === 'none', shipping);
314
+ return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
315
+ ...line };
316
+ }
317
+ const code = taxContext.getTaxRateCode?.(input.taxClass);
318
+ const line = recalculateLine({
319
+ id, productId: id, name: input.name, sku: '', unitPriceMinor: input.amountMinor, quantity: 1,
320
+ taxLines: [{ ...(code !== undefined ? { code } : {}),
321
+ ratePpm: taxStatus === 'none' ? 0 : taxContext.getTaxRatePpm(input.taxClass), taxMicros: '0' }],
322
+ discounts: [], discountMinor: 0, orderDiscountMinor: 0, netMinor: 0, taxMicros: '0',
323
+ taxInclusive: taxContext.pricesIncludeTax,
324
+ });
325
+ return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
326
+ taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: line.taxMicros };
327
+ }
328
+
266
329
  function addLine(input: AddLineInput): string {
267
330
  const { productId, variantId, unitPrice } = input;
268
331
  const quantity = input.quantity ?? 1;
@@ -270,12 +333,15 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
270
333
  if (!Number.isInteger(unitPrice.amount)) throw new RangeError('Price must be integer minor units');
271
334
  if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
272
335
  // A rate from the tax class carries the backend's name for it when one is mapped (#287, #288).
273
- const code = input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
274
- const taxRates = input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
336
+ const code = woo || input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
337
+ const taxRates = woo ? wooLine(unitPrice.amount * quantity, input.taxClass, unitPrice.taxInclusive ?? taxContext.pricesIncludeTax, input.taxStatus === 'none').taxLines
338
+ : input.taxStatus === 'none' ? [{ ...(code !== undefined ? { code } : {}), ratePpm: 0 }]
339
+ : input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
275
340
  const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
276
341
 
277
342
  const existing = lineItems.find(
278
- (li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
343
+ (li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
344
+ && (!woo || li.taxClass === input.taxClass && (li.taxStatus === 'none') === (input.taxStatus === 'none'))
279
345
  && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
280
346
  && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
281
347
  );
@@ -291,7 +357,10 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
291
357
  const lineId = uid();
292
358
  const line: LineItem = {
293
359
  id: lineId,
360
+ ...((input.custom ? input.taxClass !== undefined : woo) ? { taxClass: input.taxClass } : {}),
294
361
  productId,
362
+ ...(input.custom ? { custom: true as const } : {}),
363
+ ...(input.taxStatus === 'none' ? { taxStatus: 'none' as const } : {}),
295
364
  variantId,
296
365
  name: input.name,
297
366
  sku: input.sku ?? '',
@@ -324,6 +393,36 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
324
393
  order$: subject.asObservable(),
325
394
  addLine,
326
395
 
396
+ addFee(input) {
397
+ const fee = recalculateCharge(uid(), input);
398
+ fees = [...fees, fee]; emit(); return fee.id;
399
+ },
400
+ updateFee(id, patch) {
401
+ const fee = fees.find((entry) => entry.id === id);
402
+ if (!fee) throw new Error(`Unknown fee ${id}`);
403
+ const updated = recalculateCharge(id, { ...fee, ...patch });
404
+ fees = fees.map((entry) => entry.id === id ? updated : entry); emit();
405
+ },
406
+ removeFee(id) {
407
+ if (!fees.some((entry) => entry.id === id)) throw new Error(`Unknown fee ${id}`);
408
+ fees = fees.filter((entry) => entry.id !== id); emit();
409
+ },
410
+ addShipping(input) {
411
+ const charge = { ...recalculateCharge(uid(), input, true), methodId: input.methodId };
412
+ shipping = [...shipping, charge]; emit(); return charge.id;
413
+ },
414
+ updateShipping(id, patch) {
415
+ const charge = shipping.find((entry) => entry.id === id);
416
+ if (!charge) throw new Error(`Unknown shipping ${id}`);
417
+ const input = { ...charge, ...patch };
418
+ const updated = { ...recalculateCharge(id, input, true), methodId: input.methodId };
419
+ shipping = shipping.map((entry) => entry.id === id ? updated : entry); emit();
420
+ },
421
+ removeShipping(id) {
422
+ if (!shipping.some((entry) => entry.id === id)) throw new Error(`Unknown shipping ${id}`);
423
+ shipping = shipping.filter((entry) => entry.id !== id); emit();
424
+ },
425
+
327
426
  addProduct(doc, traits, opts) {
328
427
  const productId = traits.getId(doc);
329
428
  if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
@@ -342,6 +441,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
342
441
  unitPrice,
343
442
  quantity: opts?.quantity,
344
443
  taxClass: traits.getTaxClass?.(doc, opts?.variantId),
444
+ ...(traits.getTaxStatus?.(doc, opts?.variantId) === 'none' ? { taxStatus: 'none' as const } : {}),
345
445
  });
346
446
  },
347
447
 
@@ -432,6 +532,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
432
532
 
433
533
  clear() {
434
534
  lineItems = [];
535
+ fees = [];
536
+ shipping = [];
435
537
  orderDiscounts = [];
436
538
  payments = [];
437
539
  customer = null;
@@ -65,6 +65,9 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
65
65
  for (const line of saved.lineItems) {
66
66
  const lineId = builder.addLine({
67
67
  productId: line.productId,
68
+ custom: line.custom,
69
+ taxStatus: line.taxStatus,
70
+ ...(taxContext.rounding?.granularity === 'woocommerce' ? { taxClass: line.taxClass } : {}),
68
71
  variantId: line.variantId,
69
72
  name: line.name,
70
73
  sku: line.sku,
@@ -83,6 +86,9 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
83
86
  }
84
87
  }
85
88
 
89
+ for (const fee of saved.fees ?? []) builder.addFee(fee);
90
+ for (const charge of saved.shipping ?? []) builder.addShipping(charge);
91
+
86
92
  // Restore order-level discounts
87
93
  for (const discount of saved.discounts) {
88
94
  builder.applyOrderDiscount({
@@ -4,6 +4,8 @@ export interface Order {
4
4
  id: string;
5
5
  status: 'draft' | 'parked' | 'saved' | 'completed';
6
6
  lineItems: LineItem[];
7
+ fees?: FeeLine[];
8
+ shipping?: ShippingLine[];
7
9
  discounts: AppliedDiscount[];
8
10
  payments: Payment[];
9
11
  customer: CustomerSummary | null;
@@ -34,23 +36,31 @@ export type SentOrder = Omit<Order, 'customer'> & { customer: (Omit<CustomerSumm
34
36
  */
35
37
  export interface DisplayTotals {
36
38
  taxInclusive: boolean; // = order.pricesIncludeTax
37
- subtotalMinor: number; // before discounts: total − tax + discount when exclusive, total + discount when inclusive
39
+ subtotalMinor: number; // product lines before discounts, excluding fees and shipping (ADR-075)
38
40
  discountMinor: number; // Σ lines' discount rows + orderDiscountMinor, in the display mode; >= 0
39
41
  taxMinor: number; // = order.taxMinor (added when exclusive, included when inclusive)
40
42
  totalMinor: number; // = order.totalMinor
41
43
  lines: DisplayLine[]; // in lineItems order; Σ amountMinor = subtotalMinor
44
+ fees?: Array<{ id: string; name: string; amountMinor: number }>;
45
+ shipping?: Array<{ id: string; name: string; amountMinor: number }>;
42
46
  orderDiscountMinor: number; // the order discounts as one row, not allocated: Σ each line's share, converted on its own
43
47
  }
44
48
 
45
49
  /** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
46
50
  export interface DisplayLine {
47
51
  lineId: string;
48
- 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
49
53
  discounts: { discountId: string; label?: string; amountMinor: number }[]; // this line's own discounts, each converted on its own
50
54
  }
51
55
 
52
56
  export interface LineItem {
53
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;
62
+ custom?: true;
63
+ taxStatus?: 'none';
54
64
  productId: string;
55
65
  variantId?: string;
56
66
  name: string;
@@ -62,15 +72,33 @@ export interface LineItem {
62
72
  discounts: AppliedDiscount[];
63
73
  discountMinor: number; // line discounts + orderDiscountMinor, in the line's own mode
64
74
  orderDiscountMinor: number; // this line's allocated share of the order discounts (ADR-062)
65
- netMinor: number; // unitPriceMinor × quantity − discountMinor; the taxed base
75
+ netMinor: number; // unitPriceMinor × quantity − discountMinor; under WooCommerce, the rounded exclusive net
66
76
  taxMicros: string; // Σ taxLines[].taxMicros, decimal string of a bigint
67
77
  taxInclusive: boolean; // the price's own tax mode; the order's when the price has none
68
78
  /** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
69
79
  priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
70
80
  }
71
81
 
82
+ export interface ChargeLine {
83
+ id: string;
84
+ netMicros?: string;
85
+ totalMinor?: number;
86
+ name: string;
87
+ amountMinor: number; // integer >= 0, in the order's tax mode (ADR-075)
88
+ taxClass?: string;
89
+ taxStatus: 'taxable' | 'none';
90
+ taxLines: LineItem['taxLines'];
91
+ netMinor: number;
92
+ taxMicros: string;
93
+ }
94
+ export interface FeeLine extends ChargeLine {}
95
+ export interface ShippingLine extends ChargeLine { methodId?: string }
96
+ export interface ChargeInput { name: string; amountMinor: number; taxClass?: string; taxStatus?: 'taxable' | 'none' }
97
+
72
98
  export interface LineTaxLine {
73
99
  code?: string;
100
+ rateId?: number;
101
+ compound?: boolean;
74
102
  ratePpm: number;
75
103
  taxMicros: string; // exact, decimal string of a bigint
76
104
  }
@@ -113,6 +141,8 @@ export interface PaymentMethod {
113
141
 
114
142
  export interface AddLineInput {
115
143
  productId: string;
144
+ custom?: true;
145
+ taxStatus?: 'taxable' | 'none';
116
146
  variantId?: string;
117
147
  name: string;
118
148
  sku?: string;
@@ -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 {