@tallyui/pos 2.0.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 (44) hide show
  1. package/dist/index.d.ts +321 -61
  2. package/dist/index.js +1391 -185
  3. package/package.json +8 -8
  4. package/src/index.ts +15 -7
  5. package/src/order/index.ts +3 -0
  6. package/src/order/order-builder.ts +23 -7
  7. package/src/order/order-manager.ts +3 -1
  8. package/src/order/tax-figures.ts +23 -0
  9. package/src/order/types.ts +9 -3
  10. package/src/outbox/backend-not-found.ts +27 -0
  11. package/src/outbox/http-transport.ts +5 -4
  12. package/src/outbox/index.ts +2 -0
  13. package/src/outbox/logger.ts +3 -0
  14. package/src/outbox/order-outbox.ts +331 -27
  15. package/src/outbox/register-outbox.ts +250 -0
  16. package/src/outbox/types.test-d.ts +22 -0
  17. package/src/outbox/types.ts +17 -3
  18. package/src/outbox/use-order-outbox.ts +20 -6
  19. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  20. package/src/pos-order/command.ts +99 -11
  21. package/src/pos-order/finalize.ts +145 -7
  22. package/src/pos-order/index.ts +2 -2
  23. package/src/pos-order/needs-attention.ts +9 -4
  24. package/src/pos-order/open.ts +100 -37
  25. package/src/pos-order/schema.ts +35 -5
  26. package/src/pos-order/types.ts +31 -5
  27. package/src/product/search-products.ts +5 -2
  28. package/src/receipt/build-receipt-data.ts +5 -3
  29. package/src/receipt/types.ts +1 -0
  30. package/src/register/closure-document.ts +7 -0
  31. package/src/register/index.ts +5 -3
  32. package/src/register/movement-input.ts +1 -1
  33. package/src/register/register-commands.ts +147 -0
  34. package/src/register/register-document.ts +26 -1
  35. package/src/register/session-store.ts +41 -12
  36. package/src/register/use-register-session.ts +46 -2
  37. package/src/sale/cart.ts +1 -0
  38. package/src/sale/use-sale.ts +97 -40
  39. package/src/store-settings/map-store-settings.ts +7 -2
  40. package/src/store-settings/use-store-settings.ts +22 -7
  41. package/src/tax/exact.ts +74 -11
  42. package/src/tax/index.ts +1 -1
  43. package/src/tax/tax-provider.tsx +26 -4
  44. package/src/tax/types.ts +6 -0
@@ -1,8 +1,20 @@
1
- import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind } from '@tallyui/core';
1
+ import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind, TaxRounding } from '@tallyui/core';
2
2
  import type { DisplayTotals } from '../order/types';
3
3
 
4
4
  export type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
5
5
 
6
+ export type PosOrderLocalWarning = { code: 'customer_omitted'; field: 'email' | 'id' }
7
+ | { code: 'payment_reference_dropped'; paymentId: string };
8
+ /** The outbox's server-failure state for a pending order, kept so a restart restores it. */
9
+ export interface PosOrderServerFailures {
10
+ /** The stuck clock's start in ms: the outbox's virtual start, which leaves out offline gaps. */
11
+ since: number;
12
+ /** The latest server-answered failure reason. */
13
+ reason: string;
14
+ /** The order failed when sent alone (as a probe or while isolated), so it is retried alone. */
15
+ isolated: boolean;
16
+ }
17
+
6
18
  export interface PosOrderLine {
7
19
  id: string;
8
20
  productId: string;
@@ -44,24 +56,38 @@ export interface PosOrder {
44
56
  registerId?: string;
45
57
  /**
46
58
  * The register session the sale was taken in (ADR-032): its closure counts this order.
47
- * Local only: `toOrderCreateEnvelope` never sends it.
59
+ * Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
48
60
  */
49
61
  sessionId?: string;
50
62
  /**
51
63
  * Set only by `useSale`'s late-sale path (ADR-032): the session the sale was taken for, which
52
64
  * refused the stamp after the money was taken. Such an order has no `sessionId`, so no closure
53
- * counts it. Local only: `toOrderCreateEnvelope` never sends it.
65
+ * counts it. Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
54
66
  */
55
67
  lateSessionId?: string;
56
- /** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. Not written yet. */
68
+ /**
69
+ * The order.create version every attempt under this `commandId` goes out at: the outbox records it before the first
70
+ * send, and lowers it only on a downgrade (ADR-065 amendment). Absent means not sent yet (or requeued).
71
+ */
72
+ sentVersion?: 1 | 2 | 3 | 4;
73
+ /** The version first tried, before the downgrade (the order's audit). */
74
+ downgradedFrom?: 1 | 2 | 3 | 4;
75
+ /** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. */
57
76
  display?: DisplayTotals & { currency: string; exponent: number };
58
- /** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). Not written yet. */
77
+ /** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). */
59
78
  taxByRate?: Array<{ ratePpm: number; code?: string; label?: string; netMinor: number; amountMinor: number; grossMinor: number }>;
79
+ /**
80
+ * The tax rounding the figures were computed with (#287), frozen at finalize; the till's own record, never sent.
81
+ * Version 6's migration sets the default on every older row.
82
+ */
83
+ taxRounding: TaxRounding;
60
84
  cashierRef?: string;
61
85
  syncStatus: PosOrderSyncStatus;
62
86
  commandId: string;
63
87
  serverRefs?: CommandServerRefs;
64
88
  warnings?: CommandWarning[];
89
+ localWarnings?: PosOrderLocalWarning[];
90
+ serverFailures?: PosOrderServerFailures;
65
91
  error?: CommandError;
66
92
  updatedAt: string;
67
93
  }
@@ -14,9 +14,12 @@ export function searchProducts<Doc>(docs: Doc[], term: string, traits: ProductTr
14
14
  if (!query) return docs;
15
15
 
16
16
  const codes = (doc: Doc) =>
17
- [traits.getSku(doc), traits.getBarcode(doc)]
17
+ [...new Set([
18
+ traits.getSku(doc), traits.getBarcode(doc),
19
+ ...(traits.getVariants?.(doc) ?? []).flatMap((variant) => [variant.sku, variant.barcode]),
20
+ ]
18
21
  .filter((code): code is string => Boolean(code))
19
- .map((code) => code.toLowerCase());
22
+ .map((code) => code.toLowerCase()))];
20
23
 
21
24
  const exact = docs.filter((doc) => codes(doc).includes(query));
22
25
  if (exact.length) return exact;
@@ -1,11 +1,11 @@
1
- import type { Discount, Order } from '../order/types';
1
+ import type { Discount, SentOrder } from '../order/types';
2
2
  import { roundMicrosToMinor, taxLinesByRate } from '../tax/exact';
3
3
  import type { ReceiptConfig, ReceiptData } from './types';
4
4
 
5
- export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptData {
5
+ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData {
6
6
  // Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
7
7
  // never showed it, and an exact-shape test elsewhere in this package pins that.
8
- const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
8
+ const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
9
9
  ({ label, code, ratePpm, amountMinor }) => ({ label, code, ratePpm, amountMinor }),
10
10
  );
11
11
 
@@ -21,6 +21,7 @@ export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptDa
21
21
  return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
22
22
  });
23
23
  const label = (d: Discount) => d.label ?? d.couponCode ?? `${d.type} discount`;
24
+ const customer = order.customer?.name || order.customer?.email;
24
25
 
25
26
  return {
26
27
  header: {
@@ -29,6 +30,7 @@ export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptDa
29
30
  orderNumber: order.id,
30
31
  date: order.createdAt,
31
32
  cashier: config.cashier,
33
+ ...(customer ? { customer } : {}),
32
34
  register: config.register,
33
35
  },
34
36
  lineItems: order.lineItems.map((li, index) => ({
@@ -17,6 +17,7 @@ export interface ReceiptData {
17
17
  orderNumber: string;
18
18
  date: string;
19
19
  cashier?: string;
20
+ customer?: string;
20
21
  register?: string;
21
22
  };
22
23
  lineItems: ReceiptLineItem[];
@@ -17,6 +17,12 @@ import { minorToDecimal } from './money';
17
17
  import type { Closure, RegisterSession } from './schemas';
18
18
 
19
19
  type Values = Record<string, unknown>;
20
+
21
+ /**
22
+ * The Z report's line when a session's sales used more than one tax rounding (#287; wording approved by the Front
23
+ * desk, 2026-09-30). The closure template lives in the apps, so the document carries it ready to print.
24
+ */
25
+ export const TAX_ROUNDING_MIXED_NOTE = "This register's sales used more than one tax rounding method. Each sale's tax is as its receipt showed.";
20
26
  /** `writeClosure`'s own `breakdowns.movements` shape (session-store.ts): every entry carries at
21
27
  * least a string `id` and `reason`, so callers can read them without a cast. */
22
28
  type BreakdownMovement = Values & { id: string; reason: string };
@@ -171,6 +177,7 @@ function envelope<R extends Values>(row: R, context: ClosureContext, xreport = f
171
177
  ...Object.fromEntries(
172
178
  ['payment_methods', 'tax_rates', 'movements'].map((key) => [`has_${key}`, Object.keys((breakdowns[key] as Values) ?? {}).length > 0]),
173
179
  ),
180
+ ...(breakdowns.tax_rounding_mixed === true ? { tax_rounding_note: TAX_ROUNDING_MIXED_NOTE } : {}),
174
181
  ...money(row, ['period_sales_total', 'period_refunds_total', 'perpetual_sales_total', 'perpetual_refunds_total', 'unsynced_total']),
175
182
  opened_at_gmt: row.opened_at,
176
183
  opened_by: breakdowns.opened_by ?? null,
@@ -8,11 +8,11 @@ export { registerSessionSchema, registerSessionCollection, cashMovementSchema, c
8
8
  export type { RegisterSession, CashMovement, Closure } from './schemas';
9
9
  export {
10
10
  mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister,
11
- unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual,
11
+ unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual, RegisterIdInvalidError,
12
12
  } from './register-document';
13
13
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
14
14
  export {
15
- RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementStrandedError, openSessionSelector,
15
+ RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
16
16
  requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
17
17
  } from './session-store';
18
18
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
@@ -23,8 +23,10 @@ export { labelKeys } from './document-labels';
23
23
  export { clampClosureScope, selectClosureRows } from './closure-rows';
24
24
  export type { ClosureScope } from './closure-rows';
25
25
  export { minorToDecimal } from './money';
26
- export { formatClosureDate, buildClosureDocument, buildXReportDocument } from './closure-document';
26
+ export { formatClosureDate, buildClosureDocument, buildXReportDocument, TAX_ROUNDING_MIXED_NOTE } from './closure-document';
27
27
  export type { ClosureContext } from './closure-document';
28
28
  export { registerFactsLogger, recordRegisterFact, type Actor, type RegisterFact } from './facts';
29
29
  export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError } from './use-register-session';
30
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';
@@ -13,7 +13,7 @@
13
13
  */
14
14
  const SERVER_DECIMAL = /^\d+(?:\.\d+)?$/;
15
15
  const MAX_INTEGER_DIGITS = 15;
16
- const MAX_REASON_LENGTH = 500;
16
+ export const MAX_REASON_LENGTH = 500;
17
17
 
18
18
  export type MovementType = 'paid_in' | 'paid_out' | 'no_sale';
19
19
 
@@ -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
+ }
@@ -27,6 +27,7 @@ export type RegisterCounters = {
27
27
  * same atomic write as its number, and `applied` once its period is in the perpetual totals.
28
28
  */
29
29
  export type RegisterBucket = Partial<RegisterCounters> & {
30
+ commands_since?: string;
30
31
  closure_reservation?: { row: Closure; applied: boolean };
31
32
  /** Closure ids the orphan-stamp sweep has already checked (ADR-032, the #158 follow-ups): a
32
33
  * local-document field, so bounding the sweep needs no schema bump. */
@@ -52,6 +53,8 @@ export interface RegisterDocument {
52
53
  }
53
54
 
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;
55
58
 
56
59
  export function mintUuid(): string {
57
60
  const id = globalThis.crypto?.randomUUID?.();
@@ -121,7 +124,17 @@ export async function readBoundRegister(host: RegisterHost, storeKey: string) {
121
124
  return id ? { id, name: register?.stores[storeKey]?.register_name ?? '' } : null;
122
125
  }
123
126
 
124
- export function bindRegister(host: RegisterHost, storeKey: string, register: { id: string | null; name: string | null }) {
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
+ }
125
138
  return modifyStore(host, storeKey, (store) => ({
126
139
  ...(store ?? { sale_counter: 0 }),
127
140
  register_id: register.id,
@@ -147,6 +160,18 @@ function withRegister(store: RegisterStore | undefined, registerId: string, buck
147
160
  return { ...base, registers: { ...base.registers, [registerId]: bucket } };
148
161
  }
149
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
+
150
175
  /**
151
176
  * Mints the register's next closure number. Given the closure draft, it reserves the draft with
152
177
  * its number and perpetual totals in the same atomic write, and a retry of the same draft gets the
@@ -11,11 +11,12 @@
11
11
  * to the decimal strings its envelope carries.
12
12
  */
13
13
  import type { RxCollection } from 'rxdb';
14
- import { taxLinesByRate } from '../tax/exact';
14
+ import { DEFAULT_TAX_ROUNDING, taxLinesByRate } from '../tax/exact';
15
15
  import type { PosOrder } from '../pos-order/types';
16
16
  import { readFresh } from '../rxdb';
17
17
  import { deriveExpected, type LedgerRow } from './expected';
18
18
  import { recordRegisterFact } from './facts';
19
+ import { MAX_REASON_LENGTH } from './movement-input';
19
20
  import { countVariance } from './register-count.helpers';
20
21
  import { advancePerpetual, markClosuresSwept, mintClosureNumber, mintUuid as uuid, readRegister, type RegisterHost } from './register-document';
21
22
  import type { CashMovement, Closure, RegisterSession } from './schemas';
@@ -39,10 +40,25 @@ export class RegisterSessionClosedError extends Error {
39
40
  }
40
41
  }
41
42
 
43
+ /** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
44
+ export class RegisterMovementAmountError extends Error {
45
+ constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number) {
46
+ super(`movement_amount_invalid:${type}:${amountMinor}`);
47
+ this.name = 'RegisterMovementAmountError';
48
+ }
49
+ }
50
+
51
+ export class RegisterMovementReasonError extends Error {
52
+ constructor() {
53
+ super('movement_reason_invalid');
54
+ this.name = 'RegisterMovementReasonError';
55
+ }
56
+ }
57
+
42
58
  /**
43
59
  * The movement is recorded, on a session that closed while it was saved, and no closure is known
44
- * to count it, so it was kept rather than deleted. The caller must not record it again; job c's
45
- * server resolves stranded movements. The message can be shown to a cashier as it is.
60
+ * to count it, so it stays local on the till. The caller must not record it again; the server
61
+ * refuses it once the closure exists (ADR-068 4). The message can be shown to a cashier as it is.
46
62
  */
47
63
  export class RegisterMovementStrandedError extends Error {
48
64
  readonly id: string;
@@ -280,13 +296,20 @@ export async function recordMovement(
280
296
  closures: ClosureCollection,
281
297
  input: { sessionId: string; type: 'paid_in' | 'paid_out' | 'no_sale'; amountMinor: number; reason: string; actor: string },
282
298
  ) {
299
+ if (
300
+ !Number.isSafeInteger(input.amountMinor) ||
301
+ ((input.type === 'paid_in' || input.type === 'paid_out') && input.amountMinor <= 0) ||
302
+ (input.type === 'no_sale' && input.amountMinor !== 0)
303
+ ) throw new RegisterMovementAmountError(input.type, input.amountMinor);
304
+ const reasonLength = input.reason.trim().length;
305
+ if (reasonLength === 0 || reasonLength > MAX_REASON_LENGTH) throw new RegisterMovementReasonError();
283
306
  await requireLiveSession(sessions, input.sessionId);
284
307
  const row = await movements.insert({
285
308
  id: uuid(),
286
309
  session_id: input.sessionId,
287
310
  type: input.type,
288
311
  amountMinor: input.amountMinor,
289
- reason: input.reason,
312
+ reason: input.reason.trim(),
290
313
  created_by: input.actor,
291
314
  created_at_gmt: new Date().toISOString(),
292
315
  });
@@ -299,9 +322,8 @@ export async function recordMovement(
299
322
  // - with no closure row yet nothing is proven: `writeClosure` freezes the movements its caller
300
323
  // collected, not this collection, and reserves that draft on the register document before
301
324
  // inserting the row, so a caller's array or the reservation may already hold it. It's kept and
302
- // flagged stranded, and job c's server resolves it.
303
- // If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded for
304
- // the server to reconcile.
325
+ // flagged stranded: it stays local on the till; the server refuses it once the closure exists.
326
+ // If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded.
305
327
  let counted: readonly string[] | undefined;
306
328
  try {
307
329
  const after = await readSession(sessions, input.sessionId);
@@ -358,9 +380,8 @@ export async function voidMovement(
358
380
  // deleted here, because deleting it would leave its target claimed by a missing reversal:
359
381
  // - a closure row that lists it counts it, so it's returned;
360
382
  // - a closure row that doesn't list it, or no closure row yet, leaves it uncounted or unproven,
361
- // so it's kept and flagged stranded, and job c's server resolves it.
362
- // If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded for
363
- // the server to reconcile.
383
+ // so it's kept locally and flagged stranded; the server refuses it once the closure exists.
384
+ // If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded.
364
385
  let counted: readonly string[] | undefined;
365
386
  try {
366
387
  const after = await readSession(sessions, row.session_id);
@@ -453,11 +474,17 @@ export async function writeClosure({
453
474
  }
454
475
  // Same per-rate split a receipt shows (`taxLinesByRate`), run per order so each order's rates
455
476
  // add up to its own taxMinor, then summed across the session's orders by rate. A line's own
456
- // taxInclusive overrides the order's, matching PosOrderLine's own fallback convention.
477
+ // taxInclusive overrides the order's, matching PosOrderLine's own fallback convention. Each order is split by the
478
+ // tax rounding it recorded (#287), so its rows are its receipt's; absent is the default, today's split.
457
479
  const taxRates = new Map<string, { ratePpm: number; net_minor: number; tax_minor: number }>();
480
+ const roundings = new Set<string>();
458
481
  for (const order of bound) {
482
+ // Grouped by the rule applied: a `custom` sale used the default figures, so it joins the default's group.
483
+ const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
484
+ const { granularity, mode } = recorded.granularity === 'custom' ? DEFAULT_TAX_ROUNDING : recorded;
485
+ roundings.add(`${granularity} ${mode}`);
459
486
  const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
460
- for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor)) {
487
+ for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, undefined, order.taxRounding)) {
461
488
  const existing = taxRates.get(String(ratePpm));
462
489
  taxRates.set(String(ratePpm), {
463
490
  ratePpm,
@@ -502,6 +529,8 @@ export async function writeClosure({
502
529
  ratePpm, { name: `Tax ${ratePpm / 10000}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor },
503
530
  ]),
504
531
  ),
532
+ // The session's sales used more than one tax rounding (#287): the Z report says so.
533
+ ...(roundings.size > 1 ? { tax_rounding_mixed: true } : {}),
505
534
  opening_float: {
506
535
  expected_minor: session.expected_float_minor ?? null,
507
536
  counted_minor: session.counted_float_minor,
@@ -17,11 +17,13 @@
17
17
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
18
18
  import { combineLatest, map, of, switchMap } from 'rxjs';
19
19
  import type { RxCollection } from 'rxdb';
20
+ import type { ServerCapabilities } from '@tallyui/core';
20
21
  import type { PosOrder } from '../pos-order/types';
21
22
  import { readFresh, watchFresh } from '../rxdb';
22
23
  import { deriveExpected, type LedgerRow } from './expected';
23
24
  import { recordRegisterFact, registerFactsLogger, type Actor } from './facts';
24
25
  import { closeNeedsApproval } from './register-count.helpers';
26
+ import { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
25
27
  import { observeRegister$, readRegister, type RegisterBucket, type RegisterDocument, type RegisterHost } from './register-document';
26
28
  import type { CashMovement, Closure, RegisterSession } from './schemas';
27
29
  import * as store from './session-store';
@@ -79,6 +81,9 @@ export interface UseRegisterSessionOptions {
79
81
  movements: CashMovementCollection | null;
80
82
  /** `closures`; `null` while it opens. */
81
83
  closures: ClosureCollection | null;
84
+ /** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
85
+ commands?: RegisterCommandCollection | null;
86
+ capabilities?: ServerCapabilities;
82
87
  /** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
83
88
  orders: RxCollection<PosOrder> | null;
84
89
  /** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
@@ -142,6 +147,25 @@ function ledgerRows(orders: readonly PosOrder[]): LedgerRow[] {
142
147
 
143
148
  export function useRegisterSession(options: UseRegisterSessionOptions) {
144
149
  const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
150
+ const { commands } = options;
151
+ const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
152
+ const commandTarget = useMemo(() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId
153
+ ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
154
+ [enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]);
155
+ const commandTargetRef = useRef(commandTarget);
156
+ commandTargetRef.current = commandTarget;
157
+ const reconcile = (row?: RegisterSession) => {
158
+ const target = commandTargetRef.current;
159
+ if (!target) return;
160
+ void reconcileRegisterCommands({ ...target, observed: row ? [row] : undefined }).catch((error: unknown) => {
161
+ try {
162
+ registerCommandsLogger.error('Register command reconcile failed', { context: { registerId: target.registerId, error: String(error) } });
163
+ } catch {
164
+ // A failing log sink must not affect a register action either.
165
+ }
166
+ });
167
+ };
168
+ useEffect(reconcile, [commandTarget]);
145
169
  const source = useMemo(() => {
146
170
  if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
147
171
  return combineLatest([
@@ -312,18 +336,29 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
312
336
  return await run;
313
337
  } finally {
314
338
  openingByRegister.delete(key);
339
+ reconcile();
315
340
  }
316
341
  },
317
342
  startCounting: async () => {
343
+ let row: Awaited<ReturnType<typeof store.startCounting>> | undefined;
344
+ try {
318
345
  refuseDuringTender();
319
- const row = await store.startCounting(live().sessions, current().id);
346
+ row = await store.startCounting(live().sessions, current().id);
320
347
  recordRegisterFact({ kind: 'counting-started', actor, sessionId: row.id, registerId: row.register_id });
321
348
  return row;
349
+ } finally {
350
+ reconcile(row?.toJSON());
351
+ }
322
352
  },
323
353
  backToSelling: async () => {
324
- const row = await store.backToSelling(live().sessions, current().id);
354
+ let row: Awaited<ReturnType<typeof store.backToSelling>> | undefined;
355
+ try {
356
+ row = await store.backToSelling(live().sessions, current().id);
325
357
  recordRegisterFact({ kind: 'counting-abandoned', actor, sessionId: row.id, registerId: row.register_id });
326
358
  return row;
359
+ } finally {
360
+ reconcile(row?.toJSON());
361
+ }
327
362
  },
328
363
  /**
329
364
  * `counted` maps each tender to its counted minor units. An interrupted close resumes with
@@ -397,9 +432,11 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
397
432
  } finally {
398
433
  closingByRegister.delete(key);
399
434
  notifyClosing();
435
+ reconcile();
400
436
  }
401
437
  },
402
438
  recordMovement: async (input: { type: 'paid_in' | 'paid_out' | 'no_sale'; amountMinor: number; reason: string }) => {
439
+ try {
403
440
  // `live()` first: missing collections or host refuse before `requireOpen` writes anything.
404
441
  const { sessions, movements, closures, registerId } = live();
405
442
  const sessionId = await requireOpen();
@@ -409,8 +446,12 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
409
446
  movementId: row.id, movementType: row.type, amount: row.amountMinor,
410
447
  });
411
448
  return row;
449
+ } finally {
450
+ reconcile();
451
+ }
412
452
  },
413
453
  voidMovement: async (id: string) => {
454
+ try {
414
455
  const { sessions, movements, closures, registerId } = live();
415
456
  await requireOpen();
416
457
  const row = await store.voidMovement(sessions, movements, id, actor.id, closures);
@@ -419,6 +460,9 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
419
460
  movementId: row.id, movementType: row.type, amount: row.amountMinor, voids: id,
420
461
  });
421
462
  return row;
463
+ } finally {
464
+ reconcile();
465
+ }
422
466
  },
423
467
  },
424
468
  };
package/src/sale/cart.ts CHANGED
@@ -18,5 +18,6 @@ export function addEntryToCart<Doc>(builder: OrderBuilder, entry: CatalogueEntry
18
18
  return builder.addLine({
19
19
  productId: traits.getId(product), variantId: variant.id, name,
20
20
  sku: variant.sku ?? '', imageUrl: traits.getImageUrl?.(product), unitPrice,
21
+ taxClass: traits.getTaxClass?.(product, variant.id),
21
22
  });
22
23
  }