@tallyui/pos 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +42 -8
- package/dist/index.js +325 -53
- package/package.json +6 -6
- package/src/index.ts +1 -1
- package/src/order/order-builder.ts +62 -20
- 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 +5 -3
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +3 -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 +9 -4
- package/src/tax/types.ts +6 -1
|
@@ -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 } : {}),
|
package/src/pos-order/schema.ts
CHANGED
|
@@ -20,9 +20,12 @@ import type { PosOrder } from './types';
|
|
|
20
20
|
* nothing else. Its migration sets it on every older row. It is one-way like version 5.
|
|
21
21
|
* Version 7 adds `saleId`, the sale order's id (ADR-072; never sent), and changes nothing else. Its migration is the
|
|
22
22
|
* identity: older orders have no `saleId`. It is one-way like version 6.
|
|
23
|
+
* Version 8 adds fees, shipping, custom lines (ADR-075) and the woocommerce tax rounding (ADR-076), and changes nothing else.
|
|
24
|
+
* It also stores the WooCommerce 6dp net (`netMicros`) under the woocommerce rounding (ADR-076 amendment).
|
|
25
|
+
* Its migration is the identity. It is one-way like version 7.
|
|
23
26
|
*/
|
|
24
27
|
export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
25
|
-
version:
|
|
28
|
+
version: 8, primaryKey: 'id', type: 'object', additionalProperties: false,
|
|
26
29
|
properties: {
|
|
27
30
|
id: { type: 'string', maxLength: 36 },
|
|
28
31
|
saleId: { type: 'string', maxLength: 36 },
|
|
@@ -39,7 +42,13 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
39
42
|
type: 'object', properties: {
|
|
40
43
|
id: { type: 'string', maxLength: 36 }, productId: { type: 'string' }, variantId: { type: 'string' },
|
|
41
44
|
name: { type: 'string' }, sku: { type: 'string' }, quantity: { type: 'integer' },
|
|
45
|
+
taxStatus: { type: 'string', enum: ['taxable', 'none'] },
|
|
46
|
+
custom: { type: 'object', additionalProperties: false, properties: {
|
|
47
|
+
name: { type: 'string', minLength: 1, maxLength: 255 }, sku: { type: 'string', maxLength: 64 },
|
|
48
|
+
taxClass: { type: 'string', maxLength: 64 }, taxStatus: { type: 'string', enum: ['taxable', 'none'] },
|
|
49
|
+
}, required: ['name', 'taxStatus'] },
|
|
42
50
|
unitPriceMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, netMinor: { type: 'integer' },
|
|
51
|
+
netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 },
|
|
43
52
|
taxLines: { type: 'array', items: {
|
|
44
53
|
type: 'object', properties: { code: { type: 'string' }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' } },
|
|
45
54
|
required: ['ratePpm', 'taxMicros'],
|
|
@@ -47,6 +56,26 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
47
56
|
},
|
|
48
57
|
required: ['id', 'productId', 'name', 'sku', 'quantity', 'unitPriceMinor', 'discountMinor', 'netMinor', 'taxLines'],
|
|
49
58
|
} },
|
|
59
|
+
fees: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
60
|
+
id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 },
|
|
61
|
+
amountMinor: { type: 'integer', minimum: 0 }, taxClass: { type: 'string', maxLength: 64 },
|
|
62
|
+
taxStatus: { type: 'string', enum: ['taxable', 'none'] }, netMinor: { type: 'integer' }, taxMicros: { type: 'string' },
|
|
63
|
+
netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 }, totalMinor: { type: 'integer' },
|
|
64
|
+
taxLines: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
65
|
+
code: { type: 'string', maxLength: 255 }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' },
|
|
66
|
+
rateId: { type: 'integer' }, compound: { type: 'boolean' },
|
|
67
|
+
}, required: ['ratePpm', 'taxMicros'] } },
|
|
68
|
+
}, required: ['id', 'name', 'amountMinor', 'taxStatus', 'taxLines', 'netMinor', 'taxMicros'] } },
|
|
69
|
+
shipping: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
70
|
+
id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 },
|
|
71
|
+
methodId: { type: 'string', maxLength: 64 }, amountMinor: { type: 'integer', minimum: 0 }, taxClass: { type: 'string', maxLength: 64 },
|
|
72
|
+
taxStatus: { type: 'string', enum: ['taxable', 'none'] }, netMinor: { type: 'integer' }, taxMicros: { type: 'string' },
|
|
73
|
+
netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 }, totalMinor: { type: 'integer' },
|
|
74
|
+
taxLines: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
75
|
+
code: { type: 'string', maxLength: 255 }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' },
|
|
76
|
+
rateId: { type: 'integer' }, compound: { type: 'boolean' },
|
|
77
|
+
}, required: ['ratePpm', 'taxMicros'] } },
|
|
78
|
+
}, required: ['id', 'name', 'amountMinor', 'taxStatus', 'taxLines', 'netMinor', 'taxMicros'] } },
|
|
50
79
|
payments: { type: 'array', items: {
|
|
51
80
|
type: 'object', properties: {
|
|
52
81
|
id: { type: 'string', maxLength: 36 }, method: { type: 'string', enum: ['cash', 'external'] },
|
|
@@ -70,16 +99,23 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
|
70
99
|
}, required: ['since', 'reason', 'isolated'] },
|
|
71
100
|
lateSessionId: { type: 'string' },
|
|
72
101
|
taxRounding: { type: 'object', additionalProperties: false, properties: {
|
|
73
|
-
granularity: { type: 'string', enum: ['per_order', 'per_line_items', 'per_rate_group_items', 'custom'] },
|
|
102
|
+
granularity: { type: 'string', enum: ['per_order', 'per_line_items', 'per_rate_group_items', 'custom', 'woocommerce'] },
|
|
103
|
+
roundAtSubtotal: { type: 'boolean' },
|
|
74
104
|
mode: { type: 'string', enum: ['half_away_from_zero', 'half_up'] },
|
|
75
105
|
}, required: ['granularity'] },
|
|
76
|
-
sentVersion: { type: 'integer', minimum: 1, maximum:
|
|
77
|
-
downgradedFrom: { type: 'integer', minimum: 1, maximum:
|
|
106
|
+
sentVersion: { type: 'integer', minimum: 1, maximum: 5 },
|
|
107
|
+
downgradedFrom: { type: 'integer', minimum: 1, maximum: 5 },
|
|
78
108
|
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
79
109
|
display: { type: 'object', additionalProperties: false, properties: {
|
|
80
110
|
currency: { type: 'string' }, exponent: { type: 'integer' }, taxInclusive: { type: 'boolean' },
|
|
81
111
|
subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, taxMinor: { type: 'integer' },
|
|
82
112
|
totalMinor: { type: 'integer' }, orderDiscountMinor: { type: 'integer' },
|
|
113
|
+
fees: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
114
|
+
id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 }, amountMinor: { type: 'integer' },
|
|
115
|
+
}, required: ['id', 'name', 'amountMinor'] } },
|
|
116
|
+
shipping: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
117
|
+
id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 }, amountMinor: { type: 'integer' },
|
|
118
|
+
}, required: ['id', 'name', 'amountMinor'] } },
|
|
83
119
|
lines: { type: 'array', items: {
|
|
84
120
|
type: 'object', additionalProperties: false, properties: {
|
|
85
121
|
lineId: { type: 'string' }, amountMinor: { type: 'integer' },
|
|
@@ -129,5 +165,5 @@ export function posOrderCollection(): { schema: RxJsonSchema<PosOrder>; migratio
|
|
|
129
165
|
// it means no older sale is ever re-rounded (#287).
|
|
130
166
|
const recordRounding = (doc: PosOrder) => { doc.taxRounding ??= { ...DEFAULT_TAX_ROUNDING }; return doc; };
|
|
131
167
|
return { schema: posOrderSchema,
|
|
132
|
-
migrationStrategies: { 1: identity, 2: identity, 3: identity, 4: identity, 5: recordSent, 6: recordRounding, 7: identity } };
|
|
168
|
+
migrationStrategies: { 1: identity, 2: identity, 3: identity, 4: identity, 5: recordSent, 6: recordRounding, 7: identity, 8: identity } };
|
|
133
169
|
}
|
package/src/pos-order/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind, TaxRounding } from '@tallyui/core';
|
|
2
|
-
import type { DisplayTotals } from '../order/types';
|
|
1
|
+
import type { CommandError, CommandServerRefs, CommandWarning, OrderCreateLine, PaymentMethodKind, TaxRounding } from '@tallyui/core';
|
|
2
|
+
import type { DisplayTotals, FeeLine, ShippingLine } from '../order/types';
|
|
3
3
|
|
|
4
4
|
export type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
|
|
5
5
|
|
|
@@ -19,6 +19,8 @@ export interface PosOrderLine {
|
|
|
19
19
|
id: string;
|
|
20
20
|
productId: string;
|
|
21
21
|
variantId?: string;
|
|
22
|
+
custom?: OrderCreateLine['custom'];
|
|
23
|
+
taxStatus?: 'taxable' | 'none';
|
|
22
24
|
name: string;
|
|
23
25
|
sku: string;
|
|
24
26
|
quantity: number;
|
|
@@ -26,6 +28,7 @@ export interface PosOrderLine {
|
|
|
26
28
|
/** Line discounts plus the allocated order-discount share, in the line's own tax mode (ADR-062). */
|
|
27
29
|
discountMinor: number;
|
|
28
30
|
netMinor: number;
|
|
31
|
+
netMicros?: string;
|
|
29
32
|
taxLines: Array<{ code?: string; ratePpm: number; taxMicros: string }>;
|
|
30
33
|
/** This line's own tax mode; set only when it was converted from the store's (ADR-038 amendment). */
|
|
31
34
|
taxInclusive?: boolean;
|
|
@@ -48,6 +51,8 @@ export interface PosOrder {
|
|
|
48
51
|
currency: string;
|
|
49
52
|
pricesIncludeTax: boolean;
|
|
50
53
|
lines: PosOrderLine[];
|
|
54
|
+
fees?: FeeLine[];
|
|
55
|
+
shipping?: ShippingLine[];
|
|
51
56
|
subtotalMinor: number;
|
|
52
57
|
discountMinor: number;
|
|
53
58
|
taxMinor: number;
|
|
@@ -71,9 +76,9 @@ export interface PosOrder {
|
|
|
71
76
|
* The order.create version every attempt under this `commandId` goes out at: the outbox records it before the first
|
|
72
77
|
* send, and lowers it only on a downgrade (ADR-065 amendment). Absent means not sent yet (or requeued).
|
|
73
78
|
*/
|
|
74
|
-
sentVersion?: 1 | 2 | 3 | 4;
|
|
79
|
+
sentVersion?: 1 | 2 | 3 | 4 | 5;
|
|
75
80
|
/** The version first tried, before the downgrade (the order's audit). */
|
|
76
|
-
downgradedFrom?: 1 | 2 | 3 | 4;
|
|
81
|
+
downgradedFrom?: 1 | 2 | 3 | 4 | 5;
|
|
77
82
|
/** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. */
|
|
78
83
|
display?: DisplayTotals & { currency: string; exponent: number };
|
|
79
84
|
/** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). */
|
|
@@ -5,7 +5,8 @@ import type { ReceiptConfig, ReceiptData } from './types';
|
|
|
5
5
|
export function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData {
|
|
6
6
|
// Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
|
|
7
7
|
// never showed it, and an exact-shape test elsewhere in this package pins that.
|
|
8
|
-
const taxedLines = [...order.lineItems
|
|
8
|
+
const taxedLines = [...order.lineItems.map((line) => order.taxRounding?.granularity === 'woocommerce' && line.netMicros !== undefined
|
|
9
|
+
? { ...line, taxInclusive: order.pricesIncludeTax } : line), ...[...(order.fees ?? []), ...(order.shipping ?? [])].map((charge) => ({
|
|
9
10
|
...charge, taxInclusive: order.pricesIncludeTax,
|
|
10
11
|
}))];
|
|
11
12
|
const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
|
|
@@ -18,6 +19,7 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
|
|
|
18
19
|
let taxSoFar = order.lineItems.reduce(
|
|
19
20
|
(sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros), 0n);
|
|
20
21
|
const lineTotals = order.lineItems.map((li) => {
|
|
22
|
+
if (order.taxRounding?.granularity === 'woocommerce' && li.netMicros !== undefined) return order.pricesIncludeTax ? li.totalMinor! : li.netMinor;
|
|
21
23
|
if (!li.priceTaxModeConverted) return li.netMinor;
|
|
22
24
|
const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
|
|
23
25
|
taxSoFar += BigInt(li.taxMicros);
|
|
@@ -172,7 +172,7 @@ function envelope<R extends Values>(row: R, context: ClosureContext, xreport = f
|
|
|
172
172
|
return {
|
|
173
173
|
closure: {
|
|
174
174
|
has_sales:
|
|
175
|
-
|
|
175
|
+
Number(breakdowns.transaction_count ?? 0) > 0 || Number(row.period_sales_total || 0) !== 0 || Number(row.period_refunds_total || 0) !== 0,
|
|
176
176
|
has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
|
|
177
177
|
...Object.fromEntries(
|
|
178
178
|
['payment_methods', 'tax_rates', 'movements'].map((key) => [`has_${key}`, Object.keys((breakdowns[key] as Values) ?? {}).length > 0]),
|
|
@@ -481,10 +481,12 @@ export async function writeClosure({
|
|
|
481
481
|
for (const order of bound) {
|
|
482
482
|
// Grouped by the rule applied: a `custom` sale used the default figures, so it joins the default's group.
|
|
483
483
|
const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
|
|
484
|
-
const
|
|
485
|
-
roundings.add(`${granularity} ${mode}`);
|
|
484
|
+
const rounding = recorded.granularity === 'custom' ? DEFAULT_TAX_ROUNDING : recorded;
|
|
485
|
+
roundings.add(rounding.granularity === 'woocommerce' ? `woocommerce ${rounding.roundAtSubtotal}` : `${rounding.granularity} ${rounding.mode}`);
|
|
486
486
|
const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
|
|
487
|
-
|
|
487
|
+
const taxedLines = [...lines, ...[...(order.fees ?? []), ...(order.shipping ?? [])]
|
|
488
|
+
.map((charge) => ({ ...charge, taxInclusive: order.pricesIncludeTax }))];
|
|
489
|
+
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(taxedLines, order.taxMinor, undefined, order.taxRounding)) {
|
|
488
490
|
const existing = taxRates.get(String(ratePpm));
|
|
489
491
|
taxRates.set(String(ratePpm), {
|
|
490
492
|
ratePpm,
|
package/src/sale/cart.ts
CHANGED
|
@@ -19,5 +19,6 @@ export function addEntryToCart<Doc>(builder: OrderBuilder, entry: CatalogueEntry
|
|
|
19
19
|
productId: traits.getId(product), variantId: variant.id, name,
|
|
20
20
|
sku: variant.sku ?? '', imageUrl: traits.getImageUrl?.(product), unitPrice,
|
|
21
21
|
taxClass: traits.getTaxClass?.(product, variant.id),
|
|
22
|
+
...(traits.getTaxStatus?.(product, variant.id) === 'none' ? { taxStatus: 'none' as const } : {}),
|
|
22
23
|
});
|
|
23
24
|
}
|
package/src/sale/use-sale.ts
CHANGED
|
@@ -249,6 +249,9 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
249
249
|
|
|
250
250
|
const result = {
|
|
251
251
|
order, stage, error, idle,
|
|
252
|
+
/** The store's capabilities as passed in: a component offers fees, shipping and custom lines
|
|
253
|
+
* only when `orderCreate >= 5`, and their tax status and class only as `lineTax` allows. */
|
|
254
|
+
capabilities: opts.capabilities,
|
|
252
255
|
/** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
|
|
253
256
|
saving,
|
|
254
257
|
/** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
|
|
@@ -14,5 +14,9 @@ export function withPricingContext(context: SyncContext, settings: StoreSettings
|
|
|
14
14
|
*/
|
|
15
15
|
export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
|
|
16
16
|
const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
|
|
17
|
-
return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes })
|
|
17
|
+
return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes }),
|
|
18
|
+
...(settings.taxRates && { taxRates: settings.taxRates }),
|
|
19
|
+
...(settings.taxRoundAtSubtotal !== undefined && { taxRoundAtSubtotal: settings.taxRoundAtSubtotal }),
|
|
20
|
+
...(settings.shippingTaxClass !== undefined && { shippingTaxClass: settings.shippingTaxClass }),
|
|
21
|
+
...(settings.taxClassSlugs && { taxClassSlugs: settings.taxClassSlugs }) };
|
|
18
22
|
}
|
package/src/tax/exact.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TaxRounding } from '@tallyui/core';
|
|
2
|
+
import { woocommerceTaxByRate } from '../order/woocommerce-tax';
|
|
2
3
|
|
|
3
4
|
/** Micro-minor-units per minor unit. */
|
|
4
5
|
export const MICROS_PER_MINOR = 1_000_000n;
|
|
@@ -122,7 +123,7 @@ export interface RateTaxLine {
|
|
|
122
123
|
amountMinor: number;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
type TaxedLine = { netMinor: number; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
|
|
126
|
+
type TaxedLine = { netMinor: number; netMicros?: string; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
129
|
* A `per_line_items` or `per_rate_group_items` store's figures (#287) as @vendure/core 3.7.3 computes them; undefined
|
|
@@ -186,6 +187,7 @@ export function taxLinesByRate(
|
|
|
186
187
|
taxLabels?: Record<number, string>,
|
|
187
188
|
rounding?: TaxRounding,
|
|
188
189
|
): RateTaxLine[] {
|
|
190
|
+
if (rounding?.granularity === 'woocommerce' && lines.every((line) => line.netMicros !== undefined)) return woocommerceTaxByRate(lines, rounding.roundAtSubtotal, taxLabels, orderTaxMinor);
|
|
189
191
|
const label = (ratePpm: number) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`;
|
|
190
192
|
const rounded = roundedTaxByRate(lines, rounding);
|
|
191
193
|
if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
|
package/src/tax/index.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, co
|
|
|
2
2
|
export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './exact';
|
|
3
3
|
export { TaxProvider, useTax, taxLogger } from './tax-provider';
|
|
4
4
|
export type { TaxProviderProps } from './tax-provider';
|
|
5
|
-
export type { TaxRateMap, TaxContext } from './types';
|
|
5
|
+
export type { TaxRateMap, TaxContext, WooRate } from './types';
|
package/src/tax/tax-provider.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import type
|
|
3
|
+
import { woocommerceTax, type StoreSettings, type TaxRounding } from '@tallyui/core';
|
|
4
4
|
import type { TaxContext } from './types';
|
|
5
5
|
import { createLogger } from '../logging';
|
|
6
6
|
|
|
@@ -8,7 +8,8 @@ export const taxLogger = createLogger('tax');
|
|
|
8
8
|
|
|
9
9
|
const TaxCtx = createContext<TaxContext | null>(null);
|
|
10
10
|
|
|
11
|
-
export interface TaxProviderProps {
|
|
11
|
+
export interface TaxProviderProps extends Pick<StoreSettings, 'taxRoundAtSubtotal' | 'shippingTaxClass' | 'taxClassSlugs'> {
|
|
12
|
+
taxRates?: StoreSettings['taxRates'];
|
|
12
13
|
ratesPpm: Record<string, number>;
|
|
13
14
|
pricesIncludeTax: boolean;
|
|
14
15
|
/** `ServerCapabilities.taxRounding` (#287); a change restarts an idle sale under it. */
|
|
@@ -18,7 +19,7 @@ export interface TaxProviderProps {
|
|
|
18
19
|
children: ReactNode;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }: TaxProviderProps) {
|
|
22
|
+
export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates, shippingTaxClass, taxClassSlugs, children }: TaxProviderProps) {
|
|
22
23
|
const value = useMemo<TaxContext>(
|
|
23
24
|
() => {
|
|
24
25
|
if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
|
|
@@ -26,6 +27,8 @@ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, c
|
|
|
26
27
|
}
|
|
27
28
|
const unknown = new Set<string>();
|
|
28
29
|
return {
|
|
30
|
+
...(taxRates ? { getTaxRates: (taxClass?: string, options?: { shipping?: boolean }) =>
|
|
31
|
+
(taxRates[woocommerceTax.normalizeTaxClass(taxClass)] ?? []).filter((rate) => !options?.shipping || rate.shipping) } : {}),
|
|
29
32
|
getTaxRatePpm: (taxClass) => {
|
|
30
33
|
if (taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
|
|
31
34
|
// A class the store's rates don't key is taxed at the default rate; warn once per class and rate map.
|
|
@@ -42,9 +45,11 @@ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, c
|
|
|
42
45
|
},
|
|
43
46
|
pricesIncludeTax,
|
|
44
47
|
...(rounding ? { rounding } : {}),
|
|
48
|
+
...(shippingTaxClass !== undefined ? { shippingTaxClass } : {}),
|
|
49
|
+
...(taxClassSlugs !== undefined ? { taxClassSlugs } : {}),
|
|
45
50
|
};
|
|
46
51
|
},
|
|
47
|
-
[ratesPpm, pricesIncludeTax, rounding, rateCodes],
|
|
52
|
+
[ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates, shippingTaxClass, taxClassSlugs],
|
|
48
53
|
);
|
|
49
54
|
|
|
50
55
|
return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
|
package/src/tax/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { TaxRounding } from '@tallyui/core';
|
|
1
|
+
import type { StoreSettings, TaxRounding } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
export type WooRate = NonNullable<StoreSettings['taxRates']>[string][number];
|
|
2
4
|
|
|
3
5
|
/** Tax rates as integer parts per million (19% = 190000). */
|
|
4
6
|
export type TaxRateMap = {
|
|
@@ -7,6 +9,9 @@ export type TaxRateMap = {
|
|
|
7
9
|
};
|
|
8
10
|
|
|
9
11
|
export interface TaxContext {
|
|
12
|
+
shippingTaxClass?: string;
|
|
13
|
+
taxClassSlugs?: readonly string[];
|
|
14
|
+
getTaxRates?(taxClass?: string, options?: { shipping?: boolean }): readonly WooRate[];
|
|
10
15
|
/** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
|
|
11
16
|
getTaxRatePpm(taxClass?: string): number;
|
|
12
17
|
/** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */
|