@tallyui/pos 3.2.1 → 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.1",
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.1",
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.1",
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.1",
42
- "@tallyui/connector-woocommerce": "3.2.1",
43
- "@tallyui/database": "3.2.1"
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
@@ -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
 
@@ -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,
@@ -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,12 @@ 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
+ const wooTotals = woo ? woocommerceTotals(lines, fees, shipping, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : undefined;
192
+ const { subtotalMinor } = wooTotals ?? sumLines(lines, taxContext.rounding);
181
193
  const charges = [...fees, ...shipping];
182
194
  const taxedLines = [...lines, ...charges.map((charge) => ({ ...charge, taxInclusive: taxContext.pricesIncludeTax,
183
195
  taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString() }))];
184
- const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
196
+ const { taxMinor, totalMinor } = wooTotals ?? sumLines(taxedLines, taxContext.rounding);
185
197
  if (taxContext.rounding?.granularity === 'per_rate_group_items' && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
186
198
  perRateGroupWarned.add(taxContext);
187
199
  taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
@@ -198,11 +210,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
198
210
  // with no rows: its capped "discounts" are the negative gross cancelled, not money off.
199
211
  const returned = li.unitPriceMinor < 0;
200
212
  const discounts = returned ? [] : li.discounts.map((d) => ({
201
- 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),
202
214
  }));
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
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
206
218
  : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
207
219
  return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
208
220
  });
@@ -210,9 +222,20 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
210
222
  const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
211
223
  const displayDiscount = displayLines.reduce(
212
224
  (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);
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
+ }
216
239
  const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
217
240
  const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
218
241
  ...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
@@ -235,7 +258,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
235
258
  if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
236
259
  for (const { line, shareMinor, returned } of figures) {
237
260
  if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
238
- if (returned) continue;
261
+ if (returned || woo) continue;
239
262
  const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
240
263
  if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
241
264
  throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
@@ -244,8 +267,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
244
267
  const display = {
245
268
  taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
246
269
  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 })) } : {}),
270
+ ...(fees.length ? { fees: displayFees } : {}),
271
+ ...(shipping.length ? { shipping: displayShipping } : {}),
249
272
  };
250
273
  const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
251
274
  const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
@@ -281,11 +304,16 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
281
304
  subject.next(buildOrder());
282
305
  }
283
306
 
284
- function recalculateCharge(id: string, input: ChargeInput): ChargeLine {
307
+ function recalculateCharge(id: string, input: ChargeInput, shipping = false): ChargeLine {
285
308
  if (!Number.isInteger(input.amountMinor)) throw new RangeError('Amount must be integer minor units');
286
309
  if (input.amountMinor < 0) throw new RangeError('Amount must be >= 0');
287
310
  if (!input.name.trim()) throw new RangeError('Name must not be empty');
288
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
+ }
289
317
  const code = taxContext.getTaxRateCode?.(input.taxClass);
290
318
  const line = recalculateLine({
291
319
  id, productId: id, name: input.name, sku: '', unitPriceMinor: input.amountMinor, quantity: 1,
@@ -295,7 +323,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
295
323
  taxInclusive: taxContext.pricesIncludeTax,
296
324
  });
297
325
  return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
298
- taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: Number(line.taxMicros) };
326
+ taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: line.taxMicros };
299
327
  }
300
328
 
301
329
  function addLine(input: AddLineInput): string {
@@ -305,13 +333,15 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
305
333
  if (!Number.isInteger(unitPrice.amount)) throw new RangeError('Price must be integer minor units');
306
334
  if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
307
335
  // 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 }]
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 }]
310
339
  : input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
311
340
  const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
312
341
 
313
342
  const existing = lineItems.find(
314
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'))
315
345
  && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
316
346
  && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
317
347
  );
@@ -327,6 +357,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
327
357
  const lineId = uid();
328
358
  const line: LineItem = {
329
359
  id: lineId,
360
+ ...((input.custom ? input.taxClass !== undefined : woo) ? { taxClass: input.taxClass } : {}),
330
361
  productId,
331
362
  ...(input.custom ? { custom: true as const } : {}),
332
363
  ...(input.taxStatus === 'none' ? { taxStatus: 'none' as const } : {}),
@@ -377,14 +408,14 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
377
408
  fees = fees.filter((entry) => entry.id !== id); emit();
378
409
  },
379
410
  addShipping(input) {
380
- const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
411
+ const charge = { ...recalculateCharge(uid(), input, true), methodId: input.methodId };
381
412
  shipping = [...shipping, charge]; emit(); return charge.id;
382
413
  },
383
414
  updateShipping(id, patch) {
384
415
  const charge = shipping.find((entry) => entry.id === id);
385
416
  if (!charge) throw new Error(`Unknown shipping ${id}`);
386
417
  const input = { ...charge, ...patch };
387
- const updated = { ...recalculateCharge(id, input), methodId: input.methodId };
418
+ const updated = { ...recalculateCharge(id, input, true), methodId: input.methodId };
388
419
  shipping = shipping.map((entry) => entry.id === id ? updated : entry); emit();
389
420
  },
390
421
  removeShipping(id) {
@@ -410,6 +441,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
410
441
  unitPrice,
411
442
  quantity: opts?.quantity,
412
443
  taxClass: traits.getTaxClass?.(doc, opts?.variantId),
444
+ ...(traits.getTaxStatus?.(doc, opts?.variantId) === 'none' ? { taxStatus: 'none' as const } : {}),
413
445
  });
414
446
  },
415
447
 
@@ -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
  })),
@@ -29,7 +29,11 @@ export function withSentForm(order: Order, posOrder: PosOrder): SentOrder {
29
29
  if (customer && posOrder.customer?.id === undefined) delete customer.id;
30
30
  return { ...order, customer,
31
31
  lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
32
+ ...(order.fees ? { fees: order.fees.map((fee, i) => ({ ...fee, name: posOrder.fees![i].name })) } : {}),
33
+ ...(order.shipping ? { shipping: order.shipping.map((charge, i) => ({ ...charge, name: posOrder.shipping![i].name })) } : {}),
32
34
  display: posOrder.display ? { ...order.display,
35
+ ...(order.display.fees ? { fees: order.display.fees.map((row, i) => ({ ...row, name: posOrder.display!.fees![i].name })) } : {}),
36
+ ...(order.display.shipping ? { shipping: order.display.shipping.map((row, i) => ({ ...row, name: posOrder.display!.shipping![i].name })) } : {}),
33
37
  lines: order.display.lines.map((line, i) => ({ ...line,
34
38
  discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display!.lines[i].discounts[j].label })),
35
39
  })),
@@ -43,7 +47,15 @@ export function freezeSentForm(order: PosOrder): PosOrder {
43
47
  const lines = order.lines.map((line) => {
44
48
  const name = cutText(line.name, PAYLOAD_STRING_MAX);
45
49
  changed ||= name !== line.name;
46
- return { ...line, name };
50
+ const custom = line.custom && { ...line.custom, name: cutText(line.custom.name, PAYLOAD_STRING_MAX) };
51
+ changed ||= custom !== undefined && custom.name !== line.custom!.name;
52
+ return { ...line, name, ...(custom ? { custom } : {}) };
53
+ });
54
+ const charges = { fees: order.fees, shipping: order.shipping };
55
+ for (const field of ['fees', 'shipping'] as const) if (charges[field]) charges[field] = charges[field]!.map((charge) => {
56
+ const name = cutText(charge.name, PAYLOAD_STRING_MAX);
57
+ changed ||= name !== charge.name;
58
+ return { ...charge, name };
47
59
  });
48
60
  const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({ ...line,
49
61
  discounts: line.discounts.map((discount) => {
@@ -53,6 +65,11 @@ export function freezeSentForm(order: PosOrder): PosOrder {
53
65
  return { ...discount, label };
54
66
  }),
55
67
  })) };
68
+ for (const field of ['fees', 'shipping'] as const) if (display?.[field]) display[field] = display[field]!.map((row) => {
69
+ const name = cutText(row.name, PAYLOAD_STRING_MAX);
70
+ changed ||= name !== row.name;
71
+ return { ...row, name };
72
+ });
56
73
  const payments = order.payments.map((payment) => {
57
74
  if (payment.reference === undefined) return payment;
58
75
  const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
@@ -67,7 +84,8 @@ export function freezeSentForm(order: PosOrder): PosOrder {
67
84
  if (!localWarnings.some((warning) => warning.code === 'customer_omitted' && warning.field === field)) localWarnings.push({ code: 'customer_omitted', field });
68
85
  }
69
86
  }
70
- return changed ? { ...order, lines, payments, customer, ...(display ? { display } : {}), ...(localWarnings.length ? { localWarnings } : {}) } : order;
87
+ return changed ? { ...order, lines, payments, customer, ...(charges.fees ? { fees: charges.fees } : {}),
88
+ ...(charges.shipping ? { shipping: charges.shipping } : {}), ...(display ? { display } : {}), ...(localWarnings.length ? { localWarnings } : {}) } : order;
71
89
  }
72
90
 
73
91
  /**
@@ -94,8 +112,9 @@ export function referenceReason(value: string | undefined): 'long' | 'nul' | nul
94
112
  * customer email or id the shape check would refuse is left out, so `toOrderCreateEnvelope` sends it unchanged.
95
113
  */
96
114
  export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
97
- if (order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) {
98
- throw new Error('finalize: fees, shipping and custom lines need order.create version 5, which this release does not send yet');
115
+ if ((order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom))
116
+ && (options.capabilities?.orderCreate ?? 1) < 5) {
117
+ throw new Error("finalize: fees, shipping and custom lines need the store to accept order.create version 5; update the store's TallyUI plugin");
99
118
  }
100
119
  if (!order.lineItems.length) throw new Error('finalize: no lines');
101
120
  // Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
@@ -139,14 +158,20 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
139
158
  }
140
159
  const newId = options.newId ?? uuidv7;
141
160
  const id = newId();
142
- const lines = order.lineItems.map((line) => ({
161
+ const lines: PosOrder['lines'] = order.lineItems.map((line) => ({
143
162
  id: newId(), productId: line.productId,
144
163
  ...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
164
+ ...(line.custom ? { custom: { name: line.name, ...(line.sku ? { sku: line.sku } : {}),
165
+ ...(line.taxClass !== undefined ? { taxClass: line.taxClass } : {}), taxStatus: line.taxStatus ?? 'taxable' } } : {}),
166
+ ...(line.taxStatus !== undefined ? { taxStatus: line.taxStatus } : {}),
145
167
  name: line.name, sku: line.sku, quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
146
168
  discountMinor: line.discountMinor, netMinor: line.netMinor,
169
+ ...(order.taxRounding?.granularity === 'woocommerce' ? { netMicros: line.netMicros } : {}),
147
170
  taxLines: line.taxLines.map((tax) => ({ ...tax })),
148
171
  ...(line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}),
149
172
  }));
173
+ const fees = order.fees?.map((charge) => ({ ...charge, id: newId(), taxLines: charge.taxLines.map((tax) => ({ ...tax })) }));
174
+ const shipping = order.shipping?.map((charge) => ({ ...charge, id: newId(), taxLines: charge.taxLines.map((tax) => ({ ...tax })) }));
150
175
  const payments: PosOrderPayment[] = order.payments.map((payment) => ({
151
176
  id: newId(), method: payment.method as PosOrderPayment['method'], amountMinor: payment.amountMinor,
152
177
  ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
@@ -182,6 +207,8 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
182
207
  throw new Error('finalize: display lines do not match the order lines');
183
208
  }
184
209
  display = { currency: order.currency, exponent: minorUnitDigits(order.currency), ...order.display,
210
+ ...(fees?.length ? { fees: order.display.fees!.map((row, i) => ({ ...row, id: fees[i].id })) } : {}),
211
+ ...(shipping?.length ? { shipping: order.display.shipping!.map((row, i) => ({ ...row, id: shipping[i].id })) } : {}),
185
212
  lines: order.display.lines.map((line, i) => ({
186
213
  lineId: lines[i].id, amountMinor: line.amountMinor,
187
214
  discounts: line.discounts.map(({ discountId, label, amountMinor }) => ({
@@ -189,7 +216,9 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
189
216
  })),
190
217
  })),
191
218
  };
192
- taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, undefined, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
219
+ const taxedLines = [...order.lineItems, ...[...(order.fees ?? []), ...(order.shipping ?? [])]
220
+ .map((charge) => ({ ...charge, taxInclusive: order.pricesIncludeTax }))];
221
+ taxByRate = taxLinesByRate(taxedLines, order.taxMinor, undefined, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
193
222
  ratePpm, ...(code !== undefined ? { code } : {}), netMinor, amountMinor, grossMinor: netMinor + amountMinor,
194
223
  }));
195
224
  if (display.totalMinor !== order.totalMinor || display.taxMinor !== order.taxMinor
@@ -204,11 +233,13 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
204
233
  // absent on the snapshot is the default the figures used; `custom` computes as the default but records itself.
205
234
  const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
206
235
  const taxRounding: TaxRounding = rounding.granularity === 'custom' ? { granularity: 'custom' }
236
+ : rounding.granularity === 'woocommerce' ? { granularity: 'woocommerce', roundAtSubtotal: rounding.roundAtSubtotal }
207
237
  : { granularity: rounding.granularity, mode: rounding.mode };
208
238
  const now = (options.now ?? new Date()).toISOString();
209
239
  return freezeSentForm({
210
240
  id, saleId: order.id, createdAt: now, updatedAt: now, commandId: newId(), syncStatus: 'pending',
211
241
  currency: order.currency, pricesIncludeTax: order.pricesIncludeTax, lines, payments,
242
+ ...(fees?.length ? { fees } : {}), ...(shipping?.length ? { shipping } : {}),
212
243
  subtotalMinor: order.subtotalMinor, discountMinor: order.discountMinor,
213
244
  taxMinor: order.taxMinor, totalMinor: order.totalMinor, taxRounding,
214
245
  ...(display && taxByRate ? { display, taxByRate } : {}),