@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +2031 -43
- package/dist/index.js +4629 -205
- package/package.json +23 -12
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +53 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +8 -0
- package/src/order/order-builder.ts +256 -95
- package/src/order/order-manager.ts +26 -43
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +73 -15
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +67 -0
- package/src/outbox/index.ts +9 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +502 -0
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +36 -0
- package/src/outbox/use-order-outbox.ts +175 -0
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +123 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +219 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +16 -0
- package/src/pos-order/open.ts +269 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +130 -0
- package/src/pos-order/types.ts +93 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +32 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +39 -23
- package/src/receipt/types.ts +17 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +277 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +32 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +268 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +570 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +469 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +23 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +394 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +18 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +84 -0
- package/src/tax/exact.ts +218 -0
- package/src/tax/index.ts +4 -3
- package/src/tax/tax-provider.tsx +35 -12
- package/src/tax/types.ts +8 -6
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The register session's local write path: open, count, close, cash movements and the frozen
|
|
3
|
+
* closure. Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
|
|
4
|
+
*
|
|
5
|
+
* Everything here writes local-only collections (`schemas.ts`). WCPOS's outbox (`pending`,
|
|
6
|
+
* `retryMovement`) moves to registers job c. A session's sales are the `pos_orders` documents
|
|
7
|
+
* whose `sessionId` is the session's id, and their payments are its ledger rows. Refunds are
|
|
8
|
+
* not attributed yet (no refund model, as in `deriveExpected`). The frozen closure's money
|
|
9
|
+
* breakdowns (`payment_methods`, `opening_float`, `movements`, `tax_rates`) are minor-unit
|
|
10
|
+
* integers, the same convention as the closure row itself; `closure-document.ts` converts them
|
|
11
|
+
* to the decimal strings its envelope carries.
|
|
12
|
+
*/
|
|
13
|
+
import type { RxCollection } from 'rxdb';
|
|
14
|
+
import { DEFAULT_TAX_ROUNDING, taxLinesByRate } from '../tax/exact';
|
|
15
|
+
import type { PosOrder } from '../pos-order/types';
|
|
16
|
+
import { readFresh } from '../rxdb';
|
|
17
|
+
import { deriveExpected, type LedgerRow } from './expected';
|
|
18
|
+
import { recordRegisterFact } from './facts';
|
|
19
|
+
import { MAX_REASON_LENGTH } from './movement-input';
|
|
20
|
+
import { countVariance } from './register-count.helpers';
|
|
21
|
+
import { advancePerpetual, markClosuresSwept, mintClosureNumber, mintUuid as uuid, readRegister, type RegisterHost } from './register-document';
|
|
22
|
+
import type { CashMovement, Closure, RegisterSession } from './schemas';
|
|
23
|
+
|
|
24
|
+
export type RegisterSessionCollection = RxCollection<RegisterSession>;
|
|
25
|
+
export type CashMovementCollection = RxCollection<CashMovement>;
|
|
26
|
+
export type ClosureCollection = RxCollection<Closure>;
|
|
27
|
+
|
|
28
|
+
export class RegisterSessionRequiredError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('register_session_not_open');
|
|
31
|
+
this.name = 'RegisterSessionRequiredError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The session is closed, and a closed session is final: there's no server yet to refuse writes to it. */
|
|
36
|
+
export class RegisterSessionClosedError extends Error {
|
|
37
|
+
constructor() {
|
|
38
|
+
super('register_session_closed');
|
|
39
|
+
this.name = 'RegisterSessionClosedError';
|
|
40
|
+
}
|
|
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
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The movement is recorded, on a session that closed while it was saved, and no closure is known
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
export class RegisterMovementStrandedError extends Error {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly session_id: string;
|
|
66
|
+
constructor(movement: { id: string; session_id: string }) {
|
|
67
|
+
super('Recorded, but the session closed while saving. It may not be on this session\'s Z. Do not enter it again.');
|
|
68
|
+
this.name = 'RegisterMovementStrandedError';
|
|
69
|
+
this.id = movement.id;
|
|
70
|
+
this.session_id = movement.session_id;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every move a session may make. Nothing leaves `closed`; everything else is refused. */
|
|
75
|
+
const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['status'][]> = {
|
|
76
|
+
open: ['counting', 'closed'],
|
|
77
|
+
counting: ['open', 'closed'],
|
|
78
|
+
closed: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The session as stored, by primary key, not a cached `findOne(id)`: a status write that skips that
|
|
83
|
+
* query (a server sync) can't leave a live-session check stale (docs/rxdb/query-cache-reads.md). A
|
|
84
|
+
* deleted document counts as missing.
|
|
85
|
+
*/
|
|
86
|
+
export async function readSession(sessions: RegisterSessionCollection, id: string) {
|
|
87
|
+
const [stored] = await sessions.storageInstance.findDocumentsById([id], false);
|
|
88
|
+
return stored;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The closure as stored, by primary key, not a cached `findOne(id)` (as `readSession`); `undefined` without `closures`. */
|
|
92
|
+
async function readClosure(closures: ClosureCollection | undefined, id: string) {
|
|
93
|
+
const [stored] = (await closures?.storageInstance.findDocumentsById([id], false)) ?? [];
|
|
94
|
+
return stored;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function requireLiveSession(sessions: RegisterSessionCollection, id: string) {
|
|
98
|
+
const session = await readSession(sessions, id);
|
|
99
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
100
|
+
if (session.status === 'closed') throw new RegisterSessionClosedError();
|
|
101
|
+
return session;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const openSessionSelector = { status: 'open' } as const;
|
|
105
|
+
|
|
106
|
+
/** The open session's id before a money action, or `RegisterSessionRequiredError`; `null` when sessions are off. */
|
|
107
|
+
export async function requireOpenSession(
|
|
108
|
+
sessions: RegisterSessionCollection | undefined,
|
|
109
|
+
registerId: string | null,
|
|
110
|
+
enabled: boolean,
|
|
111
|
+
) {
|
|
112
|
+
if (!enabled) return null;
|
|
113
|
+
const session = await sessions?.findOne({ selector: { register_id: registerId ?? '', ...openSessionSelector } }).exec();
|
|
114
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
115
|
+
// This gate precedes money actions: a pre-action snapshot must not return after sync.
|
|
116
|
+
await session.incrementalPatch({ server_expected: null, server_sales_count: null });
|
|
117
|
+
return session.id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The one sanctioned way to set `PosOrder.sessionId`: verifies `sessionId` is live and returns
|
|
121
|
+
// the order stamped with it -- `finalizeOrder` is pure and never checks it. Refuses to re-stamp
|
|
122
|
+
// an order that already carries a different session's id.
|
|
123
|
+
export async function stampSession(order: PosOrder, sessionId: string, sessions: RegisterSessionCollection): Promise<PosOrder> {
|
|
124
|
+
if (order.sessionId !== undefined && order.sessionId !== sessionId) throw new Error('session_already_stamped');
|
|
125
|
+
const session = await requireLiveSession(sessions, sessionId);
|
|
126
|
+
return { ...order, sessionId: session.id };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The orphan-stamp sweep (ADR-032, the #156 review). `stampSession`'s check and the app's insert
|
|
131
|
+
* aren't atomic, so a close (a server close, which nothing local holds off) can land between them,
|
|
132
|
+
* and the closure can freeze the session's orders before the insert lands: the order then carries
|
|
133
|
+
* a closed session's `sessionId` but is on no Z. Each such order, whose session is closed and has
|
|
134
|
+
* a closure that doesn't list it, becomes a late sale as `useSale` makes one: no `sessionId`,
|
|
135
|
+
* `lateSessionId` set, and a `late-sale` fact. Never touched: the closure, an order it lists, an
|
|
136
|
+
* order whose session has no closure yet, a late order and another register's orders. The decision
|
|
137
|
+
* is made again on the stored order, so a repeat or concurrent sweep patches it once. Returns the
|
|
138
|
+
* ids it patched.
|
|
139
|
+
*
|
|
140
|
+
* Bounded by `swept_closure_ids` on the register document (the #158 follow-ups): a closure already
|
|
141
|
+
* in that set is never re-checked, so a repeat sweep with no new closure makes no `pos_orders`
|
|
142
|
+
* query. The closure lists the "never touched" rules read still come from `readFresh`. Ids join
|
|
143
|
+
* the set only after their orders are patched, so a crash in between just leaves that closure
|
|
144
|
+
* unswept for the next run; the patch above is already idempotent.
|
|
145
|
+
*
|
|
146
|
+
* The bound alone leaves a hole: `writeClosure` can freeze a closure before an insert racing the
|
|
147
|
+
* close lands, so a sweep run right after finds nothing there yet; marking that closure swept at
|
|
148
|
+
* once would then hide the insert forever. So an id joins the set only once its closure is past
|
|
149
|
+
* `SWEEP_GRACE_MS`; until then every sweep re-checks it (cheap: few closures are that recent).
|
|
150
|
+
* `full` (the app-start sweep) ignores the set outright, so even a save that hangs past the grace
|
|
151
|
+
* and lands after its closure is marked swept is still caught, on the next start.
|
|
152
|
+
*/
|
|
153
|
+
export const SWEEP_GRACE_MS = 10 * 60_000;
|
|
154
|
+
|
|
155
|
+
export async function sweepOrphanStamps({ sessions, closures, orders, registerId, register, storeKey, full = false }: {
|
|
156
|
+
sessions: RegisterSessionCollection; closures: ClosureCollection; orders: RxCollection<PosOrder>; registerId: string;
|
|
157
|
+
register: RegisterHost; storeKey: string;
|
|
158
|
+
/** Ignores `swept_closure_ids` and checks every closure of this register: the app-start sweep. */
|
|
159
|
+
full?: boolean;
|
|
160
|
+
}) {
|
|
161
|
+
const closed = new Set((await readFresh(sessions, { selector: { register_id: registerId, status: 'closed' } })).map(({ id }) => id));
|
|
162
|
+
const swept = new Set((await readRegister(register))?.stores[storeKey]?.registers?.[registerId]?.swept_closure_ids);
|
|
163
|
+
const unswept = (await readFresh(closures, { selector: { register_id: registerId } }))
|
|
164
|
+
.filter((closure) => closed.has(closure.session_id) && (full || !swept.has(closure.id)));
|
|
165
|
+
const frozen = new Map(unswept.map((closure) => [closure.session_id, new Set(closure.order_ids)]));
|
|
166
|
+
const patched: string[] = [];
|
|
167
|
+
if (!frozen.size) return patched;
|
|
168
|
+
for (const { id, sessionId, cashierRef } of await readFresh(orders, { selector: { sessionId: { $in: [...frozen.keys()] } } })) {
|
|
169
|
+
if (!sessionId || frozen.get(sessionId)?.has(id) !== false) continue;
|
|
170
|
+
let late = false;
|
|
171
|
+
await (await orders.findOne(id).exec())?.incrementalModify((doc) => {
|
|
172
|
+
late = doc.sessionId === sessionId && doc.lateSessionId === undefined;
|
|
173
|
+
if (!late) return doc;
|
|
174
|
+
delete doc.sessionId;
|
|
175
|
+
return { ...doc, lateSessionId: sessionId };
|
|
176
|
+
});
|
|
177
|
+
if (!late) continue;
|
|
178
|
+
patched.push(id);
|
|
179
|
+
try {
|
|
180
|
+
// As `useSale`'s late path: the cashier by ref only, with no display name.
|
|
181
|
+
recordRegisterFact({ kind: 'late-sale', orderId: id, sessionId, registerId, actor: { id: cashierRef ?? '', name: '' } });
|
|
182
|
+
} catch {
|
|
183
|
+
// The logger calls the app's sinks unguarded; the order is already patched.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Only a closure past the grace joins the set, and only once every one of this batch's orders is
|
|
187
|
+
// patched: a crash before this write just leaves it unswept, and the next sweep re-checks it,
|
|
188
|
+
// patching nothing twice.
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const markSwept = unswept.filter((closure) => now - new Date(closure.closed_at).getTime() >= SWEEP_GRACE_MS).map(({ id }) => id);
|
|
191
|
+
if (markSwept.length) await markClosuresSwept(register, storeKey, registerId, markSwept);
|
|
192
|
+
return patched;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `yyyy-MM-dd` of a GMT instant in an IANA `timezone`, or in the device's own zone for
|
|
197
|
+
* `'device'`. `Intl` rather than WCPOS's `date-fns`, so no dependency is added.
|
|
198
|
+
*/
|
|
199
|
+
export function businessDayOf(atGmt: string, timezone: string) {
|
|
200
|
+
const at = new Date(atGmt.endsWith('Z') ? atGmt : `${atGmt}Z`);
|
|
201
|
+
const format = new Intl.DateTimeFormat('en-US', {
|
|
202
|
+
timeZone: timezone === 'device' ? undefined : timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
203
|
+
});
|
|
204
|
+
const part = Object.fromEntries(format.formatToParts(at).map(({ type, value }) => [type, value]));
|
|
205
|
+
return `${part.year}-${part.month}-${part.day}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function openSession(
|
|
209
|
+
sessions: RegisterSessionCollection,
|
|
210
|
+
input: {
|
|
211
|
+
registerId: string;
|
|
212
|
+
expectedFloatMinor: number | null;
|
|
213
|
+
countedFloatMinor: number;
|
|
214
|
+
openedBy: string;
|
|
215
|
+
/** The store's day, not the device's UTC day. */
|
|
216
|
+
businessDay: { year: number; month: number; day: number };
|
|
217
|
+
storeKey?: string | null;
|
|
218
|
+
},
|
|
219
|
+
) {
|
|
220
|
+
const { year, month, day } = input.businessDay;
|
|
221
|
+
return sessions.insert({
|
|
222
|
+
id: uuid(),
|
|
223
|
+
register_id: input.registerId,
|
|
224
|
+
store_key: input.storeKey ?? null,
|
|
225
|
+
status: 'open',
|
|
226
|
+
opened_at_gmt: new Date().toISOString(),
|
|
227
|
+
opened_by: input.openedBy,
|
|
228
|
+
business_day: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
|
|
229
|
+
expected_float_minor: input.expectedFloatMinor,
|
|
230
|
+
counted_float_minor: input.countedFloatMinor,
|
|
231
|
+
opening_variance_minor:
|
|
232
|
+
input.expectedFloatMinor === null ? null : countVariance(input.countedFloatMinor, input.expectedFloatMinor),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function transition(
|
|
237
|
+
sessions: RegisterSessionCollection,
|
|
238
|
+
id: string,
|
|
239
|
+
status: RegisterSession['status'],
|
|
240
|
+
extra: Partial<RegisterSession> = {},
|
|
241
|
+
) {
|
|
242
|
+
const row = await sessions.findOne(id).exec();
|
|
243
|
+
if (!row) throw new RegisterSessionRequiredError();
|
|
244
|
+
// A repeat close is a no-op that returns the closed session unchanged, not an error: it is what
|
|
245
|
+
// a retry after a lost response needs, and it must not overwrite the count, time or actor.
|
|
246
|
+
if (row.status === 'closed' && status === 'closed') return row;
|
|
247
|
+
const at = new Date().toISOString();
|
|
248
|
+
// The guard runs again on the latest document, so a racing write can't slip past it.
|
|
249
|
+
return row.incrementalModify((doc) => {
|
|
250
|
+
if (doc.status === 'closed' && status === 'closed') return doc;
|
|
251
|
+
if (doc.status === 'closed') throw new RegisterSessionClosedError();
|
|
252
|
+
if (!TRANSITIONS[doc.status].includes(status)) throw new Error(`invalid_session_transition:${doc.status}->${status}`);
|
|
253
|
+
return {
|
|
254
|
+
...doc, ...extra,
|
|
255
|
+
status,
|
|
256
|
+
pending_status: status,
|
|
257
|
+
status_at: at,
|
|
258
|
+
...(status === 'counting' ? { counting_started_at_gmt: at } : {}),
|
|
259
|
+
...(status === 'closed' ? { closed_at_gmt: at } : {}),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const startCounting = (sessions: RegisterSessionCollection, id: string) => transition(sessions, id, 'counting');
|
|
265
|
+
export const backToSelling = (sessions: RegisterSessionCollection, id: string) => transition(sessions, id, 'open');
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Closes the session with its counted tenders (minor units); `timezone` dates a session opened
|
|
269
|
+
* without a business day. `approvedBy`, the manager who approved the count, is written in the same
|
|
270
|
+
* write as the close, and a repeat close keeps the first close's approver as it keeps its count.
|
|
271
|
+
*/
|
|
272
|
+
export async function closeSession(
|
|
273
|
+
sessions: RegisterSessionCollection,
|
|
274
|
+
id: string,
|
|
275
|
+
input: { counted: Record<string, number>; closedBy?: string; approvedBy?: string; timezone?: string },
|
|
276
|
+
) {
|
|
277
|
+
const session = await sessions.findOne(id).exec();
|
|
278
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
279
|
+
return transition(sessions, id, 'closed', {
|
|
280
|
+
business_day: session.business_day || businessDayOf(session.opened_at_gmt, input.timezone ?? 'device'),
|
|
281
|
+
counted: input.counted,
|
|
282
|
+
closed_by: input.closedBy ?? null,
|
|
283
|
+
...(input.approvedBy ? { approved_by: input.approvedBy } : {}),
|
|
284
|
+
closure_id: id,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Records a movement on a session that is `open` or `counting`; a closed or missing one is refused.
|
|
290
|
+
* A close that races the insert ends in `RegisterSessionClosedError` (removed) or
|
|
291
|
+
* `RegisterMovementStrandedError` (kept; don't record it again).
|
|
292
|
+
*/
|
|
293
|
+
export async function recordMovement(
|
|
294
|
+
sessions: RegisterSessionCollection,
|
|
295
|
+
movements: CashMovementCollection,
|
|
296
|
+
closures: ClosureCollection,
|
|
297
|
+
input: { sessionId: string; type: 'paid_in' | 'paid_out' | 'no_sale'; amountMinor: number; reason: string; actor: string },
|
|
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();
|
|
306
|
+
await requireLiveSession(sessions, input.sessionId);
|
|
307
|
+
const row = await movements.insert({
|
|
308
|
+
id: uuid(),
|
|
309
|
+
session_id: input.sessionId,
|
|
310
|
+
type: input.type,
|
|
311
|
+
amountMinor: input.amountMinor,
|
|
312
|
+
reason: input.reason.trim(),
|
|
313
|
+
created_by: input.actor,
|
|
314
|
+
created_at_gmt: new Date().toISOString(),
|
|
315
|
+
});
|
|
316
|
+
// Not atomic with the check above, so a close can land between the check and the insert. Once
|
|
317
|
+
// the insert has succeeded, the movement is recorded, and the store never deletes a cash record
|
|
318
|
+
// it cannot prove is uncounted (ADR-032):
|
|
319
|
+
// - a closure row that lists it counts it, so it's returned;
|
|
320
|
+
// - a closure row that doesn't list it proves it uncounted, because a closure is frozen, so it's
|
|
321
|
+
// removed and refused, and the cashier records it again in the next session;
|
|
322
|
+
// - with no closure row yet nothing is proven: `writeClosure` freezes the movements its caller
|
|
323
|
+
// collected, not this collection, and reserves that draft on the register document before
|
|
324
|
+
// inserting the row, so a caller's array or the reservation may already hold it. It's kept and
|
|
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.
|
|
327
|
+
let counted: readonly string[] | undefined;
|
|
328
|
+
try {
|
|
329
|
+
const after = await readSession(sessions, input.sessionId);
|
|
330
|
+
if (after?.status !== 'closed') return row;
|
|
331
|
+
counted = (await closures.findOne(input.sessionId).exec())?.movement_ids;
|
|
332
|
+
} catch {
|
|
333
|
+
return row;
|
|
334
|
+
}
|
|
335
|
+
if (!counted) throw new RegisterMovementStrandedError(row);
|
|
336
|
+
if (counted.includes(row.id)) return row;
|
|
337
|
+
try {
|
|
338
|
+
await row.remove();
|
|
339
|
+
} catch {
|
|
340
|
+
throw new RegisterMovementStrandedError(row);
|
|
341
|
+
}
|
|
342
|
+
throw new RegisterSessionClosedError();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reverses a movement with a `void` row while its session is live; repeated or concurrent calls
|
|
347
|
+
* share one reversal. A close that races the writes ends in `RegisterMovementStrandedError` (kept;
|
|
348
|
+
* don't void it again). Without `closures`, nothing can prove the reversal counted, so a re-read
|
|
349
|
+
* that finds the session closed always ends in `RegisterMovementStrandedError`.
|
|
350
|
+
*/
|
|
351
|
+
export async function voidMovement(
|
|
352
|
+
sessions: RegisterSessionCollection, movements: CashMovementCollection, movementId: string, actor: string,
|
|
353
|
+
closures?: ClosureCollection,
|
|
354
|
+
) {
|
|
355
|
+
const row = await movements.findOne(movementId).exec();
|
|
356
|
+
if (!row || row.type === 'void') throw new Error('invalid_void_target');
|
|
357
|
+
await requireLiveSession(sessions, row.session_id);
|
|
358
|
+
// Repeated Undo taps share one durable reversal: the target's voided_by names it.
|
|
359
|
+
const id = uuid();
|
|
360
|
+
const claimed = await row.incrementalModify((doc) => {
|
|
361
|
+
doc.voided_by ??= id;
|
|
362
|
+
return doc;
|
|
363
|
+
});
|
|
364
|
+
const reversalId = claimed.voided_by!;
|
|
365
|
+
const existing = await movements.findOne(reversalId).exec();
|
|
366
|
+
const reversal =
|
|
367
|
+
existing ??
|
|
368
|
+
(await movements.incrementalUpsert({
|
|
369
|
+
id: reversalId,
|
|
370
|
+
session_id: row.session_id,
|
|
371
|
+
type: 'void',
|
|
372
|
+
amountMinor: row.amountMinor,
|
|
373
|
+
reason: row.reason,
|
|
374
|
+
created_by: actor,
|
|
375
|
+
created_at_gmt: new Date().toISOString(),
|
|
376
|
+
voids: row.id,
|
|
377
|
+
}));
|
|
378
|
+
// Not atomic with the check above either (the #156 review): a close can land between the check
|
|
379
|
+
// and the writes. As in `recordMovement`, once written the reversal is recorded, and it's never
|
|
380
|
+
// deleted here, because deleting it would leave its target claimed by a missing reversal:
|
|
381
|
+
// - a closure row that lists it counts it, so it's returned;
|
|
382
|
+
// - a closure row that doesn't list it, or no closure row yet, leaves it uncounted or unproven,
|
|
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.
|
|
385
|
+
let counted: readonly string[] | undefined;
|
|
386
|
+
try {
|
|
387
|
+
const after = await readSession(sessions, row.session_id);
|
|
388
|
+
if (after?.status !== 'closed') return reversal;
|
|
389
|
+
counted = (await readClosure(closures, row.session_id))?.movement_ids;
|
|
390
|
+
} catch {
|
|
391
|
+
return reversal;
|
|
392
|
+
}
|
|
393
|
+
if (counted?.includes(reversal.id)) return reversal;
|
|
394
|
+
throw new RegisterMovementStrandedError(reversal);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Freezes a closed session's figures into its closure, once. The draft is reserved on the register
|
|
399
|
+
* document with its number (`mintClosureNumber`), so a failed insert or a restarted till reuses the
|
|
400
|
+
* same snapshot and number, and its period reaches the perpetual totals exactly once.
|
|
401
|
+
* Only a closed session (`closed` with `closed_at_gmt`) has a closure, and a closed session is
|
|
402
|
+
* final, so its existing closure is returned as it was frozen. Orders the server rejected still
|
|
403
|
+
* count in the drawer, because the cash was taken.
|
|
404
|
+
*/
|
|
405
|
+
export async function writeClosure({
|
|
406
|
+
closures,
|
|
407
|
+
register,
|
|
408
|
+
storeKey,
|
|
409
|
+
session,
|
|
410
|
+
counted,
|
|
411
|
+
otherTenders,
|
|
412
|
+
movements,
|
|
413
|
+
orders,
|
|
414
|
+
tillExpected,
|
|
415
|
+
labels,
|
|
416
|
+
resolveCashierName,
|
|
417
|
+
softwareVersion,
|
|
418
|
+
timezone = 'device',
|
|
419
|
+
}: {
|
|
420
|
+
closures: ClosureCollection;
|
|
421
|
+
/** The register document's host (`register_sessions`). */
|
|
422
|
+
register: RegisterHost;
|
|
423
|
+
storeKey: string;
|
|
424
|
+
session: RegisterSession;
|
|
425
|
+
/** Counted cash, minor units. */
|
|
426
|
+
counted: number;
|
|
427
|
+
otherTenders: Record<string, number>;
|
|
428
|
+
movements: readonly CashMovement[];
|
|
429
|
+
/** Any `pos_orders`; only those whose `sessionId` is this session's are counted. */
|
|
430
|
+
orders: readonly PosOrder[];
|
|
431
|
+
tillExpected?: Record<string, number>;
|
|
432
|
+
labels?: { register_name: string; closed_by_name: string; opened_by_name?: string; approved_by_name?: string };
|
|
433
|
+
resolveCashierName?: (id: string) => string;
|
|
434
|
+
/** The app's version, stamped on the closure. */
|
|
435
|
+
softwareVersion: string;
|
|
436
|
+
timezone?: string;
|
|
437
|
+
}) {
|
|
438
|
+
// Before anything is minted: an open session never reserves a closure number.
|
|
439
|
+
if (session.status !== 'closed' || !session.closed_at_gmt) throw new Error('register_session_not_closed');
|
|
440
|
+
const existing = await closures.findOne(session.id).exec();
|
|
441
|
+
if (existing) {
|
|
442
|
+
await advancePerpetual(register, storeKey, session.register_id, {
|
|
443
|
+
salesMinor: existing.period_sales_total_minor,
|
|
444
|
+
refundsMinor: existing.period_refunds_total_minor,
|
|
445
|
+
closureId: existing.id,
|
|
446
|
+
});
|
|
447
|
+
return existing;
|
|
448
|
+
}
|
|
449
|
+
const bound = orders.filter((order) => order.sessionId === session.id);
|
|
450
|
+
// Each payment is captured, and a cash payment's amount is already net of change.
|
|
451
|
+
const rows: LedgerRow[] = bound.flatMap((order) =>
|
|
452
|
+
order.payments.map((payment) => ({
|
|
453
|
+
session_id: order.sessionId,
|
|
454
|
+
kind: payment.method === 'cash' ? 'cash' : 'other',
|
|
455
|
+
method_id: payment.method,
|
|
456
|
+
status: 'captured',
|
|
457
|
+
amountMinor: payment.amountMinor,
|
|
458
|
+
})),
|
|
459
|
+
);
|
|
460
|
+
const entries = movements.filter((row) => row.session_id === session.id);
|
|
461
|
+
const till_expected =
|
|
462
|
+
tillExpected ??
|
|
463
|
+
deriveExpected({
|
|
464
|
+
session: { id: session.id, countedFloatMinor: session.counted_float_minor },
|
|
465
|
+
movements: entries,
|
|
466
|
+
ledgerRowsBySession: rows,
|
|
467
|
+
});
|
|
468
|
+
const counts = { cash: counted, ...otherTenders };
|
|
469
|
+
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
|
|
470
|
+
const payment_methods: Record<string, { sales_minor: number; refunds_minor: number }> = {};
|
|
471
|
+
for (const row of rows) {
|
|
472
|
+
const method = row.kind === 'cash' ? 'cash' : row.method_id;
|
|
473
|
+
payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row.amountMinor, refunds_minor: 0 };
|
|
474
|
+
}
|
|
475
|
+
// Same per-rate split a receipt shows (`taxLinesByRate`), run per order so each order's rates
|
|
476
|
+
// add up to its own taxMinor, then summed across the session's orders by rate. A line's own
|
|
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.
|
|
479
|
+
const taxRates = new Map<string, { ratePpm: number; net_minor: number; tax_minor: number }>();
|
|
480
|
+
const roundings = new Set<string>();
|
|
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}`);
|
|
486
|
+
const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
|
|
487
|
+
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, undefined, order.taxRounding)) {
|
|
488
|
+
const existing = taxRates.get(String(ratePpm));
|
|
489
|
+
taxRates.set(String(ratePpm), {
|
|
490
|
+
ratePpm,
|
|
491
|
+
net_minor: (existing?.net_minor ?? 0) + netMinor,
|
|
492
|
+
tax_minor: (existing?.tax_minor ?? 0) + amountMinor,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const unsynced = bound.filter((order) => order.syncStatus === 'pending');
|
|
497
|
+
const draft: Closure = {
|
|
498
|
+
id: session.id,
|
|
499
|
+
session_id: session.id,
|
|
500
|
+
register_id: session.register_id,
|
|
501
|
+
store_key: session.store_key ?? null,
|
|
502
|
+
number: 0,
|
|
503
|
+
opened_at: session.opened_at_gmt,
|
|
504
|
+
business_day: session.business_day || businessDayOf(session.opened_at_gmt, timezone),
|
|
505
|
+
closed_by: session.closed_by ?? null,
|
|
506
|
+
closed_at: session.closed_at_gmt!,
|
|
507
|
+
till_expected,
|
|
508
|
+
expected: till_expected,
|
|
509
|
+
counted: counts,
|
|
510
|
+
variance: Object.fromEntries(
|
|
511
|
+
Object.entries(counts).map(([method, value]) => [method, countVariance(value, till_expected[method] ?? 0)]),
|
|
512
|
+
),
|
|
513
|
+
period_sales_total_minor: sum(rows.map((row) => row.amountMinor)),
|
|
514
|
+
period_refunds_total_minor: 0,
|
|
515
|
+
perpetual_sales_total_minor: 0,
|
|
516
|
+
perpetual_refunds_total_minor: 0,
|
|
517
|
+
unsynced_count: unsynced.length,
|
|
518
|
+
unsynced_total_minor: sum(unsynced.flatMap((order) => order.payments.map((payment) => payment.amountMinor))),
|
|
519
|
+
software_version: softwareVersion,
|
|
520
|
+
printed_at: null,
|
|
521
|
+
print_count: 0,
|
|
522
|
+
breakdowns: {
|
|
523
|
+
...labels,
|
|
524
|
+
opened_by: session.opened_by ?? null,
|
|
525
|
+
approved_by: session.approved_by ?? null,
|
|
526
|
+
payment_methods,
|
|
527
|
+
tax_rates: Object.fromEntries(
|
|
528
|
+
[...taxRates.values()].map(({ ratePpm, net_minor, tax_minor }) => [
|
|
529
|
+
ratePpm, { name: `Tax ${ratePpm / 10000}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor },
|
|
530
|
+
]),
|
|
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 } : {}),
|
|
534
|
+
opening_float: {
|
|
535
|
+
expected_minor: session.expected_float_minor ?? null,
|
|
536
|
+
counted_minor: session.counted_float_minor,
|
|
537
|
+
variance_minor: session.opening_variance_minor ?? null,
|
|
538
|
+
},
|
|
539
|
+
movements: entries.map(({ id, type, amountMinor, reason, voids, voided_by, created_at_gmt, created_by }) => ({
|
|
540
|
+
id, type, amountMinor, reason, voids: voids ?? null, created_at_gmt, created_by: created_by ?? null, voided_by: voided_by ?? null,
|
|
541
|
+
})),
|
|
542
|
+
transaction_count: bound.length,
|
|
543
|
+
refund_count: 0,
|
|
544
|
+
cashiers: [...new Set(bound.map((order) => order.cashierRef ?? '').filter(Boolean))].map((id) => ({
|
|
545
|
+
id,
|
|
546
|
+
name: resolveCashierName?.(id) || id,
|
|
547
|
+
})),
|
|
548
|
+
},
|
|
549
|
+
order_ids: bound.map((order) => order.id),
|
|
550
|
+
movement_ids: entries.map((row) => row.id),
|
|
551
|
+
};
|
|
552
|
+
// Reserve the snapshot in the same atomic document write as its number. A failed insert
|
|
553
|
+
// or restarted till reuses this exact snapshot.
|
|
554
|
+
await mintClosureNumber(register, storeKey, session.register_id, draft);
|
|
555
|
+
const reserved = (await readRegister(register))!.stores[storeKey].registers![session.register_id].closure_reservation!.row;
|
|
556
|
+
let row;
|
|
557
|
+
try {
|
|
558
|
+
row = await closures.insert({ ...reserved, order_ids: [...reserved.order_ids], movement_ids: [...reserved.movement_ids] });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const winner = await closures.findOne(reserved.id).exec();
|
|
561
|
+
if (!winner) throw error;
|
|
562
|
+
row = winner;
|
|
563
|
+
}
|
|
564
|
+
await advancePerpetual(register, storeKey, session.register_id, {
|
|
565
|
+
salesMinor: row.period_sales_total_minor,
|
|
566
|
+
refundsMinor: row.period_refunds_total_minor,
|
|
567
|
+
closureId: row.id,
|
|
568
|
+
});
|
|
569
|
+
return row;
|
|
570
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corrections layered onto a closure's recorded figures (ADR-032): late sales, late cash
|
|
3
|
+
* movements and recounts, applied in order and deduplicated by id. Port provenance (ADR-032
|
|
4
|
+
* amendment 1): WCPOS `next` `3b5331b5c` `settled-figures.ts`. Money is TallyUI's integer
|
|
5
|
+
* minor-units convention: WCPOS's four-decimal-string arithmetic becomes direct integer
|
|
6
|
+
* arithmetic on a2's `*_minor` fields and tender maps.
|
|
7
|
+
*/
|
|
8
|
+
import type { Closure } from './schemas';
|
|
9
|
+
|
|
10
|
+
export type Correction = {
|
|
11
|
+
id: number;
|
|
12
|
+
type: 'late_sale' | 'late_movement' | 'recount';
|
|
13
|
+
created_at: string;
|
|
14
|
+
actor: { id: string; name: string };
|
|
15
|
+
approver: { id: string; name: string } | null;
|
|
16
|
+
reason: string;
|
|
17
|
+
figures: {
|
|
18
|
+
expected_delta?: Record<string, number>;
|
|
19
|
+
cash_delta?: number;
|
|
20
|
+
sales_delta?: number;
|
|
21
|
+
refunds_delta?: number;
|
|
22
|
+
counted?: Record<string, number>;
|
|
23
|
+
variance?: Record<string, number>;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RecordedFigures = Pick<
|
|
28
|
+
Closure,
|
|
29
|
+
| 'expected'
|
|
30
|
+
| 'counted'
|
|
31
|
+
| 'variance'
|
|
32
|
+
| 'period_sales_total_minor'
|
|
33
|
+
| 'period_refunds_total_minor'
|
|
34
|
+
| 'perpetual_sales_total_minor'
|
|
35
|
+
| 'perpetual_refunds_total_minor'
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Folds `corrections` onto `recorded`, ordered by `created_at` then `id`, deduplicated by `id`.
|
|
40
|
+
* A `recount` replaces the supplied tenders in `counted`; a `late_movement` adds its signed
|
|
41
|
+
* `cash_delta` to expected cash; a `late_sale` adds its `expected_delta` tenders to expected and
|
|
42
|
+
* its `sales_delta`/`refunds_delta` to both the period and perpetual totals. Variance is
|
|
43
|
+
* recomputed for every tender named by expected or counted, once any correction applies.
|
|
44
|
+
* `touched` names every field, or `field.tender`, whose settled value differs from `recorded`.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveSettled(recorded: RecordedFigures, corrections: readonly Correction[]) {
|
|
47
|
+
const settled = {
|
|
48
|
+
...recorded,
|
|
49
|
+
expected: { ...recorded.expected },
|
|
50
|
+
counted: { ...recorded.counted },
|
|
51
|
+
variance: { ...recorded.variance },
|
|
52
|
+
};
|
|
53
|
+
const seen = new Set<number>();
|
|
54
|
+
for (const correction of [...corrections].sort(
|
|
55
|
+
(a, b) => a.created_at.localeCompare(b.created_at) || a.id - b.id,
|
|
56
|
+
)) {
|
|
57
|
+
if (seen.has(correction.id)) continue;
|
|
58
|
+
seen.add(correction.id);
|
|
59
|
+
const f = correction.figures;
|
|
60
|
+
if (correction.type === 'recount' && f.counted) Object.assign(settled.counted, f.counted);
|
|
61
|
+
const deltas: Record<string, number> =
|
|
62
|
+
correction.type === 'late_movement'
|
|
63
|
+
? { cash: f.cash_delta ?? 0 }
|
|
64
|
+
: correction.type === 'late_sale'
|
|
65
|
+
? (f.expected_delta ?? {})
|
|
66
|
+
: {};
|
|
67
|
+
for (const [tender, delta] of Object.entries(deltas))
|
|
68
|
+
settled.expected[tender] = (settled.expected[tender] ?? 0) + delta;
|
|
69
|
+
if (correction.type === 'late_sale') {
|
|
70
|
+
settled.period_sales_total_minor += f.sales_delta ?? 0;
|
|
71
|
+
settled.perpetual_sales_total_minor += f.sales_delta ?? 0;
|
|
72
|
+
settled.period_refunds_total_minor += f.refunds_delta ?? 0;
|
|
73
|
+
settled.perpetual_refunds_total_minor += f.refunds_delta ?? 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (corrections.length)
|
|
77
|
+
for (const tender of new Set([...Object.keys(settled.expected), ...Object.keys(settled.counted)]))
|
|
78
|
+
settled.variance[tender] = (settled.counted[tender] ?? 0) - (settled.expected[tender] ?? 0);
|
|
79
|
+
const touched = new Set<string>();
|
|
80
|
+
for (const field of [
|
|
81
|
+
'expected',
|
|
82
|
+
'counted',
|
|
83
|
+
'variance',
|
|
84
|
+
'period_sales_total_minor',
|
|
85
|
+
'period_refunds_total_minor',
|
|
86
|
+
'perpetual_sales_total_minor',
|
|
87
|
+
'perpetual_refunds_total_minor',
|
|
88
|
+
] as const) {
|
|
89
|
+
const value = settled[field];
|
|
90
|
+
if (typeof value === 'number') {
|
|
91
|
+
if (value !== (recorded[field] as number)) touched.add(field);
|
|
92
|
+
} else
|
|
93
|
+
for (const [tender, amount] of Object.entries(value))
|
|
94
|
+
if (amount !== ((recorded[field] as Record<string, number>)[tender] ?? 0))
|
|
95
|
+
touched.add(`${field}.${tender}`);
|
|
96
|
+
}
|
|
97
|
+
return { settled, touched };
|
|
98
|
+
}
|