@tallyui/pos 0.1.0 → 3.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2031 -43
  3. package/dist/index.js +4629 -205
  4. package/package.json +23 -12
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +53 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +8 -0
  10. package/src/order/order-builder.ts +256 -95
  11. package/src/order/order-manager.ts +26 -43
  12. package/src/order/tax-figures.ts +23 -0
  13. package/src/order/types.ts +73 -15
  14. package/src/outbox/backend-not-found.ts +27 -0
  15. package/src/outbox/http-transport.ts +67 -0
  16. package/src/outbox/index.ts +9 -0
  17. package/src/outbox/logger.ts +3 -0
  18. package/src/outbox/order-outbox.ts +502 -0
  19. package/src/outbox/register-outbox.ts +250 -0
  20. package/src/outbox/types.test-d.ts +22 -0
  21. package/src/outbox/types.ts +36 -0
  22. package/src/outbox/use-order-outbox.ts +175 -0
  23. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  24. package/src/pos-order/command.ts +123 -0
  25. package/src/pos-order/device-id.ts +23 -0
  26. package/src/pos-order/finalize.ts +219 -0
  27. package/src/pos-order/index.ts +10 -0
  28. package/src/pos-order/needs-attention.ts +16 -0
  29. package/src/pos-order/open.ts +269 -0
  30. package/src/pos-order/same-sale.ts +26 -0
  31. package/src/pos-order/schema.ts +130 -0
  32. package/src/pos-order/types.ts +93 -0
  33. package/src/pos-order/uuidv7.ts +54 -0
  34. package/src/product/index.ts +2 -0
  35. package/src/product/search-products.ts +32 -0
  36. package/src/product/stock.ts +25 -0
  37. package/src/receipt/build-receipt-data.ts +39 -23
  38. package/src/receipt/types.ts +17 -10
  39. package/src/register/__fixtures__/closure-local-row.json +61 -0
  40. package/src/register/__fixtures__/closure.json +197 -0
  41. package/src/register/__fixtures__/corrections-dst.json +63 -0
  42. package/src/register/closure-document.ts +277 -0
  43. package/src/register/closure-rows.ts +65 -0
  44. package/src/register/document-labels.ts +46 -0
  45. package/src/register/expected.ts +66 -0
  46. package/src/register/export-csv.ts +74 -0
  47. package/src/register/facts.ts +109 -0
  48. package/src/register/index.ts +32 -0
  49. package/src/register/money.ts +18 -0
  50. package/src/register/movement-input.ts +59 -0
  51. package/src/register/register-commands.ts +147 -0
  52. package/src/register/register-count.denominations.ts +11 -0
  53. package/src/register/register-count.helpers.ts +64 -0
  54. package/src/register/register-document.ts +268 -0
  55. package/src/register/schemas.ts +175 -0
  56. package/src/register/session-store.ts +570 -0
  57. package/src/register/settled-figures.ts +98 -0
  58. package/src/register/use-register-session.ts +469 -0
  59. package/src/rxdb/index.ts +2 -0
  60. package/src/sale/cart.ts +23 -0
  61. package/src/sale/catalogue.ts +21 -0
  62. package/src/sale/index.ts +5 -0
  63. package/src/sale/use-sale.ts +394 -0
  64. package/src/store-settings/index.ts +5 -0
  65. package/src/store-settings/map-store-settings.ts +18 -0
  66. package/src/store-settings/resolve-store-settings.ts +65 -0
  67. package/src/store-settings/use-store-settings.ts +84 -0
  68. package/src/tax/exact.ts +218 -0
  69. package/src/tax/index.ts +4 -3
  70. package/src/tax/tax-provider.tsx +35 -12
  71. package/src/tax/types.ts +8 -6
  72. package/src/tender/index.ts +2 -0
  73. package/src/tender/tender-state.ts +326 -0
  74. package/src/currency/currency-provider.test.tsx +0 -36
  75. package/src/currency/format-currency.test.ts +0 -36
  76. package/src/currency/format-currency.ts +0 -18
  77. package/src/logging/logger.test.ts +0 -154
  78. package/src/logging/sinks.test.ts +0 -60
  79. package/src/order/discount-engine.test.ts +0 -152
  80. package/src/order/order-builder.test.ts +0 -246
  81. package/src/order/order-manager.test.ts +0 -209
  82. package/src/order/payment.test.ts +0 -91
  83. package/src/receipt/build-receipt-data.test.ts +0 -166
  84. package/src/repository/create-repository.test.ts +0 -166
  85. package/src/tax/calculate.test.ts +0 -59
  86. package/src/tax/calculate.ts +0 -32
  87. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,32 @@
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, RegisterIdInvalidError,
12
+ } from './register-document';
13
+ export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
14
+ export {
15
+ RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementAmountError, RegisterMovementReasonError, 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, TAX_ROUNDING_MIXED_NOTE } 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';
31
+ export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register-commands';
32
+ export type { RegisterCommand, RegisterCommandCollection } from './register-commands';
@@ -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
+ export 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,147 @@
1
+ import type { RxCollection, RxError, RxJsonSchema } from 'rxdb';
2
+ import type {
3
+ CommandError, RegisterCommandType, RegisterCommandResult, RegisterSessionOpenPayload, RegisterSessionTransitionPayload,
4
+ RegisterMovementRecordPayload, RegisterMovementVoidPayload, RegisterClosureSubmitPayload,
5
+ } from '@tallyui/core';
6
+ import { createLogger } from '../logging';
7
+ import { uuidv7 } from '../pos-order/uuidv7';
8
+ import { readFresh } from '../rxdb';
9
+ import { markCommandsSince, type RegisterHost } from './register-document';
10
+ import type { CashMovement, Closure, RegisterSession } from './schemas';
11
+ import type { CashMovementCollection, ClosureCollection, RegisterSessionCollection } from './session-store';
12
+
13
+ export const registerCommandsLogger = createLogger('register-commands');
14
+
15
+ export interface RegisterCommand {
16
+ key: string; registerId: string; seq: number; commandId: string; type: RegisterCommandType; version: number;
17
+ payload: Record<string, unknown>; createdAt: string; syncStatus: 'pending' | 'applied' | 'rejected';
18
+ error?: CommandError; result?: RegisterCommandResult; updatedAt: string;
19
+ }
20
+ export type RegisterCommandCollection = RxCollection<RegisterCommand>;
21
+
22
+ export const registerCommandSchema: RxJsonSchema<RegisterCommand> = {
23
+ title: 'Register commands', version: 0, primaryKey: 'key', type: 'object', additionalProperties: false,
24
+ properties: {
25
+ key: { type: 'string', maxLength: 100 }, registerId: { type: 'string', maxLength: 36 },
26
+ seq: { type: 'integer', minimum: 0, maximum: 1e15, multipleOf: 1 },
27
+ commandId: { type: 'string' }, type: { type: 'string' }, version: { type: 'integer' },
28
+ payload: { type: 'object', additionalProperties: true },
29
+ createdAt: { type: 'string' }, updatedAt: { type: 'string' },
30
+ syncStatus: { type: 'string', enum: ['pending', 'applied', 'rejected'], maxLength: 10 },
31
+ error: { type: 'object', properties: {
32
+ code: { type: 'string' }, message: { type: 'string' }, data: { type: 'object', additionalProperties: true },
33
+ }, required: ['code', 'message'], additionalProperties: false },
34
+ result: { type: 'object', additionalProperties: true },
35
+ },
36
+ required: ['key', 'registerId', 'seq', 'commandId', 'type', 'version', 'payload', 'createdAt', 'syncStatus', 'updatedAt'],
37
+ indexes: [['registerId', 'seq'], ['syncStatus', 'registerId', 'seq']],
38
+ };
39
+ export const registerCommandCollection = () => ({ schema: registerCommandSchema });
40
+
41
+ type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 };
42
+
43
+ export function sessionOpenCommand(s: RegisterSession): BuiltCommand {
44
+ return { key: `session.open:${s.id}`, type: 'register.session.open', version: 1, payload: {
45
+ sessionId: s.id, registerId: s.register_id, openedAt: s.opened_at_gmt, countedFloatMinor: s.counted_float_minor,
46
+ ...(s.store_key == null ? {} : { storeKey: s.store_key }),
47
+ ...(s.business_day == null ? {} : { businessDay: s.business_day }),
48
+ ...(s.opened_by == null ? {} : { openedBy: s.opened_by }),
49
+ ...(s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor }),
50
+ ...(s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }),
51
+ } satisfies RegisterSessionOpenPayload };
52
+ }
53
+
54
+ export function sessionTransitionCommand(s: RegisterSession & { status_at: string }): BuiltCommand {
55
+ return { key: `session.transition:${s.id}:${s.status}:${s.status_at}`, type: 'register.session.transition', version: 1, payload: {
56
+ sessionId: s.id, status: s.status, at: s.status_at,
57
+ ...(s.status !== 'closed' || s.counted == null ? {} : { counted: s.counted }),
58
+ ...(s.status !== 'closed' || s.closed_by == null ? {} : { closedBy: s.closed_by }),
59
+ ...(s.status !== 'closed' || s.approved_by == null ? {} : { approvedBy: s.approved_by }),
60
+ } satisfies RegisterSessionTransitionPayload };
61
+ }
62
+
63
+ export function movementCommand(m: CashMovement): BuiltCommand {
64
+ const common = { movementId: m.id, sessionId: m.session_id, createdAt: m.created_at_gmt,
65
+ ...(m.created_by == null ? {} : { createdBy: m.created_by }) };
66
+ return m.type === 'void'
67
+ ? { key: `movement.void:${m.id}`, type: 'register.movement.void', version: 1,
68
+ payload: { ...common, voids: m.voids! } satisfies RegisterMovementVoidPayload }
69
+ : { key: `movement.record:${m.id}`, type: 'register.movement.record', version: 1,
70
+ payload: { ...common, type: m.type, amountMinor: m.amountMinor, reason: m.reason } satisfies RegisterMovementRecordPayload };
71
+ }
72
+
73
+ export function closureCommand(c: Closure): BuiltCommand {
74
+ return { key: `closure.submit:${c.id}`, type: 'register.closure.submit', version: 1, payload: {
75
+ closureId: c.id, sessionId: c.session_id, registerId: c.register_id, number: c.number,
76
+ openedAt: c.opened_at, closedAt: c.closed_at,
77
+ ...(c.business_day == null ? {} : { businessDay: c.business_day }),
78
+ ...(c.closed_by == null ? {} : { closedBy: c.closed_by }),
79
+ ...(typeof c.breakdowns.approved_by === 'string' && c.breakdowns.approved_by ? { approvedBy: c.breakdowns.approved_by } : {}),
80
+ tillExpected: c.till_expected, counted: c.counted,
81
+ periodSalesTotalMinor: c.period_sales_total_minor, periodRefundsTotalMinor: c.period_refunds_total_minor,
82
+ perpetualSalesTotalMinor: c.perpetual_sales_total_minor, perpetualRefundsTotalMinor: c.perpetual_refunds_total_minor,
83
+ unsyncedCount: c.unsynced_count, unsyncedTotalMinor: c.unsynced_total_minor, softwareVersion: c.software_version,
84
+ orderIds: c.order_ids, movementIds: c.movement_ids,
85
+ } satisfies RegisterClosureSubmitPayload };
86
+ }
87
+
88
+ const chains = new WeakMap<RegisterCommandCollection, Map<string, Promise<void>>>();
89
+
90
+ /** Appends missing facts in dependency order; the bytes of an existing key never change. */
91
+ export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
92
+ commands: RegisterCommandCollection; sessions: RegisterSessionCollection; movements: CashMovementCollection;
93
+ closures: ClosureCollection; host: RegisterHost; storeKey: string; registerId: string; now?: string; observed?: RegisterSession[];
94
+ }): Promise<string[]> {
95
+ let registers = chains.get(commands);
96
+ if (!registers) chains.set(commands, registers = new Map());
97
+ const run = (registers.get(registerId) ?? Promise.resolve()).then(async () => {
98
+ const since = await markCommandsSince(host, storeKey, registerId, now ?? new Date().toISOString());
99
+ const [highest] = await readFresh(commands, { selector: { registerId }, sort: [{ seq: 'desc' }], limit: 1 });
100
+ let seq = highest?.seq ?? 0;
101
+ const transitioned = new Set((await readFresh(commands, { selector: { registerId, type: 'register.session.transition' } }))
102
+ .map(({ payload }) => payload.sessionId));
103
+ const rows = await readFresh(sessions, { selector: { register_id: registerId } });
104
+ const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) =>
105
+ [`session.open:${session.id}`, ...(session.status === 'closed' && session.closure_id ? [`closure.submit:${session.closure_id}`] : [])]), false)).map(({ key }) => key));
106
+ const facts = await Promise.all(rows.filter((session) =>
107
+ session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
108
+ && !complete.has(`closure.submit:${session.closure_id}`)))
109
+ .map(async (session) => {
110
+ const [entries, closureRows] = await Promise.all([
111
+ readFresh(movements, { selector: { session_id: session.id } }),
112
+ session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([]),
113
+ ]);
114
+ const events = (observed ?? []).filter((row) => row.id === session.id && !transitioned.has(session.id)
115
+ && row.status !== session.status && row.status !== 'closed')
116
+ .flatMap((row) => row.status_at == null ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
117
+ events.push(...entries.filter((entry) => entry.type !== 'void').map(movementCommand),
118
+ ...entries.filter((entry) => entry.type === 'void').map(movementCommand));
119
+ if (session.status_at != null) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
120
+ return { session, number: closureRows[0]?.number, built: [sessionOpenCommand(session), ...events,
121
+ ...closureRows.map((closure) => closureCommand(closure))] };
122
+ }));
123
+ facts.sort((a, b) => Number(a.session.status !== 'closed') - Number(b.session.status !== 'closed')
124
+ || (a.number ?? Infinity) - (b.number ?? Infinity)
125
+ || a.session.id.localeCompare(b.session.id));
126
+ const existing = new Set((await commands.storageInstance.findDocumentsById(facts.flatMap(({ built }) => built.map(({ key }) => key)), false))
127
+ .map(({ key }) => key));
128
+ const appended: string[] = [];
129
+ for (const { built } of facts) {
130
+ for (const command of built) {
131
+ if (existing.has(command.key)) continue;
132
+ seq++;
133
+ const at = now ?? new Date().toISOString();
134
+ try {
135
+ await commands.insert({ ...command, registerId, seq, commandId: uuidv7(), createdAt: at, updatedAt: at, syncStatus: 'pending' });
136
+ appended.push(command.key);
137
+ } catch (error) {
138
+ if ((error as RxError)?.code !== 'CONFLICT') throw error;
139
+ }
140
+ existing.add(command.key);
141
+ }
142
+ }
143
+ return appended;
144
+ });
145
+ registers.set(registerId, run.then(() => undefined, () => undefined));
146
+ return run;
147
+ }
@@ -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
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The till's identity and counters: one RxDB local document, `register`. Port provenance
3
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
4
+ *
5
+ * The host is whatever carries local documents: `register_sessions`, created with
6
+ * `registerSessionCollection` (a Tally database has no database-level local documents). WCPOS
7
+ * keys its buckets by site UUID plus a numeric WooCommerce store id; TallyUI keys them by one
8
+ * neutral `storeKey` the app derives (for example the connector id plus the base URL). WCPOS's
9
+ * module-level snapshots (`getRegisterSnapshot`, `getRegisterId`, `getCurrentBoundRegisterId`)
10
+ * are not ported: callers pass the host and read the document explicitly.
11
+ */
12
+ import type { RxLocalDocumentMutation } from 'rxdb';
13
+ import { map } from 'rxjs';
14
+ import type { Closure } from './schemas';
15
+
16
+ export type RegisterHost = RxLocalDocumentMutation<any>;
17
+
18
+ export type RegisterCounters = {
19
+ last_closure_number: number;
20
+ perpetual_sales_total_minor: number;
21
+ perpetual_refunds_total_minor: number;
22
+ counters_started_at?: string | null;
23
+ };
24
+
25
+ /**
26
+ * One register's counters, plus the closure being written: the draft is reserved here, in the
27
+ * same atomic write as its number, and `applied` once its period is in the perpetual totals.
28
+ */
29
+ export type RegisterBucket = Partial<RegisterCounters> & {
30
+ commands_since?: string;
31
+ closure_reservation?: { row: Closure; applied: boolean };
32
+ /** Closure ids the orphan-stamp sweep has already checked (ADR-032, the #158 follow-ups): a
33
+ * local-document field, so bounding the sweep needs no schema bump. */
34
+ swept_closure_ids?: string[];
35
+ };
36
+
37
+ export interface RegisterStore {
38
+ sale_counter: number;
39
+ registers?: Record<string, RegisterBucket>;
40
+ /** The register (drawer) this till is bound to in this store. */
41
+ register_id?: string | null;
42
+ register_name?: string | null;
43
+ }
44
+
45
+ export interface RegisterDocument {
46
+ id: string;
47
+ name: string;
48
+ /** The caller's platform, for example `'ios'` or `'web'`. */
49
+ platform: string;
50
+ created_at: string;
51
+ /** Keyed by `storeKey`. */
52
+ stores: Record<string, RegisterStore>;
53
+ }
54
+
55
+ const REGISTER = 'register';
56
+ // Matches register_sessions/closures in schemas.ts and register_commands in register-commands.ts.
57
+ const MAX_REGISTER_ID_LENGTH = 36;
58
+
59
+ export function mintUuid(): string {
60
+ const id = globalThis.crypto?.randomUUID?.();
61
+ if (id) return id;
62
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
63
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
64
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
65
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
66
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
67
+ }
68
+
69
+ export async function readRegister(host: RegisterHost): Promise<RegisterDocument | null> {
70
+ return ((await host.getLocal<RegisterDocument>(REGISTER))?.toJSON(true).data as RegisterDocument | undefined) ?? null;
71
+ }
72
+
73
+ /** Reads the register document, minting it on first use; concurrent callers get the same one. */
74
+ export async function ensureRegister(host: RegisterHost, platform: string): Promise<RegisterDocument> {
75
+ const existing = await readRegister(host);
76
+ if (existing) return existing;
77
+ const id = mintUuid().toLowerCase();
78
+ const data: RegisterDocument = {
79
+ id,
80
+ name: `Register ${id.slice(-4).toUpperCase()}`,
81
+ platform,
82
+ created_at: new Date().toISOString(),
83
+ stores: {},
84
+ };
85
+ try {
86
+ await host.insertLocal(REGISTER, data);
87
+ return data;
88
+ } catch (error) {
89
+ const winner = await readRegister(host);
90
+ if (winner) return winner;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ export function observeRegister$(host: RegisterHost) {
96
+ return host
97
+ .getLocal$<RegisterDocument>(REGISTER)
98
+ .pipe(map((doc) => (doc?.toJSON(true).data as RegisterDocument | undefined) ?? null));
99
+ }
100
+
101
+ /** Applies `modify` to one store's bucket in a single atomic write; `undefined` leaves the document unchanged. */
102
+ async function modifyStore(
103
+ host: RegisterHost,
104
+ storeKey: string,
105
+ modify: (store: RegisterStore | undefined) => RegisterStore | undefined,
106
+ ) {
107
+ const doc = await host.getLocal<RegisterDocument>(REGISTER);
108
+ if (!doc) throw new Error('Register is not initialized');
109
+ // RxDB can batch modifiers and return the same final document to each caller, so callers
110
+ // capture their own result inside `modify`.
111
+ await doc.incrementalModify((data) => {
112
+ const store = modify(data.stores[storeKey]);
113
+ return store ? { ...data, stores: { ...data.stores, [storeKey]: store } } : data;
114
+ });
115
+ }
116
+
117
+ export function getBoundRegisterId(register: RegisterDocument | null, storeKey: string): string | null {
118
+ return register?.stores?.[storeKey]?.register_id ?? null;
119
+ }
120
+
121
+ export async function readBoundRegister(host: RegisterHost, storeKey: string) {
122
+ const register = await readRegister(host);
123
+ const id = getBoundRegisterId(register, storeKey);
124
+ return id ? { id, name: register?.stores[storeKey]?.register_name ?? '' } : null;
125
+ }
126
+
127
+ export class RegisterIdInvalidError extends Error {
128
+ constructor() {
129
+ super('register_id_invalid');
130
+ this.name = 'RegisterIdInvalidError';
131
+ }
132
+ }
133
+
134
+ export async function bindRegister(host: RegisterHost, storeKey: string, register: { id: string | null; name: string | null }) {
135
+ if (typeof register.id === 'string' && (register.id.length === 0 || register.id.length > MAX_REGISTER_ID_LENGTH)) {
136
+ throw new RegisterIdInvalidError();
137
+ }
138
+ return modifyStore(host, storeKey, (store) => ({
139
+ ...(store ?? { sale_counter: 0 }),
140
+ register_id: register.id,
141
+ register_name: register.name,
142
+ }));
143
+ }
144
+
145
+ export function unbindRegister(host: RegisterHost, storeKey: string) {
146
+ return modifyStore(host, storeKey, (store) => store && { ...store, register_id: null, register_name: null });
147
+ }
148
+
149
+ export async function nextSaleCounter(host: RegisterHost, storeKey: string): Promise<number> {
150
+ let counter = 0;
151
+ await modifyStore(host, storeKey, (store) => {
152
+ counter = (store?.sale_counter ?? 0) + 1;
153
+ return { ...(store ?? {}), sale_counter: counter };
154
+ });
155
+ return counter;
156
+ }
157
+
158
+ function withRegister(store: RegisterStore | undefined, registerId: string, bucket: RegisterBucket): RegisterStore {
159
+ const base = store ?? { sale_counter: 0 };
160
+ return { ...base, registers: { ...base.registers, [registerId]: bucket } };
161
+ }
162
+
163
+ /** Records when this register first enabled commands, once, in an atomic write. */
164
+ export async function markCommandsSince(host: RegisterHost, storeKey: string, registerId: string, now: string): Promise<string> {
165
+ let since = now;
166
+ await modifyStore(host, storeKey, (store) => {
167
+ const register = store?.registers?.[registerId] ?? {};
168
+ since = register.commands_since ?? now;
169
+ if (register.commands_since !== undefined) return undefined;
170
+ return withRegister(store, registerId, { ...register, commands_since: since });
171
+ });
172
+ return since;
173
+ }
174
+
175
+ /**
176
+ * Mints the register's next closure number. Given the closure draft, it reserves the draft with
177
+ * its number and perpetual totals in the same atomic write, and a retry of the same draft gets the
178
+ * same number back. A different draft is refused while an earlier one is reserved but not yet
179
+ * applied (`closure_write_incomplete`).
180
+ */
181
+ export async function mintClosureNumber(
182
+ host: RegisterHost,
183
+ storeKey: string,
184
+ registerId: string,
185
+ closure?: Closure,
186
+ ): Promise<number> {
187
+ let number = 0;
188
+ await modifyStore(host, storeKey, (store) => {
189
+ const register = store?.registers?.[registerId] ?? {};
190
+ const reservation = register.closure_reservation;
191
+ if (closure && reservation?.row.id === closure.id && !!reservation.row.number_retried === !!closure.number_retried) {
192
+ number = reservation.row.number;
193
+ return undefined;
194
+ }
195
+ if (closure && reservation && reservation.row.id !== closure.id && !reservation.applied) {
196
+ throw new Error('closure_write_incomplete');
197
+ }
198
+ number = (register.last_closure_number ?? 0) + 1;
199
+ // A re-mint of a closure whose period was already applied to the register must not
200
+ // add it again: the register totals already carry it.
201
+ const applied = closure && reservation?.row.id === closure.id && reservation.applied ? reservation.row : null;
202
+ const sales = closure
203
+ ? (register.perpetual_sales_total_minor ?? 0) - (applied?.period_sales_total_minor ?? 0) + closure.period_sales_total_minor
204
+ : 0;
205
+ const refunds = closure
206
+ ? (register.perpetual_refunds_total_minor ?? 0) - (applied?.period_refunds_total_minor ?? 0) + closure.period_refunds_total_minor
207
+ : 0;
208
+ const next = closure
209
+ ? {
210
+ row: { ...closure, number, perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds },
211
+ applied: !!applied || !!closure.number_retried,
212
+ }
213
+ : reservation;
214
+ return withRegister(store, registerId, {
215
+ ...register,
216
+ last_closure_number: number,
217
+ ...(next ? { closure_reservation: next } : {}),
218
+ // An applied period follows the closure row it came from.
219
+ ...(applied ? { perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds } : {}),
220
+ });
221
+ });
222
+ return number;
223
+ }
224
+
225
+ /**
226
+ * Adds a closed period to the register's perpetual totals. With `closureId`, it applies that
227
+ * reserved closure once: the totals become the reservation's, and a repeat does nothing.
228
+ */
229
+ export function advancePerpetual(
230
+ host: RegisterHost,
231
+ storeKey: string,
232
+ registerId: string,
233
+ period: { salesMinor: number; refundsMinor: number; closureId?: string },
234
+ ) {
235
+ return modifyStore(host, storeKey, (store) => {
236
+ const register = store?.registers?.[registerId] ?? {};
237
+ const reservation = register.closure_reservation;
238
+ if (period.closureId && (!reservation || reservation.row.id !== period.closureId || reservation.applied)) {
239
+ return undefined;
240
+ }
241
+ const sales = register.perpetual_sales_total_minor ?? 0;
242
+ const refunds = register.perpetual_refunds_total_minor ?? 0;
243
+ return withRegister(store, registerId, {
244
+ ...register,
245
+ ...(period.closureId && reservation ? { closure_reservation: { ...reservation, applied: true } } : {}),
246
+ perpetual_sales_total_minor: period.closureId && reservation
247
+ ? Math.max(sales, reservation.row.perpetual_sales_total_minor)
248
+ : sales + period.salesMinor,
249
+ perpetual_refunds_total_minor: period.closureId && reservation
250
+ ? Math.max(refunds, reservation.row.perpetual_refunds_total_minor)
251
+ : refunds + period.refundsMinor,
252
+ counters_started_at: register.counters_started_at ?? new Date().toISOString(),
253
+ });
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Adds closure ids to the register's swept set (ADR-032, the #158 follow-ups), once each: the
259
+ * orphan-stamp sweep's bound, so a closure already checked costs the sweep no `pos_orders` query.
260
+ */
261
+ export function markClosuresSwept(host: RegisterHost, storeKey: string, registerId: string, closureIds: readonly string[]) {
262
+ return modifyStore(host, storeKey, (store) => {
263
+ const register = store?.registers?.[registerId] ?? {};
264
+ const swept = new Set(register.swept_closure_ids);
265
+ closureIds.forEach((closureId) => swept.add(closureId));
266
+ return withRegister(store, registerId, { ...register, swept_closure_ids: [...swept] });
267
+ });
268
+ }