@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/dist/index.d.ts +37 -8
- package/dist/index.js +304 -50
- package/package.json +6 -6
- package/src/index.ts +1 -1
- package/src/order/order-builder.ts +51 -19
- package/src/order/order-drafts.ts +1 -0
- package/src/order/types.ts +11 -3
- package/src/order/woocommerce-tax.ts +68 -0
- package/src/outbox/index.ts +1 -1
- package/src/outbox/order-outbox.ts +5 -3
- package/src/outbox/types.ts +8 -1
- package/src/pos-order/command.ts +21 -6
- package/src/pos-order/finalize.ts +37 -6
- package/src/pos-order/schema.ts +41 -5
- package/src/pos-order/types.ts +9 -4
- package/src/receipt/build-receipt-data.ts +3 -1
- package/src/register/closure-document.ts +1 -1
- package/src/register/session-store.ts +2 -2
- package/src/sale/cart.ts +1 -0
- package/src/store-settings/map-store-settings.ts +5 -1
- package/src/tax/exact.ts +3 -1
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +7 -4
- package/src/tax/types.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.
|
|
43
|
-
"@tallyui/database": "3.
|
|
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
|
|
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:
|
|
213
|
+
discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: displayAmount(li, d.amountMinor),
|
|
202
214
|
}));
|
|
203
|
-
const shareMinor =
|
|
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
|
|
214
|
-
|
|
215
|
-
const
|
|
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:
|
|
248
|
-
...(shipping.length ? { shipping:
|
|
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:
|
|
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 =
|
|
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,
|
package/src/order/types.ts
CHANGED
|
@@ -49,12 +49,16 @@ export interface DisplayTotals {
|
|
|
49
49
|
/** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
|
|
50
50
|
export interface DisplayLine {
|
|
51
51
|
lineId: string;
|
|
52
|
-
amountMinor: number; // quantity × unit price
|
|
52
|
+
amountMinor: number; // quantity × unit price, or WooCommerce's line figure; a residue recipient may adjust it
|
|
53
53
|
discounts: { discountId: string; label?: string; amountMinor: number }[]; // this line's own discounts, each converted on its own
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface LineItem {
|
|
57
57
|
id: string;
|
|
58
|
+
taxClass?: string;
|
|
59
|
+
/** WooCommerce's stored net at rounding precision, in integer micro-minor units, and its rounded gross. */
|
|
60
|
+
netMicros?: string;
|
|
61
|
+
totalMinor?: number;
|
|
58
62
|
custom?: true;
|
|
59
63
|
taxStatus?: 'none';
|
|
60
64
|
productId: string;
|
|
@@ -68,7 +72,7 @@ export interface LineItem {
|
|
|
68
72
|
discounts: AppliedDiscount[];
|
|
69
73
|
discountMinor: number; // line discounts + orderDiscountMinor, in the line's own mode
|
|
70
74
|
orderDiscountMinor: number; // this line's allocated share of the order discounts (ADR-062)
|
|
71
|
-
netMinor: number; // unitPriceMinor × quantity − discountMinor; the
|
|
75
|
+
netMinor: number; // unitPriceMinor × quantity − discountMinor; under WooCommerce, the rounded exclusive net
|
|
72
76
|
taxMicros: string; // Σ taxLines[].taxMicros, decimal string of a bigint
|
|
73
77
|
taxInclusive: boolean; // the price's own tax mode; the order's when the price has none
|
|
74
78
|
/** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
|
|
@@ -77,13 +81,15 @@ export interface LineItem {
|
|
|
77
81
|
|
|
78
82
|
export interface ChargeLine {
|
|
79
83
|
id: string;
|
|
84
|
+
netMicros?: string;
|
|
85
|
+
totalMinor?: number;
|
|
80
86
|
name: string;
|
|
81
87
|
amountMinor: number; // integer >= 0, in the order's tax mode (ADR-075)
|
|
82
88
|
taxClass?: string;
|
|
83
89
|
taxStatus: 'taxable' | 'none';
|
|
84
90
|
taxLines: LineItem['taxLines'];
|
|
85
91
|
netMinor: number;
|
|
86
|
-
taxMicros:
|
|
92
|
+
taxMicros: string;
|
|
87
93
|
}
|
|
88
94
|
export interface FeeLine extends ChargeLine {}
|
|
89
95
|
export interface ShippingLine extends ChargeLine { methodId?: string }
|
|
@@ -91,6 +97,8 @@ export interface ChargeInput { name: string; amountMinor: number; taxClass?: str
|
|
|
91
97
|
|
|
92
98
|
export interface LineTaxLine {
|
|
93
99
|
code?: string;
|
|
100
|
+
rateId?: number;
|
|
101
|
+
compound?: boolean;
|
|
94
102
|
ratePpm: number;
|
|
95
103
|
taxMicros: string; // exact, decimal string of a bigint
|
|
96
104
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { minorUnitDigits, woocommerceTax } from '@tallyui/core';
|
|
2
|
+
import type { WooRate } from '../tax/types';
|
|
3
|
+
import type { RateTaxLine } from '../tax/exact';
|
|
4
|
+
import type { ChargeLine, LineItem, LineTaxLine } from './types';
|
|
5
|
+
|
|
6
|
+
/** ADR-076: all WooCommerce float arithmetic stays here; only integer money leaves this module. */
|
|
7
|
+
export function woocommerceLine(amountMinor: number, rates: readonly WooRate[], inclusive: boolean, currency: string,
|
|
8
|
+
roundAtSubtotal: boolean, pricesIncludeTax = inclusive) {
|
|
9
|
+
const dp = minorUnitDigits(currency), factor = 10 ** dp;
|
|
10
|
+
const precision = woocommerceTax.getRoundingPrecision(dp);
|
|
11
|
+
const input = { amount: amountMinor / factor, rates: rates.map((rate) => ({ ...rate, order: rate.priority })),
|
|
12
|
+
amountIncludesTax: inclusive, dp, perRatePrecision: precision };
|
|
13
|
+
let result = woocommerceTax.calculateTaxes(input);
|
|
14
|
+
const net = woocommerceTax.roundHalfUp(input.amount - (inclusive ? result.total : 0), precision);
|
|
15
|
+
// WC_Order_Item_Product::calculate_taxes re-derives from the stored net (#2333 B).
|
|
16
|
+
if (inclusive && rates.length) result = woocommerceTax.calculateTaxes({ ...input, amount: net, amountIncludesTax: false });
|
|
17
|
+
const taxLines: LineTaxLine[] = result.taxes.map((tax) => {
|
|
18
|
+
const rate = rates.find((rate) => rate.id === tax.id)!;
|
|
19
|
+
return { code: rate.code, rateId: rate.id, compound: rate.compound, ratePpm: Math.round(Number(rate.rate) * 10000),
|
|
20
|
+
taxMicros: String(Math.round(tax.total * factor * 1e6)) };
|
|
21
|
+
});
|
|
22
|
+
const tax = result.taxes.reduce((sum, rate) => sum + (roundAtSubtotal ? rate.total
|
|
23
|
+
: woocommerceTax.roundTaxTotal(rate.total, dp, pricesIncludeTax)), 0);
|
|
24
|
+
return { netMinor: Math.round(woocommerceTax.roundHalfUp(net, dp) * factor), netMicros: String(Math.round(net * factor * 1e6)),
|
|
25
|
+
totalMinor: Math.round(woocommerceTax.roundHalfUp(net + tax, dp) * factor), taxLines,
|
|
26
|
+
taxMicros: taxLines.reduce((sum, rate) => sum + BigInt(rate.taxMicros), 0n).toString() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** calculateOrderTotals: products round conditionally, fees stay raw, shipping always rounds per item. */
|
|
30
|
+
export function woocommerceTotals(lines: readonly LineItem[], fees: readonly ChargeLine[], shipping: readonly ChargeLine[],
|
|
31
|
+
currency: string, roundAtSubtotal: boolean, inclusive: boolean) {
|
|
32
|
+
const dp = minorUnitDigits(currency), factor = 10 ** dp;
|
|
33
|
+
const net = (line: LineItem | ChargeLine) => Number(line.netMicros) / (factor * 1e6);
|
|
34
|
+
const productNet = lines.reduce((sum, line) => sum + (roundAtSubtotal ? net(line) : woocommerceTax.roundHalfUp(net(line), dp)), 0);
|
|
35
|
+
const amount = productNet + fees.reduce((sum, line) => sum + net(line), 0)
|
|
36
|
+
+ shipping.reduce((sum, line) => sum + woocommerceTax.roundHalfUp(net(line), dp), 0);
|
|
37
|
+
const taxes = (items: readonly (LineItem | ChargeLine)[]) => woocommerceTax.roundHalfUp(items.reduce((sum, line) =>
|
|
38
|
+
line.taxLines.reduce((sum, tax) => sum + (roundAtSubtotal ? Number(tax.taxMicros) / (factor * 1e6)
|
|
39
|
+
: woocommerceTax.roundTaxTotal(Number(tax.taxMicros) / (factor * 1e6), dp, inclusive)), sum), 0), woocommerceTax.getRoundingPrecision(dp));
|
|
40
|
+
const tax = woocommerceTax.roundHalfUp(taxes([...lines, ...fees]) + taxes(shipping), 6);
|
|
41
|
+
return { subtotalMinor: Math.round(woocommerceTax.roundHalfUp(productNet, dp) * factor),
|
|
42
|
+
taxMinor: Math.round((roundAtSubtotal ? woocommerceTax.roundHalfUp(tax, dp) : woocommerceTax.roundTaxTotal(tax, dp, inclusive)) * factor),
|
|
43
|
+
totalMinor: Math.round(woocommerceTax.roundHalfUp(woocommerceTax.roundHalfUp(amount + tax, 6), dp) * factor) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Order-math's rate buckets, including zero rows; the last row takes the bounded order tax residue. */
|
|
47
|
+
export function woocommerceTaxByRate(lines: readonly { netMinor: number; taxInclusive: boolean; taxLines: readonly LineTaxLine[] }[],
|
|
48
|
+
roundAtSubtotal: boolean, taxLabels?: Record<number, string>, orderTaxMinor?: number): RateTaxLine[] {
|
|
49
|
+
const groups = new Map<string, RateTaxLine & { amount: number; inclusive: boolean }>();
|
|
50
|
+
for (const line of lines) for (const tax of line.taxLines) {
|
|
51
|
+
const key = JSON.stringify([tax.rateId, tax.code, tax.ratePpm]);
|
|
52
|
+
const row = groups.get(key) ?? { code: tax.code, ratePpm: tax.ratePpm, label: taxLabels?.[tax.ratePpm] ?? `Tax ${tax.ratePpm / 10000}%`,
|
|
53
|
+
netMinor: 0, amountMinor: 0, amount: 0, inclusive: line.taxInclusive };
|
|
54
|
+
const amount = Number(tax.taxMicros) / 1e6;
|
|
55
|
+
row.amount += roundAtSubtotal ? amount : woocommerceTax.roundTaxTotal(amount, 0, line.taxInclusive);
|
|
56
|
+
row.netMinor += line.netMinor;
|
|
57
|
+
groups.set(key, row);
|
|
58
|
+
}
|
|
59
|
+
const rows = [...groups.values()].map(({ amount, inclusive, ...row }) => ({ ...row,
|
|
60
|
+
amountMinor: woocommerceTax.roundTaxTotal(woocommerceTax.roundHalfUp(amount, 6), 0, inclusive) }));
|
|
61
|
+
if (orderTaxMinor !== undefined && rows.length) {
|
|
62
|
+
const residue = orderTaxMinor - rows.reduce((sum, row) => sum + row.amountMinor, 0);
|
|
63
|
+
const bound = Math.floor(rows.length / 2) + 1;
|
|
64
|
+
if (Math.abs(residue) > bound) throw new Error(`woocommerce tax by rate: residue ${residue} exceeds its bound ${bound} (ADR-076)`);
|
|
65
|
+
rows[rows.length - 1].amountMinor += residue;
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
package/src/outbox/index.ts
CHANGED
|
@@ -6,6 +6,6 @@ export { createRegisterOutbox } from './register-outbox';
|
|
|
6
6
|
export type { RegisterOutboxOptions, RegisterOutbox } from './register-outbox';
|
|
7
7
|
export { useRegisterOutbox } from './use-register-outbox';
|
|
8
8
|
export type { UseRegisterOutboxOptions, UseRegisterOutboxResult } from './use-register-outbox';
|
|
9
|
-
export type { TransportOutcome, CommandTransport, OutboxState } from './types';
|
|
9
|
+
export type { TransportOutcome, CommandTransport, OrderTransportContext, OutboxState } from './types';
|
|
10
10
|
export { useOrderOutbox, outboxLogger } from './use-order-outbox';
|
|
11
11
|
export type { UseOrderOutboxOptions, UseOrderOutboxResult } from './use-order-outbox';
|
|
@@ -268,6 +268,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
268
268
|
const firstSend = orders.some((order) => order.sentVersion === undefined);
|
|
269
269
|
const serverMax = (firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.())
|
|
270
270
|
.catch((cause) => { outboxLogger.warn('Failed to read capabilities', { cause }); }))) || 3;
|
|
271
|
+
const localOrders = new Map<string, PosOrder>();
|
|
271
272
|
const batch = await Promise.all(orders.map(async (order) => {
|
|
272
273
|
const frozen = freezeSentForm(order);
|
|
273
274
|
const attempt = (attempts.get(order.commandId) ?? 0) + 1;
|
|
@@ -276,9 +277,9 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
276
277
|
try {
|
|
277
278
|
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: order.sentVersion ?? serverMax });
|
|
278
279
|
} catch (cause) {
|
|
279
|
-
//
|
|
280
|
+
// Content that cannot downgrade goes at its required version: the server's refusal rejects it.
|
|
280
281
|
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
281
|
-
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: 3 });
|
|
282
|
+
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: cause.needed === 5 ? 5 : 3 });
|
|
282
283
|
}
|
|
283
284
|
// Bounds apply when frozen: persist older tills' sent forms before sending (Front desk, 2026-09-29). A first
|
|
284
285
|
// send's version is stored, awaited, before the batch request leaves, so a crash in between keeps it.
|
|
@@ -286,9 +287,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
286
287
|
await (await collection.findOne(order.id).exec())?.incrementalModify((data) =>
|
|
287
288
|
({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope.version }));
|
|
288
289
|
}
|
|
290
|
+
localOrders.set(envelope.id, frozen);
|
|
289
291
|
return envelope;
|
|
290
292
|
}));
|
|
291
|
-
let outcome = await transport.send(batch);
|
|
293
|
+
let outcome = await transport.send(batch, { local: { orders: localOrders } });
|
|
292
294
|
// A 404 answers the whole batch (the route is missing); any other answer resets the count. Offline changes nothing.
|
|
293
295
|
backendNotFound.record(outcome, now());
|
|
294
296
|
if (outcome.kind !== 'retry' || outcome.reason !== 'network') {
|
package/src/outbox/types.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { AnyCommandEnvelope, CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
|
|
2
|
+
import type { PosOrder } from '../pos-order';
|
|
3
|
+
|
|
4
|
+
/** Local-only context for a transport (ADR-076 amendment 1): never serialised, never part of a payload. */
|
|
5
|
+
export interface OrderTransportContext {
|
|
6
|
+
/** The stored (frozen) order of each envelope in the batch, keyed by the envelope's `id` (its commandId). */
|
|
7
|
+
local: { orders: ReadonlyMap<string, PosOrder> };
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export type TransportOutcome =
|
|
4
11
|
| { kind: 'results'; results: CommandResult[] }
|
|
@@ -9,7 +16,7 @@ export type TransportOutcome =
|
|
|
9
16
|
| { kind: 'retry'; reason: string; retryAfterMs?: number };
|
|
10
17
|
|
|
11
18
|
export interface CommandTransport<E extends AnyCommandEnvelope = CommandEnvelope<OrderCreatePayload>> {
|
|
12
|
-
send(batch: E[]): Promise<TransportOutcome>;
|
|
19
|
+
send(batch: E[], context?: OrderTransportContext): Promise<TransportOutcome>;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export interface OutboxState {
|
package/src/pos-order/command.ts
CHANGED
|
@@ -55,9 +55,10 @@ function netDiscountMinor(line: PosOrderLine, pricesIncludeTax: boolean): number
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The order.create version an order's stored content makes: 3 with ADR-065's figures, else 2 when its lines are
|
|
58
|
-
* discounted (ADR-062), else 1.
|
|
58
|
+
* discounted (ADR-062), else 1; fees, shipping or custom lines need 5. Also used by pos_orders v5's migration.
|
|
59
59
|
*/
|
|
60
|
-
export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines'>): 1 | 2 | 3 {
|
|
60
|
+
export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines' | 'fees' | 'shipping'>): 1 | 2 | 3 | 5 {
|
|
61
|
+
if (order.fees?.length || order.shipping?.length || order.lines.some((line) => line.custom)) return 5;
|
|
61
62
|
return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -73,11 +74,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
73
74
|
const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
74
75
|
const content = contentVersion(order);
|
|
75
76
|
const cap = options?.maxVersion === undefined ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
|
|
77
|
+
if (content === 5 && cap !== undefined && cap < 5) throw new UnsupportedOrderVersionError(5, cap);
|
|
76
78
|
if (grossMinor > 0 && cap !== undefined && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
|
|
77
79
|
// Version 4 only when the cap allows it (the server advertises 4, and no sentVersion holds the order lower).
|
|
78
80
|
const version = (content === 3 && cap !== undefined && cap >= 4 ? 4
|
|
79
81
|
: Math.min(content, cap ?? content)) as OrderCreateEnvelope['version'];
|
|
80
|
-
const lineDiscounts = order.lines.map((line) => version
|
|
82
|
+
const lineDiscounts = order.lines.map((line) => version >= 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
|
|
81
83
|
// The order's discount is the sum of its lines', on one basis, so the payload's two always agree.
|
|
82
84
|
const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
|
|
83
85
|
const email = order.customer?.email;
|
|
@@ -85,16 +87,26 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
85
87
|
// These are the pre-#222 customerId and sessionId checks, kept byte-exact so stored orders resend older tills' bytes; don't replace with sendable.
|
|
86
88
|
const customerId = typeof id === 'string' && id.length > 0 && id.length <= 64 ? id : undefined;
|
|
87
89
|
const sessionId = order.sessionId ?? order.lateSessionId;
|
|
90
|
+
const { fees: _fees, shipping: _shipping, ...storedDisplay } = { ...order.display! };
|
|
88
91
|
return {
|
|
89
92
|
id: order.commandId, type: 'order.create', version, createdAt: order.createdAt, deviceId, attempt,
|
|
90
93
|
payload: {
|
|
91
94
|
clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
|
|
92
95
|
lines: order.lines.map((line, i) => ({
|
|
93
|
-
clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
|
|
96
|
+
clientLineId: line.id, ...(line.custom ? { custom: { ...line.custom } } : { variantId: line.variantId ?? line.productId }), title: line.name,
|
|
94
97
|
quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
|
|
95
98
|
...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
|
|
96
99
|
...(lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}),
|
|
97
100
|
})),
|
|
101
|
+
...(version === 5 && order.fees?.length ? { fees: order.fees.map((fee) => ({
|
|
102
|
+
clientFeeId: fee.id, name: fee.name, amountMinor: fee.amountMinor, taxStatus: fee.taxStatus,
|
|
103
|
+
...(fee.taxClass !== undefined ? { taxClass: fee.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(fee.taxMicros)),
|
|
104
|
+
})) } : {}),
|
|
105
|
+
...(version === 5 && order.shipping?.length ? { shipping: order.shipping.map((charge) => ({
|
|
106
|
+
clientShippingId: charge.id, name: charge.name, ...(charge.methodId !== undefined ? { methodId: charge.methodId } : {}),
|
|
107
|
+
amountMinor: charge.amountMinor, taxStatus: charge.taxStatus,
|
|
108
|
+
...(charge.taxClass !== undefined ? { taxClass: charge.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(charge.taxMicros)),
|
|
109
|
+
})) } : {}),
|
|
98
110
|
payments: order.payments.map((payment) => ({
|
|
99
111
|
clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
|
|
100
112
|
...(payment.tenderedMinor !== undefined ? { tenderedMinor: payment.tenderedMinor } : {}),
|
|
@@ -105,9 +117,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
105
117
|
...(discountMinor > 0 ? { discountMinor } : {}),
|
|
106
118
|
taxMinor: order.taxMinor, totalMinor: order.totalMinor,
|
|
107
119
|
...(version >= 3 ? {
|
|
108
|
-
display: { ...
|
|
120
|
+
display: { ...storedDisplay, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
|
|
109
121
|
clientLineId: lineId, amountMinor, discounts,
|
|
110
|
-
}))
|
|
122
|
+
})),
|
|
123
|
+
...(version === 5 && order.display!.fees ? { fees: order.display!.fees.map(({ id, amountMinor }) => ({ clientFeeId: id, amountMinor })) } : {}),
|
|
124
|
+
...(version === 5 && order.display!.shipping ? { shipping: order.display!.shipping.map(({ id, amountMinor }) => ({ clientShippingId: id, amountMinor })) } : {}),
|
|
125
|
+
},
|
|
111
126
|
taxByRate: order.taxByRate!.map(({ ratePpm, code, netMinor, amountMinor, grossMinor }) => ({
|
|
112
127
|
ratePpm, ...(code !== undefined ? { code } : {}), netMinor, taxMinor: amountMinor, grossMinor,
|
|
113
128
|
})),
|
|
@@ -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
|
-
|
|
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, ...(
|
|
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
|
-
|
|
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
|
-
|
|
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 } : {}),
|