@tallyui/pos 0.1.0 → 2.0.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "0.1.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
6
6
  "main": "./dist/index.js",
@@ -15,25 +15,36 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "src"
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/**/*.test.tsx",
21
+ "!src/**/*.test-helper.ts"
19
22
  ],
20
23
  "license": "MIT",
21
- "scripts": {
22
- "build": "tsup",
23
- "typecheck": "tsc --noEmit",
24
- "test": "vitest run"
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/TallyUI/tallyui.git",
27
+ "directory": "packages/pos"
25
28
  },
26
29
  "peerDependencies": {
27
- "@tallyui/core": "workspace:*",
30
+ "@tallyui/core": "2.0.0",
28
31
  "react": ">=18",
29
32
  "rxdb": ">=16",
30
33
  "rxjs": ">=7"
31
34
  },
32
35
  "devDependencies": {
33
- "@tallyui/core": "workspace:*",
36
+ "@tallyui/core": "2.0.0",
34
37
  "@types/react": ">=18",
35
38
  "react": "^19.2.4",
36
39
  "rxdb": "16.21.1",
37
- "rxjs": "7.8.2"
40
+ "rxjs": "7.8.2",
41
+ "@tallyui/connector-medusa": "2.0.0",
42
+ "@tallyui/connector-woocommerce": "2.0.0",
43
+ "@tallyui/database": "2.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run"
38
49
  }
39
- }
50
+ }
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useMemo, useCallback } from 'react';
2
2
  import type { ReactNode } from 'react';
3
- import { formatCurrency } from './format-currency';
3
+ import { formatMoney, type Money } from '@tallyui/core';
4
4
 
5
5
  interface CurrencyContext {
6
6
  currencyCode: string;
@@ -20,13 +20,21 @@ export function CurrencyProvider({ currencyCode, locale, children }: CurrencyPro
20
20
  return <CurrencyCtx.Provider value={value}>{children}</CurrencyCtx.Provider>;
21
21
  }
22
22
 
23
- export function useCurrencyFormatter(): (amount: number) => string {
23
+ export function useCurrencyFormatter(): (money: Money) => string {
24
24
  const ctx = useContext(CurrencyCtx);
25
25
  if (!ctx) {
26
26
  throw new Error('useCurrencyFormatter() must be used within a <CurrencyProvider>');
27
27
  }
28
28
  return useCallback(
29
- (amount: number) => formatCurrency(amount, ctx.currencyCode, ctx.locale),
30
- [ctx.currencyCode, ctx.locale],
29
+ (money: Money) => formatMoney(money, ctx.locale) ?? `${money.amount} ${money.currency}`,
30
+ [ctx.locale],
31
31
  );
32
32
  }
33
+
34
+ export function useCurrencyCode(): string {
35
+ const ctx = useContext(CurrencyCtx);
36
+ if (!ctx) {
37
+ throw new Error('useCurrencyCode() must be used within a <CurrencyProvider>');
38
+ }
39
+ return ctx.currencyCode;
40
+ }
@@ -1,3 +1,2 @@
1
- export { formatCurrency } from './format-currency';
2
- export { CurrencyProvider, useCurrencyFormatter } from './currency-provider';
1
+ export { CurrencyProvider, useCurrencyFormatter, useCurrencyCode } from './currency-provider';
3
2
  export type { CurrencyProviderProps } from './currency-provider';
package/src/index.ts CHANGED
@@ -3,27 +3,40 @@ export { createLogger, consoleSink, callbackSink } from './logging';
3
3
  export type { Logger, LogSink, LogEntry, LogLevel } from './logging';
4
4
 
5
5
  // Currency
6
- export { formatCurrency, CurrencyProvider, useCurrencyFormatter } from './currency';
6
+ export { CurrencyProvider, useCurrencyFormatter, useCurrencyCode } from './currency';
7
7
  export type { CurrencyProviderProps } from './currency';
8
8
 
9
9
  // Tax
10
- export { calculateTax, extractTax, addTax, TaxProvider, useTax } from './tax';
10
+ export { TaxProvider, useTax } from './tax';
11
+ export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, computeOrderTax, taxLinesByRate } from './tax';
12
+ export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './tax';
11
13
  export type { TaxProviderProps } from './tax';
12
- export type { TaxResult, TaxRateMap, TaxContext } from './tax';
14
+ export type { TaxRateMap, TaxContext } from './tax';
15
+
16
+ // Store settings
17
+ export { resolveStoreSettings, useStoreSettings, withPricingContext, taxProviderProps } from './store-settings';
18
+ export type { StoreSettingsResolution, ResolveStoreSettingsOptions, StoreSettingsState } from './store-settings';
19
+
20
+ // RxDB reads past the query cache, and live lists that can't leave it stale (RxDB 16.21.1 bug 4)
21
+ export { readFresh, countFresh, watchFresh } from './rxdb';
13
22
 
14
23
  // Repository
15
24
  export { createRepository } from './repository';
16
25
  export type { Repository } from './repository';
17
26
 
18
27
  // Order
19
- export { createOrderBuilder, createOrderManager } from './order';
28
+ export { createOrderBuilder, createOrderManager, allocateOrderDiscount } from './order';
20
29
  export type { OrderBuilder, OrderBuilderOptions } from './order';
21
30
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order';
22
31
  export type {
23
32
  Order,
24
33
  LineItem,
34
+ LineTaxLine,
35
+ AddLineInput,
25
36
  Discount,
26
37
  AppliedDiscount,
38
+ DisplayTotals,
39
+ DisplayLine,
27
40
  Payment,
28
41
  CustomerSummary,
29
42
  PaymentMethod,
@@ -32,3 +45,31 @@ export type {
32
45
  // Receipt
33
46
  export { buildReceiptData } from './receipt';
34
47
  export type { ReceiptData, ReceiptLineItem, ReceiptConfig } from './receipt';
48
+
49
+ // Product
50
+ export { searchProducts, withStockOverlay, getProductStock, stockOverlay$, stockOverlayAsOf$ } from './product';
51
+
52
+ // Sale
53
+ export { useSale, DISCOUNTS_UNSUPPORTED, SALE_SAVING, saleLogger, addEntryToCart, CartError, catalogueEntries, findEntryByCode, variantPriceLabel } from './sale';
54
+ export type { SaleStage, CatalogueEntry } from './sale';
55
+
56
+ export { uuidv7, finalizeOrder, toOrderCreateEnvelope, posOrderSchema, posOrderCollection, addPosOrderCollection, PosOrderOpenClosedError, posOrdersLogger, getDeviceId, needsAttention, sameSale, OrderContentMismatchError } from './pos-order';
57
+ export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrder, FinalizeOptions } from './pos-order';
58
+ export { createHttpCommandTransport, createOrderOutbox, useOrderOutbox, outboxLogger } from './outbox';
59
+ export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
60
+ export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender';
61
+ export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender';
62
+
63
+ // Register
64
+ export { normalizeAmount, isServerDecimal, movementFieldError, deriveExpected, parseMinor, validAmount, countVariance, overThreshold, closeNeedsApproval, denominationTotal, varianceText, denominations } from './register';
65
+ export type { MovementType, LedgerRow, Movement } from './register';
66
+ export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './register';
67
+ export type { RegisterSession, CashMovement, Closure } from './register';
68
+ export { mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister, unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual } from './register';
69
+ export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register';
70
+ export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
71
+ export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './register';
72
+ export { deriveSettled, exportCsv, labelKeys, clampClosureScope, selectClosureRows } from './register';
73
+ export type { Correction, RecordedFigures, ClosureScope } from './register';
74
+ export { minorToDecimal, formatClosureDate, buildClosureDocument, buildXReportDocument, registerFactsLogger, recordRegisterFact, type ClosureContext, type Actor, type RegisterFact } from './register';
75
+ export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError, type UseRegisterSessionOptions } from './register';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Splits an order discount across lines in proportion to each line's pre-order-discount amount,
3
+ * in its own tax mode (ADR-062), with largest-remainder rounding to the minor unit.
4
+ *
5
+ * - The shares sum exactly to `totalMinor`, and no share exceeds its line's amount.
6
+ * - Leftover units go to the largest remainders; ties go to the earlier line, so the result is deterministic.
7
+ * - A line with a zero or negative amount gets 0.
8
+ *
9
+ * Throws RangeError unless every input is a safe integer and 0 ≤ totalMinor ≤ Σ positive amounts.
10
+ */
11
+ export function allocateOrderDiscount(lineAmountsMinor: readonly number[], totalMinor: number): number[] {
12
+ if (!Number.isSafeInteger(totalMinor) || totalMinor < 0 || !lineAmountsMinor.every(Number.isSafeInteger)) {
13
+ throw new RangeError('Amounts must be safe integers and the discount non-negative');
14
+ }
15
+ const weights = lineAmountsMinor.map((amount) => BigInt(Math.max(0, amount)));
16
+ const weightTotal = weights.reduce((sum, weight) => sum + weight, 0n);
17
+ const total = BigInt(totalMinor);
18
+ if (total > weightTotal) throw new RangeError('Order discount exceeds the lines it applies to');
19
+ if (total === 0n) return weights.map(() => 0);
20
+
21
+ // BigInt keeps total × weight exact however large the order is.
22
+ const shares = weights.map((weight) => (total * weight) / weightTotal);
23
+ const remainders = weights.map((weight) => (total * weight) % weightTotal);
24
+ let leftover = total - shares.reduce((sum, share) => sum + share, 0n);
25
+ const ranked = remainders.map((_, index) => index)
26
+ .sort((a, b) => remainders[a] === remainders[b] ? a - b : remainders[a] > remainders[b] ? -1 : 1);
27
+ // Each leftover unit goes to a line with a non-zero remainder, whose share is still below its amount.
28
+ for (const index of ranked) {
29
+ if (leftover === 0n) break;
30
+ shares[index] += 1n;
31
+ leftover -= 1n;
32
+ }
33
+ return shares.map(Number);
34
+ }
@@ -1,12 +1,17 @@
1
1
  export { createOrderBuilder } from './order-builder';
2
+ export { allocateOrderDiscount } from './allocate-order-discount';
2
3
  export type { OrderBuilder, OrderBuilderOptions } from './order-builder';
3
4
  export { createOrderManager } from './order-manager';
4
5
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order-manager';
5
6
  export type {
6
7
  Order,
7
8
  LineItem,
9
+ LineTaxLine,
10
+ AddLineInput,
8
11
  Discount,
9
12
  AppliedDiscount,
13
+ DisplayTotals,
14
+ DisplayLine,
10
15
  Payment,
11
16
  CustomerSummary,
12
17
  PaymentMethod,
@@ -1,7 +1,8 @@
1
1
  import { BehaviorSubject, type Observable } from 'rxjs';
2
- import type { ProductTraits } from '@tallyui/core';
2
+ import { resolvePrice, type ProductTraits } from '@tallyui/core';
3
3
  import type { TaxContext } from '../tax/types';
4
- import { calculateTax } from '../tax/calculate';
4
+ import { taxMicros, roundMicrosToMinor } from '../tax/exact';
5
+ import { allocateOrderDiscount } from './allocate-order-discount';
5
6
  import type {
6
7
  Order,
7
8
  LineItem,
@@ -9,6 +10,7 @@ import type {
9
10
  AppliedDiscount,
10
11
  Payment,
11
12
  CustomerSummary,
13
+ AddLineInput,
12
14
  } from './types';
13
15
 
14
16
  let nextId = 0;
@@ -16,6 +18,40 @@ function uid(): string {
16
18
  return `${Date.now()}-${++nextId}`;
17
19
  }
18
20
 
21
+ function roundHalfAway(n: number): number {
22
+ return Math.sign(n) * Math.round(Math.abs(n));
23
+ }
24
+
25
+ /** The settlement totals of recalculated lines; the display figures derive from them (ADR-063). */
26
+ function sumLines(lines: LineItem[]): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
27
+ const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
28
+ const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
29
+ const exclusiveTaxMicros = lines.reduce((sum, li) => li.taxInclusive ? sum : sum + BigInt(li.taxMicros), 0n);
30
+ const taxMinor = roundMicrosToMinor(lineTaxMicros);
31
+ // Each line pays in its own mode: an inclusive line its gross, an exclusive line its net plus tax.
32
+ // The totals sum the lines as recalculated; nothing is subtracted after tax.
33
+ const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros);
34
+ return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
35
+ }
36
+
37
+ /** An own-mode amount on a line, in the display mode, converted on its own at the line's rates (ADR-063). */
38
+ function toDisplayMode(line: LineItem, amountMinor: number, displayInclusive: boolean): number {
39
+ if (line.taxInclusive === displayInclusive) return amountMinor;
40
+ const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
41
+ const taxMinor = roundMicrosToMinor(taxMicros(amountMinor, ratePpm, line.taxInclusive));
42
+ return line.taxInclusive ? amountMinor - taxMinor : amountMinor + taxMinor;
43
+ }
44
+
45
+ /**
46
+ * The display residue's guard (ADR-063); throws when the arithmetic is wrong rather than rounded. With no converted
47
+ * line it's exactly 0 (Σ gross = Σ net + Σ discounts). Otherwise each of the n non-zero rounded conversions is off by
48
+ * at most half a cent, and the order-level tax rounding by at most one more, so |residue| ≤ ⌊n/2⌋ + 1.
49
+ */
50
+ export function assertDisplayResidue(residue: number, convertedLines: number, conversions: number): void {
51
+ const bound = convertedLines === 0 ? 0 : Math.floor(conversions / 2) + 1;
52
+ if (Math.abs(residue) > bound) throw new Error(`Display residue ${residue} exceeds its bound of ${bound} (ADR-063)`);
53
+ }
54
+
19
55
  export interface OrderBuilderOptions {
20
56
  currency: string;
21
57
  taxContext: TaxContext;
@@ -25,6 +61,7 @@ export interface OrderBuilderOptions {
25
61
  export interface OrderBuilder {
26
62
  order$: Observable<Order>;
27
63
  addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
64
+ addLine(input: AddLineInput): string;
28
65
  updateQuantity(lineId: string, quantity: number): void;
29
66
  removeItem(lineId: string): void;
30
67
  applyLineDiscount(lineId: string, discount: Discount): void;
@@ -39,7 +76,8 @@ export interface OrderBuilder {
39
76
  }
40
77
 
41
78
  export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
42
- const { currency, taxContext } = options;
79
+ const { taxContext } = options;
80
+ const currency = options.currency.toUpperCase();
43
81
  const orderId = options.id ?? uid();
44
82
 
45
83
  let lineItems: LineItem[] = [];
@@ -51,81 +89,156 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
51
89
 
52
90
  const subject = new BehaviorSubject<Order>(buildOrder());
53
91
 
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);
92
+ /** Stored lines carry no order share; buildOrder recalculates each with its allocated share (ADR-062). */
93
+ function recalculateLine(line: LineItem, orderDiscountMinor = 0): LineItem {
94
+ const grossMinor = line.unitPriceMinor * line.quantity;
95
+
96
+ // Each amount is what that discount actually removed, capped at what's left, matching buildOrder's order-discount path (ADR-062).
97
+ let remaining = grossMinor;
98
+ const recalcedDiscounts = line.discounts.map((d) => {
99
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === 'percentage' ? grossMinor : remaining));
100
+ remaining -= amountMinor;
101
+ return { ...d, amountMinor };
102
+ });
103
+
104
+ // The order share never exceeds what the line discounts leave (allocateOrderDiscount's bound).
105
+ const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
106
+ const discountMinor = lineDiscountMinor + orderDiscountMinor;
107
+ const netMinor = grossMinor - discountMinor;
108
+ const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
109
+ // Tax in the line's own mode, from its own unit price × quantity: never re-tax a rounded base.
110
+ const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
111
+ let allocatedTax = 0n;
112
+ const taxLines = line.taxLines.map((tax, index) => {
113
+ const micros = line.taxInclusive
114
+ ? index === line.taxLines.length - 1
115
+ ? inclusiveTax - allocatedTax
116
+ : combinedRate === 0 ? 0n : inclusiveTax * BigInt(tax.ratePpm) / BigInt(combinedRate)
117
+ : taxMicros(netMinor, tax.ratePpm, false);
118
+ allocatedTax += micros;
119
+ return { ...tax, taxMicros: micros.toString() };
120
+ });
66
121
 
67
122
  return {
68
123
  ...line,
69
124
  discounts: recalcedDiscounts,
70
- discountAmount,
71
- taxAmount: tax.taxAmount,
72
- lineTotal: taxContext.pricesIncludeTax ? afterDiscount : afterDiscount + tax.taxAmount,
125
+ discountMinor,
126
+ orderDiscountMinor,
127
+ netMinor,
128
+ taxLines,
129
+ taxMicros: allocatedTax.toString(),
73
130
  };
74
131
  }
75
132
 
76
133
  function computeDiscountAmount(discount: Discount, base: number): number {
134
+ // Clamped here so both line and order discounts can never go negative (a negative
135
+ // percentage or fixed value would otherwise raise the price instead of lowering it).
77
136
  if (discount.type === 'percentage') {
78
- return Math.round((base * discount.value) / 100 * 100) / 100;
137
+ return Math.max(0, roundHalfAway(base * discount.value / 100));
79
138
  }
80
- return Math.min(discount.value, base);
139
+ if (!Number.isInteger(discount.value)) throw new RangeError('Fixed discount must be integer minor units');
140
+ return Math.max(0, Math.min(discount.value, base));
81
141
  }
82
142
 
83
143
  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;
144
+ // Order discounts are pre-tax (ADR-062). The base is the lines' pre-order-discount amounts, each in its
145
+ // own mode (an inclusive line's shelf amount, an exclusive line's net). A percentage discount is additive:
146
+ // each is computed on that base, not on what an earlier order discount leaves (WCPOS `next` / WooCommerce
147
+ // parity). A fixed discount is computed on what remains, as before. Every discount is then capped at what
148
+ // remains, which decreases as each is applied, and the total is allocated to the lines, which are then
149
+ // taxed on what is left.
150
+ const lineAmounts = lineItems.map((li) => li.netMinor);
151
+ const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
152
+ let remaining = base;
153
+ const recalcedOrderDiscounts = orderDiscounts.map((d) => {
154
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === 'percentage' ? base : remaining));
155
+ remaining -= amountMinor;
156
+ return { ...d, amountMinor };
157
+ });
158
+ const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
159
+ const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
160
+
161
+ const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines);
162
+ const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
163
+ // Display figures (ADR-063): every discount the cashier entered, and each line's order share, converted on its own
164
+ // into the display mode. A line in the display mode shows quantity × unit price; a converted line shows its
165
+ // converted remaining (netMinor) plus its rows and share, so it's never less than them. The subtotal is derived
166
+ // from the settlement total, so every sum is exact; the rounding residue goes to the converted lines' amounts,
167
+ // never to a discount or an unconverted line.
168
+ const taxInclusive = taxContext.pricesIncludeTax;
169
+ const figures = lines.map((li) => {
170
+ // A negative-priced (return) line shows what settlement charges for it (recalculateLine caps it at 0 today),
171
+ // with no rows: its capped "discounts" are the negative gross cancelled, not money off.
172
+ const returned = li.unitPriceMinor < 0;
173
+ const discounts = returned ? [] : li.discounts.map((d) => ({
174
+ discountId: d.id, ...(d.label !== undefined ? { label: d.label } : {}), amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive),
175
+ }));
176
+ const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
177
+ const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
178
+ const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity
179
+ : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
180
+ return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
181
+ });
182
+ const displayLines = figures.map((f) => f.line);
183
+ const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
184
+ const displayDiscount = displayLines.reduce(
185
+ (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
186
+ const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
187
+ const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
188
+ const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
189
+ const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
190
+ ...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
191
+ assertDisplayResidue(residue, converted.length, conversions);
192
+ // Largest converted remaining first (ties to the earlier line). A negative residue takes a line down to its rows
193
+ // and share at most, then moves on: six 2-cent inclusive lines can owe −3 with no line above 2. The spill is a
194
+ // crash guard; it never fired in a 200k-cart fuzz. Negative-priced (return) lines convert as they are and never
195
+ // take residue; refunds get their own design.
196
+ let left = residue;
197
+ const takers = converted.filter((index) => !figures[index].returned);
198
+ for (const index of takers.sort((a, b) => figures[b].remainingMinor - figures[a].remainingMinor || a - b)) {
199
+ if (left === 0) break;
200
+ const take = left > 0 ? left : Math.max(left, -figures[index].remainingMinor);
201
+ displayLines[index].amountMinor += take;
202
+ left -= take;
203
+ }
204
+ if (left !== 0) throw new Error(`Display residue ${left} left over with no converted line to take it (ADR-063)`);
205
+ // Checked here, not only in tests: every figure >= 0, and no line shows less than its own rows and share (lines
206
+ // with a non-negative gross; a return line's negative amount is its own).
207
+ if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
208
+ for (const { line, shareMinor, returned } of figures) {
209
+ if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
210
+ if (returned) continue;
211
+ const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
212
+ if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
213
+ throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
89
214
  }
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);
215
+ }
216
+ const display = {
217
+ taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
218
+ lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
219
+ };
220
+ const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
221
+ const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
222
+ const changeDueMinor = Math.max(0, paidMinor - totalMinor);
113
223
 
114
224
  return {
115
225
  id: orderId,
116
226
  status: 'draft',
117
- lineItems: [...lineItems],
227
+ lineItems: lines,
118
228
  discounts: recalcedOrderDiscounts,
119
229
  payments: [...payments],
120
230
  customer,
121
231
  note,
122
- subtotal,
123
- discountTotal: Math.round(discountTotal * 100) / 100,
124
- taxTotal: Math.round(taxTotal * 100) / 100,
125
- total: roundedTotal,
126
- balanceDue,
127
- changeDue,
232
+ subtotalMinor,
233
+ discountMinor,
234
+ taxMinor,
235
+ totalMinor,
236
+ display,
237
+ paidMinor,
238
+ balanceDueMinor,
239
+ changeDueMinor,
128
240
  currency,
241
+ pricesIncludeTax: taxContext.pricesIncludeTax,
129
242
  createdAt: now,
130
243
  updatedAt: new Date().toISOString(),
131
244
  };
@@ -135,51 +248,83 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
135
248
  subject.next(buildOrder());
136
249
  }
137
250
 
251
+ function addLine(input: AddLineInput): string {
252
+ const { productId, variantId, unitPrice } = input;
253
+ const quantity = input.quantity ?? 1;
254
+ if (unitPrice.currency !== currency) throw new RangeError('Line currency must match ' + currency);
255
+ if (!Number.isInteger(unitPrice.amount)) throw new RangeError('Price must be integer minor units');
256
+ if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
257
+ const taxRates = input.taxRates ?? [{ ratePpm: taxContext.getTaxRatePpm() }];
258
+ const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
259
+
260
+ const existing = lineItems.find(
261
+ (li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
262
+ && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
263
+ && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
264
+ );
265
+
266
+ if (existing) {
267
+ lineItems = lineItems.map((li) =>
268
+ li.id === existing.id ? recalculateLine({ ...li, quantity: li.quantity + quantity }) : li,
269
+ );
270
+ emit();
271
+ return existing.id;
272
+ }
273
+
274
+ const lineId = uid();
275
+ const line: LineItem = {
276
+ id: lineId,
277
+ productId,
278
+ variantId,
279
+ name: input.name,
280
+ sku: input.sku ?? '',
281
+ imageUrl: input.imageUrl,
282
+ unitPriceMinor: unitPrice.amount,
283
+ quantity,
284
+ taxLines: taxRates.map((tax) => ({ ...tax, taxMicros: '0' })),
285
+ discounts: [],
286
+ discountMinor: 0,
287
+ orderDiscountMinor: 0,
288
+ netMinor: 0,
289
+ taxMicros: '0',
290
+ taxInclusive,
291
+ // TV4b derives the store-level flag with Medusa's own precedence (region
292
+ // preference, then currency), so a disagreement should be rare. It can
293
+ // still happen, for example when a price preference changes after the
294
+ // settings were read. Keeping the line in its own mode means the customer
295
+ // pays exactly the shelf price, as Medusa's checkout would.
296
+ ...(taxInclusive !== taxContext.pricesIncludeTax
297
+ ? { priceTaxModeConverted: taxInclusive ? 'inclusive-to-exclusive' as const : 'exclusive-to-inclusive' as const }
298
+ : {}),
299
+ };
300
+
301
+ lineItems = [...lineItems, recalculateLine(line)];
302
+ emit();
303
+ return lineId;
304
+ }
305
+
138
306
  return {
139
307
  order$: subject.asObservable(),
308
+ addLine,
140
309
 
141
310
  addProduct(doc, traits, opts) {
142
311
  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,
312
+ if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
313
+ const variant = opts?.variantId !== undefined && traits.getVariants
314
+ ? traits.getVariants(doc, { currency }).find((variant) => variant.id === opts.variantId) : undefined;
315
+ if (opts?.variantId !== undefined && traits.getVariants && !variant)
316
+ throw new Error(`Unknown variant ${opts.variantId} for product ${productId}`);
317
+ const unitPrice = resolvePrice(variant ? variant.prices : traits.getPrices(doc, { currency }), currency)?.current;
318
+ if (!unitPrice) throw new Error('No price in ' + currency + ' for product ' + productId);
319
+ return addLine({
166
320
  productId,
167
- variantId,
321
+ variantId: opts?.variantId,
168
322
  name: traits.getName(doc),
169
- sku: traits.getSku(doc) ?? '',
323
+ sku: variant?.sku ?? traits.getSku(doc),
170
324
  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;
325
+ unitPrice,
326
+ quantity: opts?.quantity,
327
+ });
183
328
  },
184
329
 
185
330
  updateQuantity(lineId, quantity) {
@@ -202,11 +347,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
202
347
  applyLineDiscount(lineId, discount) {
203
348
  lineItems = lineItems.map((li) => {
204
349
  if (li.id !== lineId) return li;
205
- const base = li.price * li.quantity;
350
+ const base = li.unitPriceMinor * li.quantity;
206
351
  const applied: AppliedDiscount = {
207
352
  ...discount,
208
353
  id: uid(),
209
- amount: computeDiscountAmount(discount, base),
354
+ amountMinor: computeDiscountAmount(discount, base),
210
355
  };
211
356
  return recalculateLine({ ...li, discounts: [...li.discounts, applied] });
212
357
  });
@@ -217,7 +362,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
217
362
  const applied: AppliedDiscount = {
218
363
  ...discount,
219
364
  id: uid(),
220
- amount: 0,
365
+ amountMinor: computeDiscountAmount(discount, 0),
221
366
  };
222
367
  orderDiscounts = [...orderDiscounts, applied];
223
368
  emit();