@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
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What the register should hold at close, per tender method: the session's cash float plus
3
+ * captured payments and cash movements — never what the cashier counts. Port provenance
4
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
5
+ *
6
+ * **Refund attribution is deferred.** WCPOS's `attributeRefunds` debits a session's drawer for
7
+ * refunds processed against its captured payments (by stamped session, then by a legacy
8
+ * `refunded_amount` fallback). TallyUI has no refund model yet (ADR-032 amendment 1), so
9
+ * `deriveExpected` covers only the float, the session's captured payment rows, and its
10
+ * paid-in/paid-out cash movements under WCPOS's void rules.
11
+ */
12
+
13
+ /** `session_id`, `kind`, `method_id`, `status` kept as WCPOS names them; money is TallyUI's integer-minor-units convention. */
14
+ export type LedgerRow = {
15
+ session_id?: string | null;
16
+ kind: string;
17
+ method_id: string;
18
+ status: string;
19
+ amountMinor: number;
20
+ };
21
+
22
+ /** `type`, `voids` and `voided_by` are kept as WCPOS names them: neutral across backends. */
23
+ export type Movement = {
24
+ id: string;
25
+ session_id: string;
26
+ type: string;
27
+ amountMinor: number;
28
+ voided_by?: string | null;
29
+ voids?: string | null;
30
+ };
31
+
32
+ /**
33
+ * Expected totals per tender method: the counted float, plus every captured ledger row for the
34
+ * session (grouped under `cash` for cash rows, else `method_id`), plus its non-voided
35
+ * paid-in/paid-out movements.
36
+ *
37
+ * A movement is excluded when its own `voided_by` is set, or when a `type: 'void'` row's
38
+ * `voids` names its id; the `void` row itself carries no amount of its own.
39
+ */
40
+ export function deriveExpected({
41
+ session,
42
+ movements,
43
+ ledgerRowsBySession,
44
+ }: {
45
+ session: { id: string; countedFloatMinor: number };
46
+ movements: readonly Movement[];
47
+ ledgerRowsBySession: readonly LedgerRow[];
48
+ }): Record<string, number> {
49
+ const totals: Record<string, number> = { cash: session.countedFloatMinor };
50
+ for (const row of ledgerRowsBySession) {
51
+ if (row.session_id !== session.id || row.status !== 'captured') continue;
52
+ const method = row.kind === 'cash' ? 'cash' : row.method_id;
53
+ totals[method] = (totals[method] ?? 0) + row.amountMinor;
54
+ }
55
+ const voids = new Set(
56
+ movements
57
+ .filter((row) => row.session_id === session.id && row.type === 'void')
58
+ .map((row) => row.voids),
59
+ );
60
+ for (const row of movements) {
61
+ if (row.session_id !== session.id || row.voided_by || voids.has(row.id)) continue;
62
+ if (row.type === 'paid_in') totals.cash += row.amountMinor;
63
+ if (row.type === 'paid_out') totals.cash -= row.amountMinor;
64
+ }
65
+ return totals;
66
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A CSV export of the closures currently shown: one column per tender the visible rows use, an
3
+ * expected-only tender kept as a zero-counted shortage, quoted/escaped cells with a CRLF line
4
+ * ending, and a leading `'` on formula- or control-prefixed text so a spreadsheet reads it as
5
+ * text (LEDGER.md #26, #27). Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`
6
+ * `export-csv.ts`. Money is a2's integer minor units: an `exponent` parameter (as in a1's
7
+ * `parseMinor`/`varianceText`) formats each cell back to the decimal string WCPOS's server sent,
8
+ * e.g. `1250` at exponent 2 becomes `'12.50'`.
9
+ */
10
+ import { minorToDecimal } from './money';
11
+ import type { Closure } from './schemas';
12
+
13
+ /** Widens a tender-map lookup back to `number | undefined`: a method absent from the map. */
14
+ const at = (map: Record<string, number>, key: string): number | undefined => map[key];
15
+
16
+ export function exportCsv(
17
+ rows: readonly Closure[],
18
+ exponent: number,
19
+ t: (key: string) => string,
20
+ names: Record<string, string> = {},
21
+ storeName = '',
22
+ ) {
23
+ const tenders = [
24
+ ...new Set(
25
+ rows.flatMap((row) => [
26
+ ...Object.keys(row.expected),
27
+ ...Object.keys(row.counted),
28
+ ...Object.keys(row.variance),
29
+ ]),
30
+ ),
31
+ ];
32
+ const cell = (value: unknown) => {
33
+ const text = String(value ?? '');
34
+ return `"${(/^(?:[\t\r\n]|[\s\x00-\x1f\x7f-\x9f]*[=+\-@])/.test(text) ? "'" + text : text).replace(/"/g, '""')}"`;
35
+ };
36
+ const header = ['business_day', 'closure', 'register', 'store', 'opened', 'closed', 'closer'].map((key) =>
37
+ t(`reports.csv_${key}`),
38
+ );
39
+ header.push(
40
+ ...tenders.flatMap((method) => [
41
+ `${t('reports.document.expected')} (${method})`,
42
+ `${t('register.counted')} (${method})`,
43
+ `${t('register.variance')} (${method})`,
44
+ ]),
45
+ t('reports.csv_status'),
46
+ );
47
+ return [
48
+ header,
49
+ ...rows.map((row) => [
50
+ row.business_day,
51
+ row.number,
52
+ row.breakdowns.register_name || names[row.register_id] || row.register_id.slice(0, 8),
53
+ row.breakdowns.store_name || storeName || row.store_key,
54
+ row.opened_at,
55
+ row.closed_at,
56
+ row.breakdowns.closed_by_name || t('register.unknown_cashier'),
57
+ ...tenders.flatMap((method) => {
58
+ const expected = at(row.expected, method);
59
+ const counted = at(row.counted, method) ?? (expected === undefined ? undefined : 0);
60
+ const value = at(row.variance, method) ?? (expected === undefined ? undefined : (counted ?? 0) - expected);
61
+ return [
62
+ expected === undefined ? '' : minorToDecimal(expected, exponent),
63
+ counted === undefined ? '' : minorToDecimal(counted, exponent),
64
+ value === undefined
65
+ ? ''
66
+ : `${minorToDecimal(Math.abs(value), exponent)} ${t(value < 0 ? 'register.short' : value > 0 ? 'register.over' : 'reports.exact')}`,
67
+ ];
68
+ }),
69
+ row.unsynced_count > 0 ? t('register.unsynced') : row.corrections_count ? t('reports.corrected') : '',
70
+ ]),
71
+ ]
72
+ .map((row) => row.map(cell).join(','))
73
+ .join('\r\n');
74
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Register facts (ADR-032): a closed vocabulary of human register actions, logged through
3
+ * TallyUI's own logger. Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`
4
+ * `audit.ts`, `RegisterFact` and `recordRegisterFact` only — `useRegisterActor` is React and
5
+ * belongs to job c, and so do the outbox/bridge-diagnostic facts (`outbox-approval-refused`,
6
+ * `outbox-request-failed`, `movement-retry-requested`, `movement-accepted`, `session-adopted`,
7
+ * `session-pruned`, `binding-changed`, `binding-removed`, `directory-unavailable`,
8
+ * `bridge-cycle-failed`): a2 has no outbox or hardware-bridge concept yet to attach them to.
9
+ *
10
+ * Neutral changes from the WCPOS source: WCPOS's `mintUuid` (`register-document.ts`) becomes
11
+ * a2's own; a person id (`approvedBy`) is a2's string id, not WCPOS's numeric one; money fields
12
+ * are a2's integer minor units, matching `CashMovement.amountMinor` and `Closure.counted`/
13
+ * `variance`.
14
+ *
15
+ * TallyUI's own addition (registers c1a): `late-sale`, a sale whose money was taken but whose
16
+ * session refused the stamp (ADR-032 amendment "Late sale"). Its `sessionId` is the session it
17
+ * tried, and its durable subject is the order.
18
+ */
19
+ import { createLogger } from '../logging';
20
+ import { mintUuid } from './register-document';
21
+ import type { CashMovement, Closure, RegisterSession } from './schemas';
22
+
23
+ /** The register-session logging scope; the app attaches its own sinks (console, remote, toast). */
24
+ export const registerFactsLogger = createLogger('register-session');
25
+
26
+ const attempt = () => ({ operationId: mintUuid().replace(/-/g, '') });
27
+
28
+ export type Actor = { id: string; name: string };
29
+ type Pair = { sessionId: string | undefined; registerId: string | null | undefined };
30
+ type Human = Pair & { actor: Actor };
31
+ type Movement = Pair & { movementId: string; movementType: CashMovement['type']; amount: number };
32
+
33
+ type SimpleKind = 'counting-started' | 'counting-abandoned' | 'approval-refused' | 'x-report-dispatched' | 'drawer-dispatched';
34
+ export type RegisterFact =
35
+ | (Human & { kind: 'session-opened'; amount: number; variance: RegisterSession['opening_variance_minor'] })
36
+ | (Human & { kind: SimpleKind })
37
+ | (Human & { kind: 'session-closed'; closureId: string } & Pick<Closure, 'number' | 'counted' | 'variance'>)
38
+ | (Human & Movement & { kind: 'movement-recorded' })
39
+ | (Human & Movement & { kind: 'movement-voided'; voids: string })
40
+ | (Human & { kind: 'approval-granted'; approvedBy: string | null })
41
+ | (Human & { kind: 'variance-over-threshold'; variance: number; threshold: number | undefined })
42
+ | (Pair & { kind: 'late-sale'; orderId: string; actor?: Actor });
43
+
44
+ /** A human action without its own durable record gets a fresh attempt id; a durable action (a
45
+ * session, a movement or a late sale's order) uses the stable id of its subject. */
46
+ function identity(fact: RegisterFact): { operationId: string } {
47
+ switch (fact.kind) {
48
+ case 'session-opened':
49
+ return { operationId: fact.sessionId?.replace(/-/g, '') ?? '' };
50
+ case 'session-closed':
51
+ return { operationId: fact.closureId.replace(/-/g, '') };
52
+ case 'movement-recorded':
53
+ case 'movement-voided':
54
+ return { operationId: fact.movementId.replace(/-/g, '') };
55
+ case 'late-sale':
56
+ return { operationId: fact.orderId.replace(/-/g, '') };
57
+ default:
58
+ return attempt();
59
+ }
60
+ }
61
+
62
+ export function recordRegisterFact(fact: RegisterFact): void {
63
+ const terminal = identity(fact);
64
+ const pair = { sessionId: fact.sessionId, registerId: fact.registerId };
65
+ const emit = (
66
+ message: string,
67
+ type: string,
68
+ context: Record<string, unknown> = {},
69
+ level: 'info' | 'warn' = 'info',
70
+ ) => registerFactsLogger[level](message, { actor: fact.actor, terminal, context: { type, ...pair, ...context } });
71
+ switch (fact.kind) {
72
+ case 'session-opened':
73
+ return emit('Register session opened', 'register.session-opened', { amount: fact.amount, variance: fact.variance });
74
+ case 'counting-started':
75
+ return emit('Register session counting started', 'register.counting-started');
76
+ case 'counting-abandoned':
77
+ return emit('Register session counting abandoned', 'register.counting-abandoned');
78
+ case 'session-closed':
79
+ return emit('Register session closed', 'register.session-closed', {
80
+ closureId: fact.closureId, number: fact.number, counted: fact.counted, variance: fact.variance,
81
+ });
82
+ case 'movement-recorded':
83
+ // No-sale is the cashier action, independent of drawer dispatch, and makes no cash claim.
84
+ if (fact.movementType === 'no_sale')
85
+ return emit('Register no-sale recorded', 'register.no-sale-recorded', { movementId: fact.movementId });
86
+ return emit('Register cash movement recorded', 'register.movement-recorded', {
87
+ movementId: fact.movementId, movementType: fact.movementType, amount: fact.amount,
88
+ });
89
+ case 'movement-voided':
90
+ return emit('Register cash movement voided', 'register.movement-voided', {
91
+ movementId: fact.movementId, movementType: fact.movementType, amount: fact.amount, voids: fact.voids,
92
+ });
93
+ case 'approval-granted':
94
+ return emit('Register session approval granted', 'register.approval-granted', { approvedBy: fact.approvedBy });
95
+ case 'approval-refused':
96
+ return emit('Register session approval refused', 'register.approval-refused', {}, 'warn');
97
+ case 'variance-over-threshold':
98
+ return emit('Register count exceeds variance threshold', 'register.variance-over-threshold',
99
+ { variance: fact.variance, threshold: fact.threshold }, 'warn');
100
+ // These prove dispatch success, not that paper printed or a physical drawer opened.
101
+ case 'x-report-dispatched':
102
+ return emit('Register X-report print dispatched', 'register.x-report-printed');
103
+ case 'drawer-dispatched':
104
+ return emit('Register drawer kick dispatched', 'register.drawer-opened');
105
+ // The money was taken, so the sale is kept and queued, outside every closure.
106
+ case 'late-sale':
107
+ return emit('Register late sale kept outside its session', 'register.late-sale', { orderId: fact.orderId }, 'warn');
108
+ }
109
+ }
@@ -0,0 +1,30 @@
1
+ export { normalizeAmount, isServerDecimal, movementFieldError } from './movement-input';
2
+ export type { MovementType } from './movement-input';
3
+ export { deriveExpected } from './expected';
4
+ export type { LedgerRow, Movement } from './expected';
5
+ export { parseMinor, validAmount, countVariance, overThreshold, closeNeedsApproval, denominationTotal, varianceText } from './register-count.helpers';
6
+ export { denominations } from './register-count.denominations';
7
+ export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './schemas';
8
+ export type { RegisterSession, CashMovement, Closure } from './schemas';
9
+ export {
10
+ mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister,
11
+ unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual,
12
+ } from './register-document';
13
+ export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
14
+ export {
15
+ RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementStrandedError, openSessionSelector,
16
+ requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
17
+ } from './session-store';
18
+ export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
19
+ export { deriveSettled } from './settled-figures';
20
+ export type { Correction, RecordedFigures } from './settled-figures';
21
+ export { exportCsv } from './export-csv';
22
+ export { labelKeys } from './document-labels';
23
+ export { clampClosureScope, selectClosureRows } from './closure-rows';
24
+ export type { ClosureScope } from './closure-rows';
25
+ export { minorToDecimal } from './money';
26
+ export { formatClosureDate, buildClosureDocument, buildXReportDocument } from './closure-document';
27
+ export type { ClosureContext } from './closure-document';
28
+ export { registerFactsLogger, recordRegisterFact, type Actor, type RegisterFact } from './facts';
29
+ export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError } from './use-register-session';
30
+ export type { UseRegisterSessionOptions } from './use-register-session';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Minor-unit money formatting shared across the register package (moved out of `export-csv.ts` so
3
+ * `closure-document.ts` reuses the same implementation instead of duplicating it).
4
+ */
5
+
6
+ /**
7
+ * `minor` as a fixed-point decimal string with exactly `exponent` places: `-50` at exponent 2 is
8
+ * `'-0.50'`, `1250` is `'12.50'`, and at exponent 0, `1500` is `'1500'`. Integer maths only —
9
+ * sign, `Math.trunc`, and the remainder padded to `exponent` digits — no `toFixed` on floats.
10
+ */
11
+ export function minorToDecimal(minor: number, exponent: number): string {
12
+ const sign = minor < 0 ? '-' : '';
13
+ const abs = Math.abs(minor);
14
+ const scale = 10 ** exponent;
15
+ const whole = Math.trunc(abs / scale);
16
+ const fraction = abs % scale;
17
+ return exponent === 0 ? `${sign}${whole}` : `${sign}${whole}.${String(fraction).padStart(exponent, '0')}`;
18
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A cash-movement grammar a server can enforce, mirrored on the device.
3
+ *
4
+ * The limits come from WCPOS's server, which refuses a movement whose amount is not a plain
5
+ * unsigned decimal string, whose integer part carries more than fifteen significant digits,
6
+ * whose paid in/out amount is not positive, or whose reason is blank or over 500 characters.
7
+ * Gating the confirm button on `Number(amount) > 0` alone let several of those through, and a
8
+ * refused movement is cash that has already physically moved: the row is marked permanently
9
+ * failed and never reaches the ledger.
10
+ *
11
+ * No amount here is ever minor units — this mirror of the server's grammar only ever sees the
12
+ * string the cashier typed. Port provenance and re-sync notes: ADR-032 amendment 1.
13
+ */
14
+ const SERVER_DECIMAL = /^\d+(?:\.\d+)?$/;
15
+ const MAX_INTEGER_DIGITS = 15;
16
+ const MAX_REASON_LENGTH = 500;
17
+
18
+ export type MovementType = 'paid_in' | 'paid_out' | 'no_sale';
19
+
20
+ /**
21
+ * Rewrite what a keypad produced into the server's grammar, but only where the cashier's
22
+ * intent is unambiguous: a bare leading or trailing point is a half-typed number, and a comma
23
+ * followed by one or two digits is a decimal separator on a Spanish or German keypad.
24
+ *
25
+ * The digit count is the whole safeguard. `"1,000"` is a thousands separator to an English
26
+ * cashier on a hardware keyboard and a decimal to nobody, so converting it would silently
27
+ * record a movement of ONE — money quietly lost, which is the exact failure this module
28
+ * exists to prevent. Anything ambiguous is left as typed so it fails visibly instead.
29
+ */
30
+ const KEYPAD_DECIMAL_COMMA = /^(\d+),(\d{1,2})$/;
31
+ export function normalizeAmount(raw: string): string {
32
+ const trimmed = raw.trim();
33
+ const dotted = trimmed.replace(KEYPAD_DECIMAL_COMMA, '$1.$2');
34
+ return dotted.replace(/^\./, '0.').replace(/\.$/, '');
35
+ }
36
+
37
+ /** Whether the server's `decimal()` would accept this string. */
38
+ export function isServerDecimal(value: string): boolean {
39
+ if (!SERVER_DECIMAL.test(value)) return false;
40
+ return value.split('.')[0].replace(/^0+/, '').length <= MAX_INTEGER_DIGITS;
41
+ }
42
+
43
+ /**
44
+ * The field the server would name in its 400, or null when it would accept the movement.
45
+ * A no sale carries no amount — the sheet sends '0' — but still needs a reason.
46
+ */
47
+ export function movementFieldError(input: {
48
+ type: MovementType;
49
+ amount: string;
50
+ reason: string;
51
+ }): 'amount' | 'reason' | null {
52
+ if (input.type !== 'no_sale') {
53
+ const amount = normalizeAmount(input.amount);
54
+ if (!isServerDecimal(amount) || Number(amount) <= 0) return 'amount';
55
+ }
56
+ const reason = input.reason.trim();
57
+ if (!reason || reason.length > MAX_REASON_LENGTH) return 'reason';
58
+ return null;
59
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Cash denominations by face value, in integer minor units, largest first (ADR-032 amendment 1:
3
+ * WCPOS `next` `3b5331b5c`). Every list, `default` included, assumes a two-decimal currency.
4
+ */
5
+ const coinsMinor = [200, 100, 50, 20, 10, 5, 2, 1];
6
+ export const denominations: Record<string, number[]> = {
7
+ GBP: [5000, 2000, 1000, 500, ...coinsMinor],
8
+ EUR: [50000, 20000, 10000, 5000, 2000, 1000, 500, ...coinsMinor],
9
+ USD: [10000, 5000, 2000, 1000, 500, 100, 25, 10, 5, 1],
10
+ default: [10000, 5000, 2000, 1000, 500, 200, 100, 50, 25, 10, 5, 1],
11
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure maths behind a register count: variance against the expected float, a display
3
+ * string for it, and the denomination arithmetic the counting keypad uses. Port provenance
4
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
5
+ *
6
+ * Money here is TallyUI's convention: integer minor units. Everywhere WCPOS assumed two
7
+ * decimals, these take the currency's `exponent` instead — 0 for JPY, 3 for KWD — except
8
+ * `validAmount`, which like WCPOS still takes the cashier's typed text.
9
+ */
10
+
11
+ /**
12
+ * Parses a decimal string the cashier typed into integer minor units at `exponent` decimal
13
+ * places, without rounding. Returns `NaN` when the string carries more precision than the
14
+ * currency allows, so a safe-integer check on the result also rejects it.
15
+ */
16
+ export function parseMinor(text: string, exponent: number): number {
17
+ const [whole, fraction = ''] = text.trim().split('.');
18
+ if (fraction.length > exponent) return NaN;
19
+ return Number(whole || '0') * 10 ** exponent + Number(fraction.padEnd(exponent, '0') || '0');
20
+ }
21
+
22
+ export const validAmount = (value: string, exponent: number) =>
23
+ /^(?:\d+\.?\d*|\.\d+)$/.test(value) &&
24
+ Number.isFinite(Number(value)) &&
25
+ Number.isSafeInteger(parseMinor(value, exponent));
26
+
27
+ /** Counted minus expected, both already in minor units. */
28
+ export const countVariance = (countedMinor: number, expectedMinor: number) =>
29
+ countedMinor - expectedMinor;
30
+
31
+ /** Whether the variance exceeds the store's configured threshold (minor units; none means never). */
32
+ export const overThreshold = (variance: number, thresholdMinor?: number | null) =>
33
+ thresholdMinor != null && Math.abs(variance) > thresholdMinor;
34
+
35
+ /**
36
+ * Whether a close's cash count needs a manager's approval: its cash variance against the expected
37
+ * cash is over the threshold. The one definition `RegisterCount` and `useRegisterSession`'s
38
+ * `closeSession` gate share (ADR-032).
39
+ */
40
+ export const closeNeedsApproval = (countedCashMinor: number, expectedCashMinor: number, thresholdMinor?: number | null) =>
41
+ overThreshold(countVariance(countedCashMinor, expectedCashMinor), thresholdMinor);
42
+
43
+ /** Sums denomination face values (already minor units) by piece count. */
44
+ export const denominationTotal = (pieces: Record<number, number>) =>
45
+ Object.entries(pieces).reduce((sum, [value, count]) => sum + Number(value) * count, 0);
46
+
47
+ /**
48
+ * The variance line under the count: exact, short or over. `format` takes a major-unit
49
+ * amount, as an app's currency formatter expects, so `exponent` converts `variance` back for
50
+ * it; `t` supplies the translated words for the `register.exact`/`register.short`/
51
+ * `register.over` keys. Both are the app's own — neutral here.
52
+ *
53
+ * The direction lives in the word, not a sign (the Front desk, 2026-09-28): a signed amount
54
+ * next to "short" read as a double negative, so the amount here is always its absolute value.
55
+ */
56
+ export function varianceText(
57
+ variance: number,
58
+ exponent: number,
59
+ format: (value: number) => string,
60
+ t: (key: string) => string,
61
+ ) {
62
+ if (!variance) return t('register.exact');
63
+ return `${format(Math.abs(variance) / 10 ** exponent)} ${t(variance < 0 ? 'register.short' : 'register.over')}`;
64
+ }