@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.
@@ -55,9 +55,10 @@ function netDiscountMinor(line: PosOrderLine, pricesIncludeTax: boolean): number
55
55
 
56
56
  /**
57
57
  * The order.create version an order's stored content makes: 3 with ADR-065's figures, else 2 when its lines are
58
- * discounted (ADR-062), else 1. The builder's, and pos_orders v5's migration's for a row with no `sentVersion`.
58
+ * discounted (ADR-062), else 1; fees, shipping or custom lines need 5. Also used by pos_orders v5's migration.
59
59
  */
60
- export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines'>): 1 | 2 | 3 {
60
+ export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines' | 'fees' | 'shipping'>): 1 | 2 | 3 | 5 {
61
+ if (order.fees?.length || order.shipping?.length || order.lines.some((line) => line.custom)) return 5;
61
62
  return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
62
63
  }
63
64
 
@@ -73,11 +74,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
73
74
  const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
74
75
  const content = contentVersion(order);
75
76
  const cap = options?.maxVersion === undefined ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
77
+ if (content === 5 && cap !== undefined && cap < 5) throw new UnsupportedOrderVersionError(5, cap);
76
78
  if (grossMinor > 0 && cap !== undefined && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
77
79
  // Version 4 only when the cap allows it (the server advertises 4, and no sentVersion holds the order lower).
78
80
  const version = (content === 3 && cap !== undefined && cap >= 4 ? 4
79
81
  : Math.min(content, cap ?? content)) as OrderCreateEnvelope['version'];
80
- const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
82
+ const lineDiscounts = order.lines.map((line) => version >= 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
81
83
  // The order's discount is the sum of its lines', on one basis, so the payload's two always agree.
82
84
  const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
83
85
  const email = order.customer?.email;
@@ -85,16 +87,26 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
85
87
  // These are the pre-#222 customerId and sessionId checks, kept byte-exact so stored orders resend older tills' bytes; don't replace with sendable.
86
88
  const customerId = typeof id === 'string' && id.length > 0 && id.length <= 64 ? id : undefined;
87
89
  const sessionId = order.sessionId ?? order.lateSessionId;
90
+ const { fees: _fees, shipping: _shipping, ...storedDisplay } = { ...order.display! };
88
91
  return {
89
92
  id: order.commandId, type: 'order.create', version, createdAt: order.createdAt, deviceId, attempt,
90
93
  payload: {
91
94
  clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
92
95
  lines: order.lines.map((line, i) => ({
93
- clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
96
+ clientLineId: line.id, ...(line.custom ? { custom: { ...line.custom } } : { variantId: line.variantId ?? line.productId }), title: line.name,
94
97
  quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
95
98
  ...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
96
99
  ...(lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}),
97
100
  })),
101
+ ...(version === 5 && order.fees?.length ? { fees: order.fees.map((fee) => ({
102
+ clientFeeId: fee.id, name: fee.name, amountMinor: fee.amountMinor, taxStatus: fee.taxStatus,
103
+ ...(fee.taxClass !== undefined ? { taxClass: fee.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(fee.taxMicros)),
104
+ })) } : {}),
105
+ ...(version === 5 && order.shipping?.length ? { shipping: order.shipping.map((charge) => ({
106
+ clientShippingId: charge.id, name: charge.name, ...(charge.methodId !== undefined ? { methodId: charge.methodId } : {}),
107
+ amountMinor: charge.amountMinor, taxStatus: charge.taxStatus,
108
+ ...(charge.taxClass !== undefined ? { taxClass: charge.taxClass } : {}), taxMinor: roundMicrosToMinor(BigInt(charge.taxMicros)),
109
+ })) } : {}),
98
110
  payments: order.payments.map((payment) => ({
99
111
  clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
100
112
  ...(payment.tenderedMinor !== undefined ? { tenderedMinor: payment.tenderedMinor } : {}),
@@ -105,9 +117,12 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
105
117
  ...(discountMinor > 0 ? { discountMinor } : {}),
106
118
  taxMinor: order.taxMinor, totalMinor: order.totalMinor,
107
119
  ...(version >= 3 ? {
108
- display: { ...order.display!, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
120
+ display: { ...storedDisplay, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
109
121
  clientLineId: lineId, amountMinor, discounts,
110
- })) },
122
+ })),
123
+ ...(version === 5 && order.display!.fees ? { fees: order.display!.fees.map(({ id, amountMinor }) => ({ clientFeeId: id, amountMinor })) } : {}),
124
+ ...(version === 5 && order.display!.shipping ? { shipping: order.display!.shipping.map(({ id, amountMinor }) => ({ clientShippingId: id, amountMinor })) } : {}),
125
+ },
111
126
  taxByRate: order.taxByRate!.map(({ ratePpm, code, netMinor, amountMinor, grossMinor }) => ({
112
127
  ratePpm, ...(code !== undefined ? { code } : {}), netMinor, taxMinor: amountMinor, grossMinor,
113
128
  })),
@@ -29,7 +29,11 @@ export function withSentForm(order: Order, posOrder: PosOrder): SentOrder {
29
29
  if (customer && posOrder.customer?.id === undefined) delete customer.id;
30
30
  return { ...order, customer,
31
31
  lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
32
+ ...(order.fees ? { fees: order.fees.map((fee, i) => ({ ...fee, name: posOrder.fees![i].name })) } : {}),
33
+ ...(order.shipping ? { shipping: order.shipping.map((charge, i) => ({ ...charge, name: posOrder.shipping![i].name })) } : {}),
32
34
  display: posOrder.display ? { ...order.display,
35
+ ...(order.display.fees ? { fees: order.display.fees.map((row, i) => ({ ...row, name: posOrder.display!.fees![i].name })) } : {}),
36
+ ...(order.display.shipping ? { shipping: order.display.shipping.map((row, i) => ({ ...row, name: posOrder.display!.shipping![i].name })) } : {}),
33
37
  lines: order.display.lines.map((line, i) => ({ ...line,
34
38
  discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display!.lines[i].discounts[j].label })),
35
39
  })),
@@ -43,7 +47,15 @@ export function freezeSentForm(order: PosOrder): PosOrder {
43
47
  const lines = order.lines.map((line) => {
44
48
  const name = cutText(line.name, PAYLOAD_STRING_MAX);
45
49
  changed ||= name !== line.name;
46
- return { ...line, name };
50
+ const custom = line.custom && { ...line.custom, name: cutText(line.custom.name, PAYLOAD_STRING_MAX) };
51
+ changed ||= custom !== undefined && custom.name !== line.custom!.name;
52
+ return { ...line, name, ...(custom ? { custom } : {}) };
53
+ });
54
+ const charges = { fees: order.fees, shipping: order.shipping };
55
+ for (const field of ['fees', 'shipping'] as const) if (charges[field]) charges[field] = charges[field]!.map((charge) => {
56
+ const name = cutText(charge.name, PAYLOAD_STRING_MAX);
57
+ changed ||= name !== charge.name;
58
+ return { ...charge, name };
47
59
  });
48
60
  const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({ ...line,
49
61
  discounts: line.discounts.map((discount) => {
@@ -53,6 +65,11 @@ export function freezeSentForm(order: PosOrder): PosOrder {
53
65
  return { ...discount, label };
54
66
  }),
55
67
  })) };
68
+ for (const field of ['fees', 'shipping'] as const) if (display?.[field]) display[field] = display[field]!.map((row) => {
69
+ const name = cutText(row.name, PAYLOAD_STRING_MAX);
70
+ changed ||= name !== row.name;
71
+ return { ...row, name };
72
+ });
56
73
  const payments = order.payments.map((payment) => {
57
74
  if (payment.reference === undefined) return payment;
58
75
  const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
@@ -67,7 +84,8 @@ export function freezeSentForm(order: PosOrder): PosOrder {
67
84
  if (!localWarnings.some((warning) => warning.code === 'customer_omitted' && warning.field === field)) localWarnings.push({ code: 'customer_omitted', field });
68
85
  }
69
86
  }
70
- return changed ? { ...order, lines, payments, customer, ...(display ? { display } : {}), ...(localWarnings.length ? { localWarnings } : {}) } : order;
87
+ return changed ? { ...order, lines, payments, customer, ...(charges.fees ? { fees: charges.fees } : {}),
88
+ ...(charges.shipping ? { shipping: charges.shipping } : {}), ...(display ? { display } : {}), ...(localWarnings.length ? { localWarnings } : {}) } : order;
71
89
  }
72
90
 
73
91
  /**
@@ -94,6 +112,10 @@ 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 {
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");
118
+ }
97
119
  if (!order.lineItems.length) throw new Error('finalize: no lines');
98
120
  // Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
99
121
  if (order.discountMinor < 0
@@ -136,14 +158,20 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
136
158
  }
137
159
  const newId = options.newId ?? uuidv7;
138
160
  const id = newId();
139
- const lines = order.lineItems.map((line) => ({
161
+ const lines: PosOrder['lines'] = order.lineItems.map((line) => ({
140
162
  id: newId(), productId: line.productId,
141
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 } : {}),
142
167
  name: line.name, sku: line.sku, quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
143
168
  discountMinor: line.discountMinor, netMinor: line.netMinor,
169
+ ...(order.taxRounding?.granularity === 'woocommerce' ? { netMicros: line.netMicros } : {}),
144
170
  taxLines: line.taxLines.map((tax) => ({ ...tax })),
145
171
  ...(line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}),
146
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 })) }));
147
175
  const payments: PosOrderPayment[] = order.payments.map((payment) => ({
148
176
  id: newId(), method: payment.method as PosOrderPayment['method'], amountMinor: payment.amountMinor,
149
177
  ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
@@ -179,6 +207,8 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
179
207
  throw new Error('finalize: display lines do not match the order lines');
180
208
  }
181
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 })) } : {}),
182
212
  lines: order.display.lines.map((line, i) => ({
183
213
  lineId: lines[i].id, amountMinor: line.amountMinor,
184
214
  discounts: line.discounts.map(({ discountId, label, amountMinor }) => ({
@@ -186,7 +216,9 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
186
216
  })),
187
217
  })),
188
218
  };
189
- taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, undefined, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
219
+ const taxedLines = [...order.lineItems, ...[...(order.fees ?? []), ...(order.shipping ?? [])]
220
+ .map((charge) => ({ ...charge, taxInclusive: order.pricesIncludeTax }))];
221
+ taxByRate = taxLinesByRate(taxedLines, order.taxMinor, undefined, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
190
222
  ratePpm, ...(code !== undefined ? { code } : {}), netMinor, amountMinor, grossMinor: netMinor + amountMinor,
191
223
  }));
192
224
  if (display.totalMinor !== order.totalMinor || display.taxMinor !== order.taxMinor
@@ -201,11 +233,13 @@ export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosO
201
233
  // absent on the snapshot is the default the figures used; `custom` computes as the default but records itself.
202
234
  const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
203
235
  const taxRounding: TaxRounding = rounding.granularity === 'custom' ? { granularity: 'custom' }
236
+ : rounding.granularity === 'woocommerce' ? { granularity: 'woocommerce', roundAtSubtotal: rounding.roundAtSubtotal }
204
237
  : { granularity: rounding.granularity, mode: rounding.mode };
205
238
  const now = (options.now ?? new Date()).toISOString();
206
239
  return freezeSentForm({
207
240
  id, saleId: order.id, createdAt: now, updatedAt: now, commandId: newId(), syncStatus: 'pending',
208
241
  currency: order.currency, pricesIncludeTax: order.pricesIncludeTax, lines, payments,
242
+ ...(fees?.length ? { fees } : {}), ...(shipping?.length ? { shipping } : {}),
209
243
  subtotalMinor: order.subtotalMinor, discountMinor: order.discountMinor,
210
244
  taxMinor: order.taxMinor, totalMinor: order.totalMinor, taxRounding,
211
245
  ...(display && taxByRate ? { display, taxByRate } : {}),
@@ -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: 7, primaryKey: 'id', type: 'object', additionalProperties: false,
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: 4 },
77
- downgradedFrom: { type: 'integer', minimum: 1, maximum: 4 },
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
  }
@@ -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,11 @@ 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 taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
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) => ({
10
+ ...charge, taxInclusive: order.pricesIncludeTax,
11
+ }))];
12
+ const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
9
13
  ({ label, code, ratePpm, amountMinor }) => ({ label, code, ratePpm, amountMinor }),
10
14
  );
11
15
 
@@ -15,6 +19,7 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
15
19
  let taxSoFar = order.lineItems.reduce(
16
20
  (sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros), 0n);
17
21
  const lineTotals = order.lineItems.map((li) => {
22
+ if (order.taxRounding?.granularity === 'woocommerce' && li.netMicros !== undefined) return order.pricesIncludeTax ? li.totalMinor! : li.netMinor;
18
23
  if (!li.priceTaxModeConverted) return li.netMinor;
19
24
  const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
20
25
  taxSoFar += BigInt(li.taxMicros);
@@ -48,6 +53,8 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
48
53
  label: label(d),
49
54
  amountMinor: d.amountMinor,
50
55
  })),
56
+ fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
57
+ shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
51
58
  orderDiscountMinor: order.display.orderDiscountMinor,
52
59
  totals: {
53
60
  taxInclusive: order.display.taxInclusive,
@@ -21,6 +21,8 @@ export interface ReceiptData {
21
21
  register?: string;
22
22
  };
23
23
  lineItems: ReceiptLineItem[];
24
+ fees: Array<{ name: string; amountMinor: number }>;
25
+ shipping: Array<{ name: string; amountMinor: number }>;
24
26
  discounts: { label: string; amountMinor: number }[];
25
27
  orderDiscountMinor: number; // the order discounts as one row, in the display mode; + Σ displayDiscounts = totals.discountMinor
26
28
  totals: {
@@ -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
- row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
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,8 +481,8 @@ 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 { granularity, mode } = recorded.granularity === 'custom' ? DEFAULT_TAX_ROUNDING : recorded;
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
  for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, undefined, order.taxRounding)) {
488
488
  const existing = taxRates.get(String(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
  }
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
4
- import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
5
- import { finalizeOrder, type PosOrder } from '../pos-order';
4
+ import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type ChargeInput, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
5
+ import { finalizeOrder, uuidv7, type PosOrder } from '../pos-order';
6
6
  import { MESSAGE_NAME_MAX, referenceError, referenceReason, withSentForm } from '../pos-order/finalize';
7
7
  import { CUSTOMER_REFUSALS, customerRefusal, cutText, PAYLOAD_STRING_MAX } from '../pos-order/command';
8
8
  import { useTax } from '../tax';
@@ -137,12 +137,12 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
137
137
  }, [rendered]);
138
138
  // New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale keeping its customer:
139
139
  // a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
140
- const idle = stage.kind === 'cart' && !order.lineItems.length;
140
+ const idle = stage.kind === 'cart' && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
141
141
  // A layout effect, on live idleness (#301): it runs inside the settings' commit, before any tap can be handled, and
142
142
  // the rendered `idle` would miss a line added since this render (it would then be dropped with the old builder).
143
143
  useLayoutEffect(() => {
144
144
  const live = builderNow.current.getSnapshot();
145
- const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !inFlight.current && !pending.current;
145
+ const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
146
146
  if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
147
147
  });
148
148
  // A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
@@ -275,6 +275,50 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
275
275
  }
276
276
  },
277
277
  setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
278
+ /** ADR-075: adds a fee; returns its id or the builder's refusal. */
279
+ addFee(input: ChargeInput): { id: string } | string {
280
+ if (locked()) return SALE_SAVING;
281
+ try { return { id: builderNow.current.addFee(input) }; }
282
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
283
+ },
284
+ /** ADR-075: updates a fee; returns a refusal or null. */
285
+ updateFee(id: string, patch: Partial<ChargeInput>): string | null {
286
+ if (locked()) return SALE_SAVING;
287
+ try { builderNow.current.updateFee(id, patch); return null; }
288
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
289
+ },
290
+ /** ADR-075: removes a fee; returns a refusal or null. */
291
+ removeFee(id: string): string | null {
292
+ if (locked()) return SALE_SAVING;
293
+ try { builderNow.current.removeFee(id); return null; }
294
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
295
+ },
296
+ /** ADR-075: adds shipping; returns its id or the builder's refusal. */
297
+ addShipping(input: ChargeInput & { methodId?: string }): { id: string } | string {
298
+ if (locked()) return SALE_SAVING;
299
+ try { return { id: builderNow.current.addShipping(input) }; }
300
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
301
+ },
302
+ /** ADR-075: updates shipping; returns a refusal or null. */
303
+ updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): string | null {
304
+ if (locked()) return SALE_SAVING;
305
+ try { builderNow.current.updateShipping(id, patch); return null; }
306
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
307
+ },
308
+ /** ADR-075: removes shipping; returns a refusal or null. */
309
+ removeShipping(id: string): string | null {
310
+ if (locked()) return SALE_SAVING;
311
+ try { builderNow.current.removeShipping(id); return null; }
312
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
313
+ },
314
+ /** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
315
+ addCustomLine(input: { name: string; priceMinor: number; quantity?: number; taxClass?: string; taxStatus?: 'taxable' | 'none'; sku?: string }): { id: string } | string {
316
+ if (locked()) return SALE_SAVING;
317
+ try {
318
+ return { id: builderNow.current.addLine({ ...input, custom: true, productId: `custom:${uuidv7()}`,
319
+ unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() } }) };
320
+ } catch (error) { if (error instanceof Error) return error.message; throw error; }
321
+ },
278
322
  /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
279
323
  setUnitPrice(lineId: string, amountMinor: number): string | null {
280
324
  if (locked()) return SALE_SAVING;
@@ -435,7 +479,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
435
479
  if (stageNow.current.kind !== 'cart') return 'Finish or cancel the payment before parking the sale';
436
480
  const builder = builderNow.current;
437
481
  const snapshot = builder.getSnapshot();
438
- if (!snapshot.lineItems.length) return 'There is nothing to park';
482
+ if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return 'There is nothing to park';
439
483
  await writeOrderDraft(opts.drafts, snapshot);
440
484
  if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return 'The sale changed while it was being parked; park it again';
441
485
  resetSale(null);
@@ -447,7 +491,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
447
491
  if (!opts.drafts) throw new Error('useSale: resume() needs the drafts option');
448
492
  const live = builderNow.current;
449
493
  const liveSnapshot = live.getSnapshot();
450
- if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length) return 'Park or clear the current sale first';
494
+ if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return 'Park or clear the current sale first';
451
495
  const doc = await opts.drafts.findOne(draftId).exec();
452
496
  if (!doc) return 'That parked sale is no longer there';
453
497
  const saved: Order = JSON.parse(doc.toJSON().data);
@@ -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';
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import type { ReactNode } from 'react';
3
- import type { TaxRounding } from '@tallyui/core';
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, 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.
@@ -44,7 +47,7 @@ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, c
44
47
  ...(rounding ? { rounding } : {}),
45
48
  };
46
49
  },
47
- [ratesPpm, pricesIncludeTax, rounding, rateCodes],
50
+ [ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates],
48
51
  );
49
52
 
50
53
  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,7 @@ export type TaxRateMap = {
7
9
  };
8
10
 
9
11
  export interface TaxContext {
12
+ getTaxRates?(taxClass?: string, options?: { shipping?: boolean }): readonly WooRate[];
10
13
  /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
11
14
  getTaxRatePpm(taxClass?: string): number;
12
15
  /** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */