@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +2031 -43
- package/dist/index.js +4629 -205
- package/package.json +23 -12
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +53 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +8 -0
- package/src/order/order-builder.ts +256 -95
- package/src/order/order-manager.ts +26 -43
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +73 -15
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +67 -0
- package/src/outbox/index.ts +9 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +502 -0
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +36 -0
- package/src/outbox/use-order-outbox.ts +175 -0
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +123 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +219 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +16 -0
- package/src/pos-order/open.ts +269 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +130 -0
- package/src/pos-order/types.ts +93 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +32 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +39 -23
- package/src/receipt/types.ts +17 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +277 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +32 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +268 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +570 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +469 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +23 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +394 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +18 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +84 -0
- package/src/tax/exact.ts +218 -0
- package/src/tax/index.ts +4 -3
- package/src/tax/tax-provider.tsx +35 -12
- package/src/tax/types.ts +8 -6
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { BehaviorSubject, type Observable } from 'rxjs';
|
|
2
|
-
import type
|
|
2
|
+
import { resolvePrice, type ProductTraits, type TaxRounding } from '@tallyui/core';
|
|
3
3
|
import type { TaxContext } from '../tax/types';
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
145
|
+
return Math.max(0, roundHalfAway(base * discount.value / 100));
|
|
79
146
|
}
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
239
|
+
lineItems: lines,
|
|
118
240
|
discounts: recalcedOrderDiscounts,
|
|
119
241
|
payments: [...payments],
|
|
120
242
|
customer,
|
|
121
243
|
note,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
172
|
-
quantity,
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
366
|
+
const base = li.unitPriceMinor * li.quantity;
|
|
206
367
|
const applied: AppliedDiscount = {
|
|
207
368
|
...discount,
|
|
208
369
|
id: uid(),
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
103
|
+
// Restore line items and their discounts
|
|
101
104
|
for (const line of savedOrder.lineItems) {
|
|
102
|
-
const
|
|
103
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
+
}
|