@tallyui/pos 0.1.0 → 3.0.0-next.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2031 -43
  3. package/dist/index.js +4629 -205
  4. package/package.json +23 -12
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +53 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +8 -0
  10. package/src/order/order-builder.ts +256 -95
  11. package/src/order/order-manager.ts +26 -43
  12. package/src/order/tax-figures.ts +23 -0
  13. package/src/order/types.ts +73 -15
  14. package/src/outbox/backend-not-found.ts +27 -0
  15. package/src/outbox/http-transport.ts +67 -0
  16. package/src/outbox/index.ts +9 -0
  17. package/src/outbox/logger.ts +3 -0
  18. package/src/outbox/order-outbox.ts +502 -0
  19. package/src/outbox/register-outbox.ts +250 -0
  20. package/src/outbox/types.test-d.ts +22 -0
  21. package/src/outbox/types.ts +36 -0
  22. package/src/outbox/use-order-outbox.ts +175 -0
  23. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  24. package/src/pos-order/command.ts +123 -0
  25. package/src/pos-order/device-id.ts +23 -0
  26. package/src/pos-order/finalize.ts +219 -0
  27. package/src/pos-order/index.ts +10 -0
  28. package/src/pos-order/needs-attention.ts +16 -0
  29. package/src/pos-order/open.ts +269 -0
  30. package/src/pos-order/same-sale.ts +26 -0
  31. package/src/pos-order/schema.ts +130 -0
  32. package/src/pos-order/types.ts +93 -0
  33. package/src/pos-order/uuidv7.ts +54 -0
  34. package/src/product/index.ts +2 -0
  35. package/src/product/search-products.ts +32 -0
  36. package/src/product/stock.ts +25 -0
  37. package/src/receipt/build-receipt-data.ts +39 -23
  38. package/src/receipt/types.ts +17 -10
  39. package/src/register/__fixtures__/closure-local-row.json +61 -0
  40. package/src/register/__fixtures__/closure.json +197 -0
  41. package/src/register/__fixtures__/corrections-dst.json +63 -0
  42. package/src/register/closure-document.ts +277 -0
  43. package/src/register/closure-rows.ts +65 -0
  44. package/src/register/document-labels.ts +46 -0
  45. package/src/register/expected.ts +66 -0
  46. package/src/register/export-csv.ts +74 -0
  47. package/src/register/facts.ts +109 -0
  48. package/src/register/index.ts +32 -0
  49. package/src/register/money.ts +18 -0
  50. package/src/register/movement-input.ts +59 -0
  51. package/src/register/register-commands.ts +147 -0
  52. package/src/register/register-count.denominations.ts +11 -0
  53. package/src/register/register-count.helpers.ts +64 -0
  54. package/src/register/register-document.ts +268 -0
  55. package/src/register/schemas.ts +175 -0
  56. package/src/register/session-store.ts +570 -0
  57. package/src/register/settled-figures.ts +98 -0
  58. package/src/register/use-register-session.ts +469 -0
  59. package/src/rxdb/index.ts +2 -0
  60. package/src/sale/cart.ts +23 -0
  61. package/src/sale/catalogue.ts +21 -0
  62. package/src/sale/index.ts +5 -0
  63. package/src/sale/use-sale.ts +394 -0
  64. package/src/store-settings/index.ts +5 -0
  65. package/src/store-settings/map-store-settings.ts +18 -0
  66. package/src/store-settings/resolve-store-settings.ts +65 -0
  67. package/src/store-settings/use-store-settings.ts +84 -0
  68. package/src/tax/exact.ts +218 -0
  69. package/src/tax/index.ts +4 -3
  70. package/src/tax/tax-provider.tsx +35 -12
  71. package/src/tax/types.ts +8 -6
  72. package/src/tender/index.ts +2 -0
  73. package/src/tender/tender-state.ts +326 -0
  74. package/src/currency/currency-provider.test.tsx +0 -36
  75. package/src/currency/format-currency.test.ts +0 -36
  76. package/src/currency/format-currency.ts +0 -18
  77. package/src/logging/logger.test.ts +0 -154
  78. package/src/logging/sinks.test.ts +0 -60
  79. package/src/order/discount-engine.test.ts +0 -152
  80. package/src/order/order-builder.test.ts +0 -246
  81. package/src/order/order-manager.test.ts +0 -209
  82. package/src/order/payment.test.ts +0 -91
  83. package/src/receipt/build-receipt-data.test.ts +0 -166
  84. package/src/repository/create-repository.test.ts +0 -166
  85. package/src/tax/calculate.test.ts +0 -59
  86. package/src/tax/calculate.ts +0 -32
  87. package/src/tax/tax-provider.test.tsx +0 -51
@@ -1,7 +1,9 @@
1
1
  import { BehaviorSubject, type Observable } from 'rxjs';
2
- import type { ProductTraits } from '@tallyui/core';
2
+ import { resolvePrice, type ProductTraits, type TaxRounding } from '@tallyui/core';
3
3
  import type { TaxContext } from '../tax/types';
4
- import { calculateTax } from '../tax/calculate';
4
+ import { taxMicros, roundMicrosToMinor, roundedTaxByRate } from '../tax/exact';
5
+ import { taxLogger } from '../tax/tax-provider';
6
+ import { allocateOrderDiscount } from './allocate-order-discount';
5
7
  import type {
6
8
  Order,
7
9
  LineItem,
@@ -9,13 +11,55 @@ import type {
9
11
  AppliedDiscount,
10
12
  Payment,
11
13
  CustomerSummary,
14
+ AddLineInput,
12
15
  } from './types';
13
16
 
17
+ /** Tax contexts already warned that per_rate_group_items fell back to per_order for inclusive lines (#287). */
18
+ const perRateGroupWarned = new WeakSet<TaxContext>();
19
+
14
20
  let nextId = 0;
15
21
  function uid(): string {
16
22
  return `${Date.now()}-${++nextId}`;
17
23
  }
18
24
 
25
+ function roundHalfAway(n: number): number {
26
+ return Math.sign(n) * Math.round(Math.abs(n));
27
+ }
28
+
29
+ /** 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 } {
31
+ // 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
+ const rounded = roundedTaxByRate(lines, rounding);
33
+ if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
34
+ const mode = rounding?.granularity === 'per_order' ? rounding.mode : undefined;
35
+ const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
36
+ const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
37
+ const exclusiveTaxMicros = lines.reduce((sum, li) => li.taxInclusive ? sum : sum + BigInt(li.taxMicros), 0n);
38
+ const taxMinor = roundMicrosToMinor(lineTaxMicros, mode);
39
+ // Each line pays in its own mode: an inclusive line its gross, an exclusive line its net plus tax.
40
+ // The totals sum the lines as recalculated; nothing is subtracted after tax.
41
+ const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros, mode);
42
+ return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
43
+ }
44
+
45
+ /** An own-mode amount on a line, in the display mode, converted on its own at the line's rates (ADR-063). */
46
+ function toDisplayMode(line: LineItem, amountMinor: number, displayInclusive: boolean): number {
47
+ if (line.taxInclusive === displayInclusive) return amountMinor;
48
+ const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
49
+ const taxMinor = roundMicrosToMinor(taxMicros(amountMinor, ratePpm, line.taxInclusive));
50
+ return line.taxInclusive ? amountMinor - taxMinor : amountMinor + taxMinor;
51
+ }
52
+
53
+ /**
54
+ * The display residue's guard (ADR-063); throws when the arithmetic is wrong rather than rounded. With no converted
55
+ * line it's exactly 0 (Σ gross = Σ net + Σ discounts). Otherwise each of the n non-zero rounded conversions is off by
56
+ * at most half a cent, and the order-level tax rounding by at most one more, so |residue| ≤ ⌊n/2⌋ + 1.
57
+ */
58
+ export function assertDisplayResidue(residue: number, convertedLines: number, conversions: number): void {
59
+ const bound = convertedLines === 0 ? 0 : Math.floor(conversions / 2) + 1;
60
+ if (Math.abs(residue) > bound) throw new Error(`Display residue ${residue} exceeds its bound of ${bound} (ADR-063)`);
61
+ }
62
+
19
63
  export interface OrderBuilderOptions {
20
64
  currency: string;
21
65
  taxContext: TaxContext;
@@ -25,6 +69,7 @@ export interface OrderBuilderOptions {
25
69
  export interface OrderBuilder {
26
70
  order$: Observable<Order>;
27
71
  addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
72
+ addLine(input: AddLineInput): string;
28
73
  updateQuantity(lineId: string, quantity: number): void;
29
74
  removeItem(lineId: string): void;
30
75
  applyLineDiscount(lineId: string, discount: Discount): void;
@@ -39,7 +84,8 @@ export interface OrderBuilder {
39
84
  }
40
85
 
41
86
  export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
42
- const { currency, taxContext } = options;
87
+ const { taxContext } = options;
88
+ const currency = options.currency.toUpperCase();
43
89
  const orderId = options.id ?? uid();
44
90
 
45
91
  let lineItems: LineItem[] = [];
@@ -51,81 +97,161 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
51
97
 
52
98
  const subject = new BehaviorSubject<Order>(buildOrder());
53
99
 
54
- function recalculateLine(line: LineItem): LineItem {
55
- const gross = line.price * line.quantity;
56
-
57
- // Recompute discount amounts from current gross
58
- const recalcedDiscounts = line.discounts.map((d) => ({
59
- ...d,
60
- amount: computeDiscountAmount(d, gross),
61
- }));
62
-
63
- const discountAmount = recalcedDiscounts.reduce((sum, d) => sum + d.amount, 0);
64
- const afterDiscount = gross - discountAmount;
65
- const tax = calculateTax(afterDiscount, line.taxRate, taxContext.pricesIncludeTax);
100
+ /** Stored lines carry no order share; buildOrder recalculates each with its allocated share (ADR-062). */
101
+ function recalculateLine(line: LineItem, orderDiscountMinor = 0): LineItem {
102
+ const grossMinor = line.unitPriceMinor * line.quantity;
103
+
104
+ // Each amount is what that discount actually removed, capped at what's left, matching buildOrder's order-discount path (ADR-062).
105
+ let remaining = grossMinor;
106
+ const recalcedDiscounts = line.discounts.map((d) => {
107
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === 'percentage' ? grossMinor : remaining));
108
+ remaining -= amountMinor;
109
+ return { ...d, amountMinor };
110
+ });
111
+
112
+ // The order share never exceeds what the line discounts leave (allocateOrderDiscount's bound).
113
+ const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
114
+ const discountMinor = lineDiscountMinor + orderDiscountMinor;
115
+ const netMinor = grossMinor - discountMinor;
116
+ const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
117
+ // Tax in the line's own mode, from its own unit price × quantity: never re-tax a rounded base.
118
+ const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
119
+ let allocatedTax = 0n;
120
+ const taxLines = line.taxLines.map((tax, index) => {
121
+ const micros = line.taxInclusive
122
+ ? index === line.taxLines.length - 1
123
+ ? inclusiveTax - allocatedTax
124
+ : combinedRate === 0 ? 0n : inclusiveTax * BigInt(tax.ratePpm) / BigInt(combinedRate)
125
+ : taxMicros(netMinor, tax.ratePpm, false);
126
+ allocatedTax += micros;
127
+ return { ...tax, taxMicros: micros.toString() };
128
+ });
66
129
 
67
130
  return {
68
131
  ...line,
69
132
  discounts: recalcedDiscounts,
70
- discountAmount,
71
- taxAmount: tax.taxAmount,
72
- lineTotal: taxContext.pricesIncludeTax ? afterDiscount : afterDiscount + tax.taxAmount,
133
+ discountMinor,
134
+ orderDiscountMinor,
135
+ netMinor,
136
+ taxLines,
137
+ taxMicros: allocatedTax.toString(),
73
138
  };
74
139
  }
75
140
 
76
141
  function computeDiscountAmount(discount: Discount, base: number): number {
142
+ // Clamped here so both line and order discounts can never go negative (a negative
143
+ // percentage or fixed value would otherwise raise the price instead of lowering it).
77
144
  if (discount.type === 'percentage') {
78
- return Math.round((base * discount.value) / 100 * 100) / 100;
145
+ return Math.max(0, roundHalfAway(base * discount.value / 100));
79
146
  }
80
- return Math.min(discount.value, base);
147
+ if (!Number.isInteger(discount.value)) throw new RangeError('Fixed discount must be integer minor units');
148
+ return Math.max(0, Math.min(discount.value, base));
81
149
  }
82
150
 
83
151
  function buildOrder(): Order {
84
- const subtotalLines = lineItems.reduce((sum, li) => {
85
- const lineGross = li.price * li.quantity - li.discountAmount;
86
- if (taxContext.pricesIncludeTax) {
87
- const tax = calculateTax(lineGross, li.taxRate, true);
88
- return sum + tax.priceExclTax;
152
+ // Order discounts are pre-tax (ADR-062). The base is the lines' pre-order-discount amounts, each in its
153
+ // own mode (an inclusive line's shelf amount, an exclusive line's net). A percentage discount is additive:
154
+ // each is computed on that base, not on what an earlier order discount leaves (WCPOS `next` / WooCommerce
155
+ // parity). A fixed discount is computed on what remains, as before. Every discount is then capped at what
156
+ // remains, which decreases as each is applied, and the total is allocated to the lines, which are then
157
+ // taxed on what is left.
158
+ const lineAmounts = lineItems.map((li) => li.netMinor);
159
+ const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
160
+ let remaining = base;
161
+ const recalcedOrderDiscounts = orderDiscounts.map((d) => {
162
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === 'percentage' ? base : remaining));
163
+ remaining -= amountMinor;
164
+ return { ...d, amountMinor };
165
+ });
166
+ const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
167
+ const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
168
+
169
+ const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
170
+ if (taxContext.rounding?.granularity === 'per_rate_group_items' && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
171
+ perRateGroupWarned.add(taxContext);
172
+ taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
173
+ }
174
+ const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
175
+ // Display figures (ADR-063): every discount the cashier entered, and each line's order share, converted on its own
176
+ // into the display mode. A line in the display mode shows quantity × unit price; a converted line shows its
177
+ // converted remaining (netMinor) plus its rows and share, so it's never less than them. The subtotal is derived
178
+ // from the settlement total, so every sum is exact; the rounding residue goes to the converted lines' amounts,
179
+ // never to a discount or an unconverted line.
180
+ const taxInclusive = taxContext.pricesIncludeTax;
181
+ const figures = lines.map((li) => {
182
+ // A negative-priced (return) line shows what settlement charges for it (recalculateLine caps it at 0 today),
183
+ // with no rows: its capped "discounts" are the negative gross cancelled, not money off.
184
+ const returned = li.unitPriceMinor < 0;
185
+ const discounts = returned ? [] : li.discounts.map((d) => ({
186
+ discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive),
187
+ }));
188
+ const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
189
+ const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
190
+ const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
191
+ : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
192
+ return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
193
+ });
194
+ const displayLines = figures.map((f) => f.line);
195
+ const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
196
+ const displayDiscount = displayLines.reduce(
197
+ (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
198
+ const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
199
+ const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
200
+ const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
201
+ const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
202
+ ...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
203
+ assertDisplayResidue(residue, converted.length, conversions);
204
+ // Largest converted remaining first (ties to the earlier line). A negative residue takes a line down to its rows
205
+ // and share at most, then moves on: six 2-cent inclusive lines can owe −3 with no line above 2. The spill is a
206
+ // crash guard; it never fired in a 200k-cart fuzz. Negative-priced (return) lines convert as they are and never
207
+ // take residue; refunds get their own design.
208
+ let left = residue;
209
+ const takers = converted.filter((index) => !figures[index].returned);
210
+ for (const index of takers.sort((a, b) => figures[b].remainingMinor - figures[a].remainingMinor || a - b)) {
211
+ if (left === 0) break;
212
+ const take = left > 0 ? left : Math.max(left, -figures[index].remainingMinor);
213
+ displayLines[index].amountMinor += take;
214
+ left -= take;
215
+ }
216
+ if (left !== 0) throw new Error(`Display residue ${left} left over with no converted line to take it (ADR-063)`);
217
+ // Checked here, not only in tests: every figure >= 0, and no line shows less than its own rows and share (lines
218
+ // with a non-negative gross; a return line's negative amount is its own).
219
+ if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
220
+ for (const { line, shareMinor, returned } of figures) {
221
+ if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
222
+ if (returned) continue;
223
+ const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
224
+ if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
225
+ throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
89
226
  }
90
- return sum + lineGross;
91
- }, 0);
92
-
93
- const subtotal = Math.round(subtotalLines * 100) / 100;
94
-
95
- const recalcedOrderDiscounts = orderDiscounts.map((d) => ({
96
- ...d,
97
- amount: computeDiscountAmount(d, subtotal),
98
- }));
99
-
100
- const discountTotal =
101
- lineItems.reduce((sum, li) => sum + li.discountAmount, 0) +
102
- recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
103
-
104
- const taxTotal = lineItems.reduce((sum, li) => sum + li.taxAmount, 0);
105
-
106
- const total = lineItems.reduce((sum, li) => sum + li.lineTotal, 0) -
107
- recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
108
-
109
- const roundedTotal = Math.round(total * 100) / 100;
110
- const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
111
- const balanceDue = Math.max(0, Math.round((roundedTotal - paidAmount) * 100) / 100);
112
- const changeDue = Math.max(0, Math.round((paidAmount - roundedTotal) * 100) / 100);
227
+ }
228
+ const display = {
229
+ taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
230
+ lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
231
+ };
232
+ const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
233
+ const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
234
+ const changeDueMinor = Math.max(0, paidMinor - totalMinor);
113
235
 
114
236
  return {
115
237
  id: orderId,
116
238
  status: 'draft',
117
- lineItems: [...lineItems],
239
+ lineItems: lines,
118
240
  discounts: recalcedOrderDiscounts,
119
241
  payments: [...payments],
120
242
  customer,
121
243
  note,
122
- subtotal,
123
- discountTotal: Math.round(discountTotal * 100) / 100,
124
- taxTotal: Math.round(taxTotal * 100) / 100,
125
- total: roundedTotal,
126
- balanceDue,
127
- changeDue,
244
+ subtotalMinor,
245
+ discountMinor,
246
+ taxMinor,
247
+ totalMinor,
248
+ display,
249
+ paidMinor,
250
+ balanceDueMinor,
251
+ changeDueMinor,
128
252
  currency,
253
+ pricesIncludeTax: taxContext.pricesIncludeTax,
254
+ ...(taxContext.rounding ? { taxRounding: taxContext.rounding } : {}),
129
255
  createdAt: now,
130
256
  updatedAt: new Date().toISOString(),
131
257
  };
@@ -135,51 +261,86 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
135
261
  subject.next(buildOrder());
136
262
  }
137
263
 
264
+ function addLine(input: AddLineInput): string {
265
+ const { productId, variantId, unitPrice } = input;
266
+ const quantity = input.quantity ?? 1;
267
+ if (unitPrice.currency !== currency) throw new RangeError('Line currency must match ' + currency);
268
+ if (!Number.isInteger(unitPrice.amount)) throw new RangeError('Price must be integer minor units');
269
+ if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
270
+ // A rate from the tax class carries the backend's name for it when one is mapped (#287, #288).
271
+ const code = input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
272
+ const taxRates = input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
273
+ const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
274
+
275
+ const existing = lineItems.find(
276
+ (li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
277
+ && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
278
+ && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
279
+ );
280
+
281
+ if (existing) {
282
+ lineItems = lineItems.map((li) =>
283
+ li.id === existing.id ? recalculateLine({ ...li, quantity: li.quantity + quantity }) : li,
284
+ );
285
+ emit();
286
+ return existing.id;
287
+ }
288
+
289
+ const lineId = uid();
290
+ const line: LineItem = {
291
+ id: lineId,
292
+ productId,
293
+ variantId,
294
+ name: input.name,
295
+ sku: input.sku ?? '',
296
+ imageUrl: input.imageUrl,
297
+ unitPriceMinor: unitPrice.amount,
298
+ quantity,
299
+ taxLines: taxRates.map((tax) => ({ ...tax, taxMicros: '0' })),
300
+ discounts: [],
301
+ discountMinor: 0,
302
+ orderDiscountMinor: 0,
303
+ netMinor: 0,
304
+ taxMicros: '0',
305
+ taxInclusive,
306
+ // TV4b derives the store-level flag with Medusa's own precedence (region
307
+ // preference, then currency), so a disagreement should be rare. It can
308
+ // still happen, for example when a price preference changes after the
309
+ // settings were read. Keeping the line in its own mode means the customer
310
+ // pays exactly the shelf price, as Medusa's checkout would.
311
+ ...(taxInclusive !== taxContext.pricesIncludeTax
312
+ ? { priceTaxModeConverted: taxInclusive ? 'inclusive-to-exclusive' as const : 'exclusive-to-inclusive' as const }
313
+ : {}),
314
+ };
315
+
316
+ lineItems = [...lineItems, recalculateLine(line)];
317
+ emit();
318
+ return lineId;
319
+ }
320
+
138
321
  return {
139
322
  order$: subject.asObservable(),
323
+ addLine,
140
324
 
141
325
  addProduct(doc, traits, opts) {
142
326
  const productId = traits.getId(doc);
143
- const variantId = opts?.variantId;
144
- const quantity = opts?.quantity ?? 1;
145
-
146
- const existing = lineItems.find(
147
- (li) => li.productId === productId && li.variantId === variantId,
148
- );
149
-
150
- if (existing) {
151
- existing.quantity += quantity;
152
- lineItems = lineItems.map((li) =>
153
- li.id === existing.id ? recalculateLine(existing) : li,
154
- );
155
- emit();
156
- return existing.id;
157
- }
158
-
159
- const priceStr = traits.getPrice(doc);
160
- const price = priceStr ? parseFloat(priceStr) : 0;
161
- const taxRate = taxContext.getTaxRate();
162
-
163
- const lineId = uid();
164
- const line: LineItem = {
165
- id: lineId,
327
+ if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
328
+ const variant = opts?.variantId !== undefined && traits.getVariants
329
+ ? traits.getVariants(doc, { currency }).find((variant) => variant.id === opts.variantId) : undefined;
330
+ if (opts?.variantId !== undefined && traits.getVariants && !variant)
331
+ throw new Error(`Unknown variant ${opts.variantId} for product ${productId}`);
332
+ const unitPrice = resolvePrice(variant ? variant.prices : traits.getPrices(doc, { currency }), currency)?.current;
333
+ if (!unitPrice) throw new Error('No price in ' + currency + ' for product ' + productId);
334
+ return addLine({
166
335
  productId,
167
- variantId,
336
+ variantId: opts?.variantId,
168
337
  name: traits.getName(doc),
169
- sku: traits.getSku(doc) ?? '',
338
+ sku: variant?.sku ?? traits.getSku(doc),
170
339
  imageUrl: traits.getImageUrl(doc),
171
- price,
172
- quantity,
173
- taxRate,
174
- taxAmount: 0,
175
- discounts: [],
176
- discountAmount: 0,
177
- lineTotal: 0,
178
- };
179
-
180
- lineItems = [...lineItems, recalculateLine(line)];
181
- emit();
182
- return lineId;
340
+ unitPrice,
341
+ quantity: opts?.quantity,
342
+ taxClass: traits.getTaxClass?.(doc, opts?.variantId),
343
+ });
183
344
  },
184
345
 
185
346
  updateQuantity(lineId, quantity) {
@@ -202,11 +363,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
202
363
  applyLineDiscount(lineId, discount) {
203
364
  lineItems = lineItems.map((li) => {
204
365
  if (li.id !== lineId) return li;
205
- const base = li.price * li.quantity;
366
+ const base = li.unitPriceMinor * li.quantity;
206
367
  const applied: AppliedDiscount = {
207
368
  ...discount,
208
369
  id: uid(),
209
- amount: computeDiscountAmount(discount, base),
370
+ amountMinor: computeDiscountAmount(discount, base),
210
371
  };
211
372
  return recalculateLine({ ...li, discounts: [...li.discounts, applied] });
212
373
  });
@@ -217,7 +378,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
217
378
  const applied: AppliedDiscount = {
218
379
  ...discount,
219
380
  id: uid(),
220
- amount: 0,
381
+ amountMinor: computeDiscountAmount(discount, 0),
221
382
  };
222
383
  orderDiscounts = [...orderDiscounts, applied];
223
384
  emit();
@@ -1,6 +1,7 @@
1
1
  import { BehaviorSubject, map, type Observable } from 'rxjs';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import type { TaxContext } from '../tax/types';
4
+ import { customerRefusal } from '../pos-order/command';
4
5
  import { createOrderBuilder, type OrderBuilder } from './order-builder';
5
6
  import type { Order } from './types';
6
7
 
@@ -8,7 +9,7 @@ export interface ParkedOrderSummary {
8
9
  id: string;
9
10
  customerName?: string;
10
11
  itemCount: number;
11
- total: number;
12
+ totalMinor: number;
12
13
  parkedAt: string;
13
14
  source: 'local' | 'server';
14
15
  }
@@ -29,7 +30,8 @@ export interface OrderManager {
29
30
  }
30
31
 
31
32
  export function createOrderManager(options: OrderManagerOptions): OrderManager {
32
- const { currency, taxContext, draftsCollection } = options;
33
+ const { taxContext, draftsCollection } = options;
34
+ const currency = options.currency.toUpperCase();
33
35
 
34
36
  let activeBuilder = createOrderBuilder({ currency, taxContext });
35
37
  const activeSubject = new BehaviorSubject<OrderBuilder>(activeBuilder);
@@ -42,7 +44,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
42
44
  id: json.id,
43
45
  customerName: json.customerName || undefined,
44
46
  itemCount: json.itemCount ?? 0,
45
- total: json.total ?? 0,
47
+ totalMinor: json.total ?? 0,
46
48
  parkedAt: json.parkedAt ?? '',
47
49
  source: 'local' as const,
48
50
  };
@@ -67,7 +69,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
67
69
  data: JSON.stringify(snapshot),
68
70
  customerName: snapshot.customer?.name ?? '',
69
71
  itemCount: snapshot.lineItems.length,
70
- total: snapshot.total,
72
+ total: snapshot.totalMinor,
71
73
  parkedAt: new Date().toISOString(),
72
74
  });
73
75
 
@@ -90,54 +92,33 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
90
92
  });
91
93
 
92
94
  // Restore state from saved order
93
- if (savedOrder.customer) {
95
+ // A customer whose email or id order.create would refuse (see useSale's setCustomer) isn't restored.
96
+ if (savedOrder.customer && !customerRefusal(savedOrder.customer)) {
94
97
  builder.setCustomer(savedOrder.customer);
95
98
  }
96
99
  if (savedOrder.note) {
97
100
  builder.setNote(savedOrder.note);
98
101
  }
99
102
 
100
- // Restore line items using synthetic traits
103
+ // Restore line items and their discounts
101
104
  for (const line of savedOrder.lineItems) {
102
- const syntheticTraits = {
103
- getId: (d: any) => d.productId,
104
- getName: (d: any) => d.name,
105
- getSku: (d: any) => d.sku,
106
- getPrice: (d: any) => String(d.price),
107
- getRegularPrice: (d: any) => String(d.price),
108
- getSalePrice: () => undefined,
109
- isOnSale: () => false,
110
- getImageUrl: (d: any) => d.imageUrl,
111
- getImageUrls: (d: any) => (d.imageUrl ? [d.imageUrl] : []),
112
- getDescription: () => undefined,
113
- getStockStatus: () => 'instock' as const,
114
- getStockQuantity: () => null,
115
- hasVariants: () => false,
116
- getType: () => 'simple',
117
- getBarcode: () => undefined,
118
- getCategoryNames: () => [],
119
- };
120
- builder.addProduct(line, syntheticTraits, {
105
+ const lineId = builder.addLine({
106
+ productId: line.productId,
121
107
  variantId: line.variantId,
108
+ name: line.name,
109
+ sku: line.sku,
110
+ imageUrl: line.imageUrl,
111
+ unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
122
112
  quantity: line.quantity,
113
+ taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm })),
123
114
  });
124
- }
125
-
126
- // Restore line-level discounts
127
- const resumedOrder = builder.getSnapshot();
128
- for (const savedLine of savedOrder.lineItems) {
129
- const newLine = resumedOrder.lineItems.find(
130
- (li) => li.productId === savedLine.productId && li.variantId === savedLine.variantId,
131
- );
132
- if (newLine) {
133
- for (const discount of savedLine.discounts) {
134
- builder.applyLineDiscount(newLine.id, {
135
- type: discount.type,
136
- value: discount.value,
137
- label: discount.label,
138
- couponCode: discount.couponCode,
139
- });
140
- }
115
+ for (const discount of line.discounts) {
116
+ builder.applyLineDiscount(lineId, {
117
+ type: discount.type,
118
+ value: discount.value,
119
+ label: discount.label,
120
+ couponCode: discount.couponCode,
121
+ });
141
122
  }
142
123
  }
143
124
 
@@ -155,7 +136,9 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
155
136
  for (const payment of savedOrder.payments) {
156
137
  builder.addPayment({
157
138
  method: payment.method,
158
- amount: payment.amount,
139
+ amountMinor: payment.amountMinor,
140
+ tenderedMinor: payment.tenderedMinor,
141
+ changeMinor: payment.changeMinor,
159
142
  reference: payment.reference,
160
143
  });
161
144
  }
@@ -0,0 +1,23 @@
1
+ import type { TaxRounding } from '@tallyui/core';
2
+ import { createOrderBuilder } from './order-builder';
3
+ import { taxLinesByRate } from '../tax/exact';
4
+
5
+ /** A sent line; `discountMinor` is its `lines[].discountMinor` (line discounts + its order share), in its own mode. */
6
+ export type BasketLine = { unitPriceMinor: number; quantity: number; discountMinor?: number; taxInclusive: boolean; taxLines: Array<{ code?: string; ratePpm: number }> };
7
+
8
+ /**
9
+ * The till's settlement figures for a sent basket under a store's strategy (#287), computed exactly as a sale computes
10
+ * them, so a backend can score its own orders against the till (vendurepos/app#38). Each discountMinor is applied as a
11
+ * fixed line discount in the line's own mode.
12
+ */
13
+ export function taxFiguresForBasket(currency: string, pricesIncludeTax: boolean, lines: readonly BasketLine[], rounding?: TaxRounding) {
14
+ const code = currency.toUpperCase();
15
+ const builder = createOrderBuilder({ currency: code, taxContext: { getTaxRatePpm: () => 0, pricesIncludeTax, ...(rounding ? { rounding } : {}) } });
16
+ lines.forEach((line, index) => {
17
+ const lineId = builder.addLine({ productId: `line-${index}`, name: `Line ${index}`, quantity: line.quantity,
18
+ unitPrice: { amount: line.unitPriceMinor, currency: code, taxInclusive: line.taxInclusive }, taxRates: line.taxLines });
19
+ if (line.discountMinor) builder.applyLineDiscount(lineId, { type: 'fixed', value: line.discountMinor });
20
+ });
21
+ const { subtotalMinor, taxMinor, totalMinor, lineItems, taxRounding } = builder.getSnapshot();
22
+ return { subtotalMinor, taxMinor, totalMinor, taxByRate: taxLinesByRate(lineItems, taxMinor, undefined, taxRounding) };
23
+ }