@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
@@ -0,0 +1,218 @@
1
+ import type { TaxRounding } from '@tallyui/core';
2
+
3
+ /** Micro-minor-units per minor unit. */
4
+ export const MICROS_PER_MINOR = 1_000_000n;
5
+
6
+ /** What an absent `TaxRounding` means (#287): the till's only rounding before #287a. */
7
+ export const DEFAULT_TAX_ROUNDING = { granularity: 'per_order', mode: 'half_away_from_zero' } as const satisfies TaxRounding;
8
+
9
+ /** Converts a plain decimal percentage with at most four fractional digits to safe integer ppm. */
10
+ export function ratePpmFromPercent(percent: number | string): number {
11
+ const value = String(percent);
12
+ const match = /^(\d+)(?:\.(\d{1,4}))?$/.exec(value);
13
+ if (!match || match[0] !== value) {
14
+ throw new RangeError('Percent must be a plain decimal with at most four fractional digits');
15
+ }
16
+ const ratePpm = BigInt(match[1]) * 10000n + BigInt((match[2] ?? '').padEnd(4, '0'));
17
+ if (ratePpm > BigInt(Number.MAX_SAFE_INTEGER)) {
18
+ throw new RangeError('Rate must be a safe integer');
19
+ }
20
+ return Number(ratePpm);
21
+ }
22
+
23
+ /**
24
+ * Exact exclusive tax, or inclusive tax rounded half away from zero to a micro-minor-unit.
25
+ * Throws RangeError unless amount and rate are safe integers and rate is non-negative.
26
+ */
27
+ export function taxMicros(amountMinor: number, ratePpm: number, pricesIncludeTax: boolean): bigint {
28
+ if (!Number.isSafeInteger(amountMinor) || !Number.isSafeInteger(ratePpm) || ratePpm < 0) {
29
+ throw new RangeError('Amount must be a safe integer and rate a non-negative safe integer');
30
+ }
31
+ const tax = BigInt(amountMinor) * BigInt(ratePpm);
32
+ if (!pricesIncludeTax) return tax;
33
+
34
+ const numerator = tax * MICROS_PER_MINOR;
35
+ const denominator = MICROS_PER_MINOR + BigInt(ratePpm);
36
+ const sign = numerator < 0n ? -1n : 1n;
37
+ return sign * ((sign * numerator + denominator / 2n) / denominator);
38
+ }
39
+
40
+ /** #287: `half_up` is Math.round's rule, Vendure's DefaultMoneyStrategy (default-money-strategy.js:19-21). */
41
+ export type RoundingMode = 'half_away_from_zero' | 'half_up';
42
+
43
+ /** `n / d` (d > 0) rounded to an integer in `mode`, exactly; the modes differ only on exact negative halves. */
44
+ export function roundRatio(n: bigint, d: bigint, mode: RoundingMode = 'half_away_from_zero'): bigint {
45
+ if (mode === 'half_away_from_zero' && n < 0n) return -roundRatio(-n, d, mode);
46
+ const a = 2n * n + d, b = 2n * d; // floor(n / d + 1/2)
47
+ return a / b - (a < 0n && a % b !== 0n ? 1n : 0n);
48
+ }
49
+
50
+ /** Rounds micro-minor-units to integer minor units, half away from zero unless `mode` says otherwise. */
51
+ export function roundMicrosToMinor(micros: bigint, mode?: RoundingMode): number {
52
+ const minor = roundRatio(micros, MICROS_PER_MINOR, mode);
53
+ if (minor < -BigInt(Number.MAX_SAFE_INTEGER) || minor > BigInt(Number.MAX_SAFE_INTEGER)) {
54
+ throw new RangeError('Rounded amount must be a safe integer');
55
+ }
56
+ return Number(minor);
57
+ }
58
+
59
+ export interface TaxLineInput {
60
+ unitPriceMinor: number; // integer, may be negative (returns)
61
+ quantity: number; // integer >= 1
62
+ ratePpm: number; // integer >= 0
63
+ }
64
+
65
+ export interface OrderTaxTotals {
66
+ subtotalMinor: number; // excl. tax
67
+ taxMinor: number; // rounded once
68
+ totalMinor: number; // incl. tax
69
+ lineTaxMicros: bigint[]; // exact, one per input line, same order
70
+ }
71
+
72
+ /**
73
+ * Sums tax on each unit price × quantity and rounds once for the order.
74
+ * Exclusive: total = subtotal + tax. Inclusive: subtotal = total − tax.
75
+ * Empty orders return zeros and an empty lineTaxMicros.
76
+ * Throws RangeError for unsafe integer inputs or totals, negative rates, or quantity < 1.
77
+ */
78
+ export function computeOrderTax(lines: TaxLineInput[], pricesIncludeTax: boolean): OrderTaxTotals {
79
+ let amountTotal = 0n;
80
+ let taxTotal = 0n;
81
+ const lineTaxMicros: bigint[] = [];
82
+
83
+ for (const line of lines) {
84
+ if (!Number.isSafeInteger(line.quantity) || line.quantity < 1) {
85
+ throw new RangeError('Quantity must be a safe integer >= 1');
86
+ }
87
+ let tax = taxMicros(line.unitPriceMinor, line.ratePpm, false) * BigInt(line.quantity);
88
+ const amount = BigInt(line.unitPriceMinor) * BigInt(line.quantity);
89
+ if (pricesIncludeTax) {
90
+ const numerator = tax * MICROS_PER_MINOR;
91
+ const denominator = MICROS_PER_MINOR + BigInt(line.ratePpm);
92
+ const sign = numerator < 0n ? -1n : 1n;
93
+ tax = sign * ((sign * numerator + denominator / 2n) / denominator);
94
+ }
95
+ amountTotal += amount;
96
+ taxTotal += tax;
97
+ lineTaxMicros.push(tax);
98
+ }
99
+
100
+ const taxMinor = taxTotal / MICROS_PER_MINOR
101
+ + BigInt(roundMicrosToMinor(taxTotal % MICROS_PER_MINOR));
102
+ const subtotalMinor = pricesIncludeTax ? amountTotal - taxMinor : amountTotal;
103
+ const totalMinor = pricesIncludeTax ? amountTotal : amountTotal + taxMinor;
104
+ for (const value of [subtotalMinor, taxMinor, totalMinor]) {
105
+ if (value < -BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
106
+ throw new RangeError('Order totals must be safe integers');
107
+ }
108
+ }
109
+ return {
110
+ subtotalMinor: Number(subtotalMinor),
111
+ taxMinor: Number(taxMinor),
112
+ totalMinor: Number(totalMinor),
113
+ lineTaxMicros,
114
+ };
115
+ }
116
+
117
+ export interface RateTaxLine {
118
+ label: string;
119
+ code?: string;
120
+ ratePpm: number;
121
+ netMinor: number;
122
+ amountMinor: number;
123
+ }
124
+
125
+ type TaxedLine = { netMinor: number; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
126
+
127
+ /**
128
+ * A `per_line_items` or `per_rate_group_items` store's figures (#287) as @vendure/core 3.7.3 computes them; undefined
129
+ * otherwise. `baseMinor` (the subtotal) is Σ each item's rounded net, `rates` the tax by `code`+`ratePpm`, summing to
130
+ * `taxMinor`; the total is their sum. Vendure's rounding is `half_up`. vendurepos scores the #38 set with this.
131
+ */
132
+ export function roundedTaxByRate(lines: readonly TaxedLine[], rounding?: TaxRounding) {
133
+ if (rounding?.granularity !== 'per_line_items' && rounding?.granularity !== 'per_rate_group_items') return undefined;
134
+ // Known gap (#287): Vendure's inclusive per_rate_group_items total can differ from the shelf prices, which the display
135
+ // can't show until it has a rounding row (#310), so an order with any inclusive line keeps per_order's figures and rows.
136
+ if (rounding.granularity === 'per_rate_group_items' && lines.some((line) => line.taxInclusive)) return undefined;
137
+ const { granularity, mode } = rounding;
138
+ const rates = new Map<string, { code?: string; ratePpm: number; netMinor: bigint; amountMinor: bigint }>();
139
+ let baseMinor = 0n;
140
+ // vendurepos posts each line undiscounted (A) and its discountMinor as its own −D surcharge in the line's mode with
141
+ // the line's tax lines (vendurepos/app order-create.service.ts:654-663); a return line's negative D is its cap, not an item.
142
+ const items = lines.flatMap((line) => (line.discountMinor ?? 0) > 0
143
+ ? [{ ...line, netMinor: line.netMinor + line.discountMinor! }, { ...line, netMinor: -line.discountMinor! }] : [line]);
144
+ for (const line of items) {
145
+ const net = BigInt(line.netMinor);
146
+ const ratePpm = BigInt(line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0));
147
+ // Each item's net is rounded first, at the sum of its rates (order-line.entity.js:121-122): an inclusive item's
148
+ // netPriceOf(gross) (:193-196, :249-251; surcharge.entity.js:33-38; tax-utils.js:16-17), an exclusive one's as it is.
149
+ const base = line.taxInclusive ? roundRatio(net * MICROS_PER_MINOR, MICROS_PER_MINOR + ratePpm, mode) : net;
150
+ // per_line_items: the item's tax is its rounded gross less that net (:201-204, :259-261; surcharge.entity.js:36-38;
151
+ // default-order-tax-calculation-strategy.js:22-29): round(net × r) exclusive, gross − net inclusive.
152
+ const lineTax = line.taxInclusive ? net - base : roundRatio(net * ratePpm, MICROS_PER_MINOR, mode);
153
+ let left = lineTax;
154
+ baseMinor += base;
155
+ line.taxLines.forEach((tax, index) => {
156
+ // Vendure's key is the rate's name and value (order-level-tax-calculation-strategy.js:103).
157
+ const key = JSON.stringify([tax.code ?? '', tax.ratePpm]);
158
+ const row = rates.get(key) ?? { code: tax.code, ratePpm: tax.ratePpm, netMinor: 0n, amountMinor: 0n };
159
+ // per_line_items, stacked rates: each rate's share of the item's tax, rounded (default-order-tax-calculation-strategy.js:51-66),
160
+ // the last rate taking the rest so the rows sum to the tax. per_rate_group_items: each rate takes the whole net, and
161
+ // a −D item joins its line's group (order-level-tax-calculation-strategy.js:88-91, :98-115).
162
+ const share = index === line.taxLines.length - 1 ? left : ratePpm === 0n ? 0n : roundRatio(lineTax * BigInt(tax.ratePpm), ratePpm, mode);
163
+ left -= share;
164
+ rates.set(key, { ...row, netMinor: row.netMinor + base, amountMinor: row.amountMinor + share });
165
+ });
166
+ }
167
+ const rows = [...rates.values()].map((row) => ({ ...row, netMinor: Number(row.netMinor), amountMinor: Number(
168
+ // per_rate_group_items: round(Σ net × r) once per group, and the order's tax is their sum (:35-49, :56).
169
+ granularity === 'per_rate_group_items' ? roundRatio(row.netMinor * BigInt(row.ratePpm), MICROS_PER_MINOR, mode) : row.amountMinor) }));
170
+ return { baseMinor: Number(baseMinor), taxMinor: rows.reduce((sum, row) => sum + row.amountMinor, 0), rates: rows };
171
+ }
172
+
173
+ /**
174
+ * Groups each line's stacked tax rates (ADR-040: each independently taxes the line's full
175
+ * tax-free base) by `code`+`ratePpm`, floors each group's exact tax to minor units, then
176
+ * distributes `orderTaxMinor` minus that floor sum by largest remainder (ties to the higher rate)
177
+ * so the rates sum to exactly `orderTaxMinor`. A line's `netMinor` is in its OWN tax mode
178
+ * (`taxInclusive`): an inclusive line's net already contains its tax, so its tax-free base is
179
+ * `netMinor − roundMicrosToMinor(Σ its taxMicros)`; an exclusive line's base is `netMinor` as is.
180
+ * Shared by a receipt's tax summary and a Z report's per-rate breakdown. That is `per_order`'s split; a `per_line_items` or
181
+ * `per_rate_group_items` order's rows are `roundedTaxByRate`'s (#287).
182
+ */
183
+ export function taxLinesByRate(
184
+ lines: readonly TaxedLine[],
185
+ orderTaxMinor: number,
186
+ taxLabels?: Record<number, string>,
187
+ rounding?: TaxRounding,
188
+ ): RateTaxLine[] {
189
+ const label = (ratePpm: number) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`;
190
+ const rounded = roundedTaxByRate(lines, rounding);
191
+ if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
192
+ const byRate = new Map<string, { code?: string; ratePpm: number; micros: bigint; netMinor: number }>();
193
+ for (const line of lines) {
194
+ const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
195
+ const base = line.taxInclusive ? line.netMinor - roundMicrosToMinor(lineTaxMicros) : line.netMinor;
196
+ for (const tax of line.taxLines) {
197
+ const key = JSON.stringify([tax.code ?? '', tax.ratePpm]);
198
+ const existing = byRate.get(key);
199
+ byRate.set(key, {
200
+ code: tax.code, ratePpm: tax.ratePpm,
201
+ micros: (existing?.micros ?? 0n) + BigInt(tax.taxMicros),
202
+ netMinor: (existing?.netMinor ?? 0) + base,
203
+ });
204
+ }
205
+ }
206
+ const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
207
+ const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
208
+ return {
209
+ line: { label: label(ratePpm), code, ratePpm, netMinor, amountMinor: Number(floor) },
210
+ remainder: micros - floor * MICROS_PER_MINOR,
211
+ };
212
+ });
213
+ const leftover = orderTaxMinor - groups.reduce((sum, group) => sum + group.line.amountMinor, 0);
214
+ const ranked = [...groups].sort((a, b) =>
215
+ a.remainder === b.remainder ? b.line.ratePpm - a.line.ratePpm : a.remainder > b.remainder ? -1 : 1);
216
+ for (const group of ranked.slice(0, leftover)) group.line.amountMinor += 1;
217
+ return groups.map((group) => group.line);
218
+ }
package/src/tax/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { calculateTax, extractTax, addTax } from './calculate';
2
- export { TaxProvider, useTax } from './tax-provider';
1
+ export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, computeOrderTax, taxLinesByRate } from './exact';
2
+ export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './exact';
3
+ export { TaxProvider, useTax, taxLogger } from './tax-provider';
3
4
  export type { TaxProviderProps } from './tax-provider';
4
- export type { TaxResult, TaxRateMap, TaxContext } from './types';
5
+ export type { TaxRateMap, TaxContext } from './types';
@@ -1,27 +1,50 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import type { ReactNode } from 'react';
3
+ import type { TaxRounding } from '@tallyui/core';
3
4
  import type { TaxContext } from './types';
5
+ import { createLogger } from '../logging';
6
+
7
+ export const taxLogger = createLogger('tax');
4
8
 
5
9
  const TaxCtx = createContext<TaxContext | null>(null);
6
10
 
7
11
  export interface TaxProviderProps {
8
- rates: Record<string, number>;
12
+ ratesPpm: Record<string, number>;
9
13
  pricesIncludeTax: boolean;
14
+ /** `ServerCapabilities.taxRounding` (#287); a change restarts an idle sale under it. */
15
+ rounding?: TaxRounding;
16
+ /** Tax class → the backend's rate name, for `per_rate_group_items`'s grouping (#287). */
17
+ rateCodes?: Record<string, string>;
10
18
  children: ReactNode;
11
19
  }
12
20
 
13
- export function TaxProvider({ rates, pricesIncludeTax, children }: TaxProviderProps) {
21
+ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }: TaxProviderProps) {
14
22
  const value = useMemo<TaxContext>(
15
- () => ({
16
- getTaxRate(taxClass?: string) {
17
- if (taxClass && taxClass in rates) {
18
- return rates[taxClass];
19
- }
20
- return rates.default ?? 0;
21
- },
22
- pricesIncludeTax,
23
- }),
24
- [rates, pricesIncludeTax],
23
+ () => {
24
+ if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
25
+ throw new RangeError('TaxProvider: rates must be integer ppm');
26
+ }
27
+ const unknown = new Set<string>();
28
+ return {
29
+ getTaxRatePpm: (taxClass) => {
30
+ if (taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
31
+ // A class the store's rates don't key is taxed at the default rate; warn once per class and rate map.
32
+ if (taxClass !== undefined && !unknown.has(taxClass)) {
33
+ unknown.add(taxClass);
34
+ taxLogger.warn('No tax rate for this tax class; using the default rate', { taxClass });
35
+ }
36
+ return ratesPpm.default ?? 0;
37
+ },
38
+ // The name of the rate getTaxRatePpm picks: the class's own, else the default's.
39
+ getTaxRateCode: (taxClass) => {
40
+ const key = taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass) ? taxClass : 'default';
41
+ return rateCodes && Object.hasOwn(rateCodes, key) ? rateCodes[key] : undefined;
42
+ },
43
+ pricesIncludeTax,
44
+ ...(rounding ? { rounding } : {}),
45
+ };
46
+ },
47
+ [ratesPpm, pricesIncludeTax, rounding, rateCodes],
25
48
  );
26
49
 
27
50
  return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
package/src/tax/types.ts CHANGED
@@ -1,15 +1,17 @@
1
- export interface TaxResult {
2
- priceExclTax: number;
3
- priceInclTax: number;
4
- taxAmount: number;
5
- }
1
+ import type { TaxRounding } from '@tallyui/core';
6
2
 
3
+ /** Tax rates as integer parts per million (19% = 190000). */
7
4
  export type TaxRateMap = {
8
5
  default: number;
9
6
  [taxClass: string]: number;
10
7
  };
11
8
 
12
9
  export interface TaxContext {
13
- getTaxRate(taxClass?: string): number;
10
+ /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
11
+ getTaxRatePpm(taxClass?: string): number;
12
+ /** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */
13
+ getTaxRateCode?(taxClass?: string): string | undefined;
14
14
  pricesIncludeTax: boolean;
15
+ /** The store's tax rounding strategy (#287); absent means `per_order`, half away from zero. */
16
+ rounding?: TaxRounding;
15
17
  }
@@ -0,0 +1,2 @@
1
+ export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender-state';
2
+ export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender-state';
@@ -0,0 +1,326 @@
1
+ /** The transport a payment terminal is reached over: hardware-neutral, not tied to any backend. */
2
+ export type PaymentTransport = 'bluetooth' | 'usb' | 'network' | 'tap_to_pay';
3
+
4
+ export type TenderView = 'select' | 'amount' | 'cancel';
5
+ export type TenderLineId = string | number;
6
+
7
+ export type TenderPlan =
8
+ | { kind: 'even'; ways: number; from: number }
9
+ | { kind: 'fixed'; firstMinor: number; title: string | null; from: number }
10
+ | { kind: 'items'; lineIds: TenderLineId[]; firstMinor: number; ways: number; from: number };
11
+ export type SplitTab = 'even' | 'amount' | 'percent' | 'item';
12
+
13
+ export interface TenderState {
14
+ view: TenderView;
15
+ /** Method id being tendered; null in the 'select' and 'cancel' views. */
16
+ methodId: string | null;
17
+ readerId: string | null;
18
+ transport: PaymentTransport | null;
19
+ /** Keypad entry in minor units. */
20
+ entryMinor: number;
21
+ /** False until the cashier has touched the keypad since the entry was pre-filled. */
22
+ entryDirty: boolean;
23
+ plan: TenderPlan | null;
24
+ splitView: boolean;
25
+ splitTab: SplitTab;
26
+ pickedLineIds: TenderLineId[];
27
+ linesPaidBy: Record<TenderLineId, string[]>;
28
+ }
29
+
30
+ /** '0'..'9' plus the two edit keys. There is deliberately no decimal key: digits shift in from the right. */
31
+ export type TenderKey =
32
+ '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'clear' | 'backspace';
33
+
34
+ export type TenderAction =
35
+ | {
36
+ type: 'pick-method';
37
+ methodId: string;
38
+ prefillMinor: number;
39
+ readerId: string | null;
40
+ transport?: PaymentTransport | null;
41
+ }
42
+ | { type: 'pick-transport'; transport: PaymentTransport }
43
+ | { type: 'pick-reader'; readerId: string | null }
44
+ | { type: 'tender-started' }
45
+ | { type: 'key'; key: TenderKey }
46
+ | { type: 'set-entry'; minor: number }
47
+ | { type: 'back' }
48
+ | {
49
+ type: 'tender-recorded';
50
+ rowsSinceFrom: { title: string; amountMinor: number }[];
51
+ /** The order's balance once this row counts, so the next leg can be pre-typed. */
52
+ balanceMinor: number;
53
+ }
54
+ | { type: 'open-split' }
55
+ | { type: 'close-split' }
56
+ | { type: 'set-split-tab'; tab: SplitTab }
57
+ | { type: 'toggle-split-line'; lineId: TenderLineId }
58
+ | { type: 'set-plan'; plan: TenderPlan; balanceMinor: number }
59
+ | { type: 'clear-plan'; balanceMinor: number }
60
+ | { type: 'arm-custom' }
61
+ | { type: 'request-cancel' }
62
+ | { type: 'reset' };
63
+ /** $9,999,999.99 at two decimals — a till will never legitimately take more, and it stops a stuck key running the display off the screen. */
64
+ export const MAX_TENDER_MINOR = 999999999;
65
+ export const initialTenderState: TenderState = {
66
+ view: 'select',
67
+ methodId: null,
68
+ readerId: null,
69
+ transport: null,
70
+ entryMinor: 0,
71
+ entryDirty: false,
72
+ plan: null,
73
+ splitView: false,
74
+ splitTab: 'even',
75
+ pickedLineIds: [],
76
+ linesPaidBy: {},
77
+ };
78
+
79
+ /**
80
+ * Reducer init. An order whose method the checkout store already holds — a URL seed, or a
81
+ * tab the cashier is coming back to — reopens its keypad prefilled with the balance, exactly
82
+ * as a tap on that tile would. Everything else starts from scratch.
83
+ */
84
+ export function initTenderState({
85
+ methodId,
86
+ balanceMinor,
87
+ }: {
88
+ methodId: string | null;
89
+ balanceMinor: number;
90
+ }): TenderState {
91
+ if (!methodId) return initialTenderState;
92
+ return { ...initialTenderState, view: 'amount', methodId, entryMinor: balanceMinor };
93
+ }
94
+
95
+ export function tenderReducer(state: TenderState, action: TenderAction): TenderState {
96
+ switch (action.type) {
97
+ case 'pick-method':
98
+ return {
99
+ ...state,
100
+ view: 'amount',
101
+ methodId: action.methodId,
102
+ readerId: action.readerId,
103
+ transport: action.transport ?? null,
104
+ entryMinor: state.entryDirty ? state.entryMinor : action.prefillMinor,
105
+ entryDirty: state.entryDirty,
106
+ };
107
+ case 'pick-transport':
108
+ return { ...state, transport: action.transport };
109
+ case 'pick-reader':
110
+ return { ...state, readerId: action.readerId };
111
+ case 'key': {
112
+ if (state.view !== 'amount') {
113
+ return state;
114
+ }
115
+ if (action.key === 'backspace') {
116
+ return { ...state, entryMinor: Math.floor(state.entryMinor / 10), entryDirty: true };
117
+ }
118
+ if (action.key === 'clear') {
119
+ return { ...state, entryMinor: 0, entryDirty: true };
120
+ }
121
+ const digit = Number(action.key);
122
+ if (!state.entryDirty) {
123
+ return { ...state, entryMinor: digit, entryDirty: true };
124
+ }
125
+ const entryMinor = state.entryMinor * 10 + digit;
126
+ return entryMinor > MAX_TENDER_MINOR ? state : { ...state, entryMinor };
127
+ }
128
+ case 'set-entry':
129
+ return {
130
+ ...state,
131
+ entryMinor: Math.max(0, Math.min(action.minor, MAX_TENDER_MINOR)),
132
+ entryDirty: true,
133
+ };
134
+ case 'tender-started':
135
+ // A leg is being recorded: the method stays chosen for the next one; the entry
136
+ // clears until `tender-recorded` pre-types the next planned leg.
137
+ return { ...state, entryMinor: 0, entryDirty: false, splitView: false };
138
+ case 'back':
139
+ return {
140
+ ...state,
141
+ view: 'select',
142
+ methodId: null,
143
+ readerId: null,
144
+ transport: null,
145
+ entryMinor: 0,
146
+ entryDirty: false,
147
+ };
148
+ case 'tender-recorded': {
149
+ const linesPaidBy = { ...state.linesPaidBy };
150
+ if (
151
+ state.plan?.kind === 'items' &&
152
+ action.rowsSinceFrom.reduce((sum, row) => sum + row.amountMinor, 0) >= state.plan.firstMinor
153
+ ) {
154
+ for (const id of state.plan.lineIds) {
155
+ // Preserve the group's original methods when subsequently taking the rest.
156
+ linesPaidBy[id] ??= [...new Set(action.rowsSinceFrom.map((row) => row.title))];
157
+ }
158
+ }
159
+ // The method stays chosen for the next leg (the selector is always on screen now);
160
+ // the entry pre-types the next planned leg, or the balance when no plan is left.
161
+ const rows = action.rowsSinceFrom.map((row) => ({
162
+ minor: row.amountMinor,
163
+ title: row.title,
164
+ }));
165
+ const plan = activePlan(state.plan, rows.length, action.balanceMinor);
166
+ const entryMinor = plan
167
+ ? planLegs(plan, rows, action.balanceMinor).thisPaymentMinor
168
+ : action.balanceMinor;
169
+ return {
170
+ ...state,
171
+ view: state.methodId ? 'amount' : 'select',
172
+ entryMinor,
173
+ entryDirty: false,
174
+ splitView: false,
175
+ pickedLineIds: [],
176
+ linesPaidBy,
177
+ };
178
+ }
179
+ case 'open-split':
180
+ return {
181
+ ...state,
182
+ splitView: true,
183
+ pickedLineIds:
184
+ state.plan?.kind === 'items'
185
+ ? state.plan.lineIds.filter((id) => !state.linesPaidBy[id])
186
+ : state.pickedLineIds,
187
+ };
188
+ case 'close-split':
189
+ return { ...state, splitView: false };
190
+ case 'set-split-tab':
191
+ return { ...state, splitTab: action.tab };
192
+ case 'toggle-split-line':
193
+ if (state.linesPaidBy[action.lineId]) return state;
194
+ return {
195
+ ...state,
196
+ pickedLineIds: state.pickedLineIds.includes(action.lineId)
197
+ ? state.pickedLineIds.filter((id) => id !== action.lineId)
198
+ : [...state.pickedLineIds, action.lineId],
199
+ };
200
+ case 'set-plan':
201
+ return {
202
+ ...state,
203
+ plan: action.plan,
204
+ splitView: false,
205
+ pickedLineIds: [],
206
+ entryMinor:
207
+ state.view === 'amount'
208
+ ? planLegs(action.plan, [], action.balanceMinor).thisPaymentMinor
209
+ : state.entryMinor,
210
+ entryDirty: state.view !== 'amount' && state.entryDirty,
211
+ };
212
+ case 'clear-plan':
213
+ case 'arm-custom':
214
+ return {
215
+ ...state,
216
+ plan: null,
217
+ splitView: false,
218
+ view: action.type === 'arm-custom' ? 'amount' : state.view,
219
+ entryMinor: action.type === 'clear-plan' ? action.balanceMinor : 0,
220
+ entryDirty: false,
221
+ };
222
+ case 'request-cancel':
223
+ return { ...state, view: 'cancel', splitView: false };
224
+ case 'reset':
225
+ return initialTenderState;
226
+ }
227
+ }
228
+ /**
229
+ * What the leg applies to the order. Cash may be tendered above the balance;
230
+ * the excess is change, never an overpayment on the order.
231
+ */
232
+ export function appliedMinor(entryMinor: number, balanceMinor: number): number {
233
+ return Math.max(0, Math.min(entryMinor, balanceMinor));
234
+ }
235
+ /** Change handed back. Zero for any tender whose method cannot give change. */
236
+ export function changeMinor(
237
+ entryMinor: number,
238
+ appliedAmountMinor: number,
239
+ givesChange: boolean,
240
+ ): number {
241
+ return givesChange ? Math.max(0, entryMinor - appliedAmountMinor) : 0;
242
+ }
243
+ /**
244
+ * Quick tendered amounts under a cash keypad: the balance itself, then the next
245
+ * whole 5, 10 and 50 above it. Deduped, ascending, never below the balance.
246
+ * The caller supplies those major-unit steps already scaled to minor units.
247
+ */
248
+ export function quickTenderedAmounts(
249
+ balanceMinor: number,
250
+ stepsMinor: readonly number[],
251
+ ): number[] {
252
+ if (balanceMinor === 0) {
253
+ return [];
254
+ }
255
+
256
+ const amounts = new Set<number>([balanceMinor]);
257
+ for (const stepMinor of stepsMinor) {
258
+ if (stepMinor <= 0) continue;
259
+ const remainder = balanceMinor % stepMinor;
260
+ amounts.add(remainder === 0 ? balanceMinor : balanceMinor + stepMinor - remainder);
261
+ }
262
+ return [...amounts].sort((left, right) => left - right);
263
+ }
264
+ /**
265
+ * Even split shares. Returns the next tender's share, rounded half-up to the
266
+ * minor unit; the last leg remains whatever balance is left.
267
+ */
268
+ export function evenSplitShareMinor(balanceMinor: number, ways: number): number {
269
+ if (ways < 2) {
270
+ return balanceMinor;
271
+ }
272
+ const wholeShare = Math.floor(balanceMinor / ways);
273
+ const remainder = balanceMinor % ways;
274
+ return wholeShare + (remainder * 2 >= ways ? 1 : 0);
275
+ }
276
+
277
+ /** Completion is derived from payment rows, never an extra counter in the reducer. */
278
+ export function activePlan(
279
+ plan: TenderPlan | null,
280
+ taken: number,
281
+ balanceMinor: number,
282
+ ): TenderPlan | null {
283
+ if (!plan || balanceMinor === 0) return null;
284
+ if (plan.kind !== 'items' && taken >= (plan.kind === 'even' ? plan.ways : 2)) return null;
285
+ return plan;
286
+ }
287
+
288
+ export interface PlanLeg {
289
+ minor: number;
290
+ state: 'done' | 'now' | 'todo' | 'rest';
291
+ title?: string;
292
+ }
293
+
294
+ /** Label pieces stay untranslated here; the flow supplies localized copy and item names. */
295
+ export function planLegs(
296
+ plan: TenderPlan,
297
+ rowsSinceFrom: { minor: number; title: string }[],
298
+ balanceMinor: number,
299
+ ) {
300
+ const taken = rowsSinceFrom.length;
301
+ const legs: PlanLeg[] = rowsSinceFrom.map((row) => ({ ...row, state: 'done' }));
302
+ const groupPaid = rowsSinceFrom.reduce((sum, row) => sum + row.minor, 0);
303
+ const groupLeft =
304
+ plan.kind === 'items'
305
+ ? Math.min(balanceMinor, Math.max(0, plan.firstMinor - groupPaid))
306
+ : balanceMinor;
307
+ const rest = plan.kind === 'items' && groupLeft === 0;
308
+ const ways = plan.kind === 'fixed' ? 2 : Math.max(plan.ways, taken + 1);
309
+ const left = plan.kind === 'fixed' || rest ? 1 : Math.max(1, ways - taken);
310
+ const amount = rest ? balanceMinor : groupLeft;
311
+ const each =
312
+ plan.kind === 'fixed' && taken === 0
313
+ ? Math.min(plan.firstMinor, balanceMinor)
314
+ : evenSplitShareMinor(amount, left);
315
+ let remaining = amount;
316
+ for (let i = 0; i < left; i++) {
317
+ const minor = Math.min(remaining, i === left - 1 && plan.kind !== 'fixed' ? remaining : each);
318
+ legs.push({ minor, state: i === 0 ? 'now' : 'todo' });
319
+ remaining -= minor;
320
+ }
321
+ if (plan.kind === 'fixed' && remaining > 0) legs.push({ minor: remaining, state: 'todo' });
322
+ if (plan.kind === 'items' && !rest && balanceMinor > groupLeft)
323
+ legs.push({ minor: balanceMinor - groupLeft, state: 'rest' });
324
+ const title = plan.kind === 'fixed' && taken === 0 ? plan.title : null;
325
+ return { legs, thisPaymentMinor: each, label: { n: taken + 1, ways, rest, title } };
326
+ }