@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -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 +270 -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 +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -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 +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "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
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
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": "
|
|
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": "
|
|
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 {
|
|
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(): (
|
|
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
|
-
(
|
|
30
|
-
[ctx.
|
|
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
|
+
}
|
package/src/currency/index.ts
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export {
|
|
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 {
|
|
6
|
+
export { CurrencyProvider, useCurrencyFormatter, useCurrencyCode } from './currency';
|
|
7
7
|
export type { CurrencyProviderProps } from './currency';
|
|
8
8
|
|
|
9
9
|
// Tax
|
|
10
|
-
export {
|
|
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 {
|
|
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
|
+
}
|
package/src/order/index.ts
CHANGED
|
@@ -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
|
|
2
|
+
import { resolvePrice, type ProductTraits } from '@tallyui/core';
|
|
3
3
|
import type { TaxContext } from '../tax/types';
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
137
|
+
return Math.max(0, roundHalfAway(base * discount.value / 100));
|
|
79
138
|
}
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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);
|
|
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:
|
|
227
|
+
lineItems: lines,
|
|
118
228
|
discounts: recalcedOrderDiscounts,
|
|
119
229
|
payments: [...payments],
|
|
120
230
|
customer,
|
|
121
231
|
note,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
172
|
-
quantity,
|
|
173
|
-
|
|
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.
|
|
350
|
+
const base = li.unitPriceMinor * li.quantity;
|
|
206
351
|
const applied: AppliedDiscount = {
|
|
207
352
|
...discount,
|
|
208
353
|
id: uid(),
|
|
209
|
-
|
|
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
|
-
|
|
365
|
+
amountMinor: computeDiscountAmount(discount, 0),
|
|
221
366
|
};
|
|
222
367
|
orderDiscounts = [...orderDiscounts, applied];
|
|
223
368
|
emit();
|