@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.
- package/dist/index.d.ts +321 -61
- package/dist/index.js +1391 -185
- package/package.json +8 -8
- package/src/index.ts +15 -7
- package/src/order/index.ts +3 -0
- package/src/order/order-builder.ts +23 -7
- package/src/order/order-manager.ts +3 -1
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +9 -3
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +5 -4
- package/src/outbox/index.ts +2 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +331 -27
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +17 -3
- package/src/outbox/use-order-outbox.ts +20 -6
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +99 -11
- package/src/pos-order/finalize.ts +145 -7
- package/src/pos-order/index.ts +2 -2
- package/src/pos-order/needs-attention.ts +9 -4
- package/src/pos-order/open.ts +100 -37
- package/src/pos-order/schema.ts +35 -5
- package/src/pos-order/types.ts +31 -5
- package/src/product/search-products.ts +5 -2
- package/src/receipt/build-receipt-data.ts +5 -3
- package/src/receipt/types.ts +1 -0
- package/src/register/closure-document.ts +7 -0
- package/src/register/index.ts +5 -3
- package/src/register/movement-input.ts +1 -1
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-document.ts +26 -1
- package/src/register/session-store.ts +41 -12
- package/src/register/use-register-session.ts +46 -2
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +97 -40
- package/src/store-settings/map-store-settings.ts +7 -2
- package/src/store-settings/use-store-settings.ts +22 -7
- package/src/tax/exact.ts +74 -11
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +26 -4
- package/src/tax/types.ts +6 -0
package/src/pos-order/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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).
|
|
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
|
-
[
|
|
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,
|
|
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:
|
|
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) => ({
|
package/src/receipt/types.ts
CHANGED
|
@@ -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,
|
package/src/register/index.ts
CHANGED
|
@@ -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';
|
|
@@ -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
|
|
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
|
|
45
|
-
*
|
|
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
|
|
303
|
-
// If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded
|
|
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
|
|
362
|
-
// If the re-read or the lookup fails, the outcome is unknown, so it's returned as recorded
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|