@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/dist/index.d.ts +122 -7
- package/dist/index.js +483 -47
- package/package.json +6 -6
- package/src/index.ts +5 -1
- package/src/order/index.ts +4 -0
- package/src/order/order-builder.ts +116 -14
- package/src/order/order-drafts.ts +6 -0
- package/src/order/types.ts +33 -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 +38 -4
- package/src/pos-order/schema.ts +41 -5
- package/src/pos-order/types.ts +9 -4
- package/src/receipt/build-receipt-data.ts +8 -1
- package/src/receipt/types.ts +2 -0
- 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/sale/use-sale.ts +50 -6
- 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
|
@@ -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
|
|
package/src/order/index.ts
CHANGED
|
@@ -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
|
|
172
|
-
|
|
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:
|
|
213
|
+
discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: displayAmount(li, d.amountMinor),
|
|
189
214
|
}));
|
|
190
|
-
const shareMinor =
|
|
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
|
|
201
|
-
|
|
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 =
|
|
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({
|
package/src/order/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
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 {
|