@tallyui/pos 0.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -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 +270 -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 +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -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 +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- 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,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useRegisterSession`: the till's register session as React state, and its actions (ADR-032).
|
|
3
|
+
* Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `use-register-session.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Neutral changes: the app supplies every input, as with the order outbox (no context, no
|
|
6
|
+
* WooCommerce store document, no engine query). A session's sales are the `pos_orders` whose
|
|
7
|
+
* `sessionId` is its id, and its expected figures and sales count are derived locally from them
|
|
8
|
+
* and its movements. Nothing is sent to any server. Money is integer minor units.
|
|
9
|
+
*
|
|
10
|
+
* Not ported (registers job c2 owns the server side; TallyUI has no refund model yet):
|
|
11
|
+
* - `retryMovement` and `refusedMovements`: there's no outbox to refuse a movement (c2).
|
|
12
|
+
* - Refund records, refund parents and their demand and close-time wait: no refund model.
|
|
13
|
+
* - Anchor invalidation, `server_expected` and `server_sales_count`: no server figures (c2).
|
|
14
|
+
* - `unsyncedCount` and the `sync_status` filters: nothing is sent (c2).
|
|
15
|
+
* - The binding (`useRegisterBinding`) and `blind` from WooCommerce capabilities: app inputs.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
18
|
+
import { combineLatest, map, of, switchMap } from 'rxjs';
|
|
19
|
+
import type { RxCollection } from 'rxdb';
|
|
20
|
+
import type { PosOrder } from '../pos-order/types';
|
|
21
|
+
import { readFresh, watchFresh } from '../rxdb';
|
|
22
|
+
import { deriveExpected, type LedgerRow } from './expected';
|
|
23
|
+
import { recordRegisterFact, registerFactsLogger, type Actor } from './facts';
|
|
24
|
+
import { closeNeedsApproval } from './register-count.helpers';
|
|
25
|
+
import { observeRegister$, readRegister, type RegisterBucket, type RegisterDocument, type RegisterHost } from './register-document';
|
|
26
|
+
import type { CashMovement, Closure, RegisterSession } from './schemas';
|
|
27
|
+
import * as store from './session-store';
|
|
28
|
+
import { RegisterSessionRequiredError, type CashMovementCollection, type ClosureCollection, type RegisterSessionCollection } from './session-store';
|
|
29
|
+
|
|
30
|
+
/** A sale is at tender, so the till can't count or close under it. Its message can be shown to a cashier as it is. */
|
|
31
|
+
export class RegisterTenderInProgressError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super('Finish or cancel the sale in progress first.');
|
|
34
|
+
this.name = 'RegisterTenderInProgressError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The register already has an `open` or `counting` session, or an open is in flight. Its message can be shown to a cashier as it is. */
|
|
39
|
+
export class RegisterSessionAlreadyOpenError extends Error {
|
|
40
|
+
constructor() {
|
|
41
|
+
super('This register already has an open session.');
|
|
42
|
+
this.name = 'RegisterSessionAlreadyOpenError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An earlier session's close didn't finish (its closure is unwritten or unapplied); close it first. Its message can be shown to a cashier as it is. */
|
|
47
|
+
export class RegisterCloseIncompleteError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super('Finish closing the previous session first.');
|
|
50
|
+
this.name = 'RegisterCloseIncompleteError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The count is over `varianceThreshold` and the close carries no `approvedBy`. Its message is `RegisterCount`'s refusal copy, so it can be shown to a cashier as it is. */
|
|
55
|
+
export class RegisterApprovalRequiredError extends Error {
|
|
56
|
+
constructor() {
|
|
57
|
+
super('Manager approval needed. Ask a manager to approve, or count again.');
|
|
58
|
+
this.name = 'RegisterApprovalRequiredError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Module-wide, so two hook instances for one register can't both open (keyed by `registerId`).
|
|
63
|
+
const openingByRegister = new Map<string, Promise<unknown>>();
|
|
64
|
+
// The close in flight per register, which a second close joins; its listeners re-render `closing`.
|
|
65
|
+
const closingByRegister = new Map<string, ReturnType<typeof store.writeClosure>>();
|
|
66
|
+
const closingListeners = new Set<() => void>();
|
|
67
|
+
const notifyClosing = () => closingListeners.forEach((listener) => listener());
|
|
68
|
+
const subscribeClosing = (listener: () => void) => {
|
|
69
|
+
closingListeners.add(listener);
|
|
70
|
+
return () => { closingListeners.delete(listener); };
|
|
71
|
+
};
|
|
72
|
+
// Closures whose `session-closed` fact is logged, so a double-tapped close logs it once.
|
|
73
|
+
const closedFacts = new Set<string>();
|
|
74
|
+
|
|
75
|
+
export interface UseRegisterSessionOptions {
|
|
76
|
+
/** `register_sessions`, created with `registerSessionCollection()`; `null` while it opens. */
|
|
77
|
+
sessions: RegisterSessionCollection | null;
|
|
78
|
+
/** `cash_movements`; `null` while it opens. */
|
|
79
|
+
movements: CashMovementCollection | null;
|
|
80
|
+
/** `closures`; `null` while it opens. */
|
|
81
|
+
closures: ClosureCollection | null;
|
|
82
|
+
/** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
|
|
83
|
+
orders: RxCollection<PosOrder> | null;
|
|
84
|
+
/** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
|
|
85
|
+
register: RegisterHost | null;
|
|
86
|
+
/** The app's neutral store key (see `bindRegister`). */
|
|
87
|
+
storeKey: string;
|
|
88
|
+
/** The register (drawer) this till is bound to, or `null` when it isn't bound. */
|
|
89
|
+
registerId: string | null;
|
|
90
|
+
/** The store uses register sessions; `false` turns everything off, as WCPOS's `sessionsOn`. */
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
/** The signed-in cashier, for the facts and `opened_by`/`closed_by`. */
|
|
93
|
+
actor: Actor;
|
|
94
|
+
/** The store's IANA zone, or `'device'`: the business day a session opens on. */
|
|
95
|
+
timezone: string;
|
|
96
|
+
/** The app's version, stamped on the closure. */
|
|
97
|
+
softwareVersion: string;
|
|
98
|
+
/** A sale is at tender (`sale.stage.kind === 'tender'`): counting and closing refuse. */
|
|
99
|
+
tenderInProgress: boolean;
|
|
100
|
+
/** The count variance, in minor units, over which the app asks for approval. */
|
|
101
|
+
varianceThreshold?: number;
|
|
102
|
+
/** `'HH:mm'` on the device's clock; an open session after it is `overdue`. */
|
|
103
|
+
expectedCloseTime?: string;
|
|
104
|
+
/** The cashier may not see expected figures while counting. */
|
|
105
|
+
blind?: boolean;
|
|
106
|
+
/** The closure's register name, and a person id's display name. */
|
|
107
|
+
labels?: { registerName?: string; resolveCashierName?: (id: string) => string };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Reservation = RegisterBucket['closure_reservation'];
|
|
111
|
+
type Snapshot = {
|
|
112
|
+
rows: RegisterSession[]; closureRows: Closure[]; reservation: Reservation;
|
|
113
|
+
entries: CashMovement[]; sales: PosOrder[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const reservationOf = (register: RegisterDocument | null, storeKey: string, registerId: string) =>
|
|
117
|
+
register?.stores[storeKey]?.registers?.[registerId]?.closure_reservation;
|
|
118
|
+
|
|
119
|
+
/** A closed session whose close didn't finish: its closure row was never written. */
|
|
120
|
+
const unwritten = (row: RegisterSession, closureRows: readonly { id: string }[]) =>
|
|
121
|
+
row.status === 'closed' && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The session for this register: first the one an unapplied closure reservation names (its close
|
|
125
|
+
* was interrupted after the number was reserved, perhaps after its row was written), so it can
|
|
126
|
+
* finish; then the open or counting one; then a closed one whose closure row was never written.
|
|
127
|
+
*/
|
|
128
|
+
function currentSession(rows: RegisterSession[], closureRows: Closure[], reservation: Reservation) {
|
|
129
|
+
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : undefined)
|
|
130
|
+
?? rows.find((row) => row.status !== 'closed')
|
|
131
|
+
?? rows.find((row) => unwritten(row, closureRows))
|
|
132
|
+
?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A session's ledger rows, built from its orders' payments the way `writeClosure` builds them. */
|
|
136
|
+
function ledgerRows(orders: readonly PosOrder[]): LedgerRow[] {
|
|
137
|
+
return orders.flatMap((order) => order.payments.map((payment) => ({
|
|
138
|
+
session_id: order.sessionId, kind: payment.method === 'cash' ? 'cash' : 'other', method_id: payment.method,
|
|
139
|
+
status: 'captured', amountMinor: payment.amountMinor,
|
|
140
|
+
})));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
144
|
+
const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
|
|
145
|
+
const source = useMemo(() => {
|
|
146
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
147
|
+
return combineLatest([
|
|
148
|
+
watchFresh(sessions, { selector: { register_id: registerId } }),
|
|
149
|
+
watchFresh(closures, { selector: { register_id: registerId } }),
|
|
150
|
+
observeRegister$(register).pipe(map((document) => reservationOf(document, storeKey, registerId))),
|
|
151
|
+
]).pipe(switchMap(([rows, closureRows, reservation]) => {
|
|
152
|
+
const current = currentSession(rows, closureRows, reservation);
|
|
153
|
+
if (!current) return of({ rows, closureRows, reservation, entries: [], sales: [] } as Snapshot);
|
|
154
|
+
return combineLatest([
|
|
155
|
+
watchFresh(movements, { selector: { session_id: current.id } }),
|
|
156
|
+
watchFresh(orders, { selector: { sessionId: current.id } }),
|
|
157
|
+
]).pipe(map(([entries, sales]) => ({ rows, closureRows, reservation, entries, sales })));
|
|
158
|
+
}));
|
|
159
|
+
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
|
|
160
|
+
// A latest-value ref: an `actions` object from an earlier render still sees the current flag.
|
|
161
|
+
const tender = useRef(tenderInProgress);
|
|
162
|
+
tender.current = tenderInProgress;
|
|
163
|
+
const closing = useSyncExternalStore(subscribeClosing, () => closingByRegister.has(registerId ?? ''));
|
|
164
|
+
const [observed, setObserved] = useState<{ source: typeof source; snapshot: Snapshot } | null>(null);
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!source) return;
|
|
167
|
+
const subscription = source.subscribe((snapshot) => setObserved({ source, snapshot }));
|
|
168
|
+
return () => subscription.unsubscribe();
|
|
169
|
+
}, [source]);
|
|
170
|
+
// WCPOS re-evaluates `overdue` each minute.
|
|
171
|
+
const [, setMinute] = useState(0);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!enabled || !expectedCloseTime) return;
|
|
174
|
+
const timer = setInterval(() => setMinute((value) => value + 1), 60_000);
|
|
175
|
+
return () => clearInterval(timer);
|
|
176
|
+
}, [enabled, expectedCloseTime]);
|
|
177
|
+
|
|
178
|
+
// Only an emission from the current source is trusted: a changed register or collection starts empty.
|
|
179
|
+
const data = source && observed?.source === source ? observed.snapshot : null;
|
|
180
|
+
|
|
181
|
+
// The orphan-stamp sweep (`store.sweepOrphanStamps`): one run at a time, and a request during a
|
|
182
|
+
// run schedules one more, which reads afresh (carrying `full` forward if any waiting call asked
|
|
183
|
+
// for it). A failure is logged, never thrown into the UI.
|
|
184
|
+
const sweeper = useRef<{
|
|
185
|
+
running?: Promise<void>; queued?: Promise<void>; queuedFull?: boolean;
|
|
186
|
+
target?: Parameters<typeof store.sweepOrphanStamps>[0];
|
|
187
|
+
}>({});
|
|
188
|
+
sweeper.current.target = enabled && sessions && closures && orders && registerId && register
|
|
189
|
+
? { sessions, closures, orders, registerId, register, storeKey } : undefined;
|
|
190
|
+
const sweep = (full = false): Promise<void> => {
|
|
191
|
+
const state = sweeper.current;
|
|
192
|
+
if (state.running) {
|
|
193
|
+
state.queuedFull = state.queuedFull || full;
|
|
194
|
+
return (state.queued ??= state.running.then(() => {
|
|
195
|
+
const runFull = !!state.queuedFull;
|
|
196
|
+
state.queued = undefined;
|
|
197
|
+
state.queuedFull = undefined;
|
|
198
|
+
return sweep(runFull);
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
const target = state.target;
|
|
202
|
+
if (!target) return Promise.resolve();
|
|
203
|
+
const run = store.sweepOrphanStamps({ ...target, full }).then(() => undefined, (error: unknown) => {
|
|
204
|
+
try {
|
|
205
|
+
registerFactsLogger.error('Register orphan-stamp sweep failed', { context: { type: 'register.sweep-failed', registerId: target.registerId, error: String(error) } });
|
|
206
|
+
} catch {
|
|
207
|
+
// A failing sink must not throw into the UI either.
|
|
208
|
+
}
|
|
209
|
+
}).finally(() => { state.running = undefined; });
|
|
210
|
+
return (state.running = run);
|
|
211
|
+
};
|
|
212
|
+
// On start (this source's first snapshot), it sweeps every closure, ignoring `swept_closure_ids`
|
|
213
|
+
// (`full`): the once-per-start repair for an insert that raced a close or a server close made
|
|
214
|
+
// while the app was closed. Later, on a closure it hasn't seen (such as one a sync writes) and
|
|
215
|
+
// after `closeSession`'s own closure, it runs the bounded, fast-path sweep instead.
|
|
216
|
+
const seen = useRef<{ source: typeof source; ids: Set<string> } | null>(null);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!data) return;
|
|
219
|
+
const ids = data.closureRows.map((row) => row.id);
|
|
220
|
+
if (seen.current?.source !== source) {
|
|
221
|
+
seen.current = { source, ids: new Set(ids) };
|
|
222
|
+
void sweep(true);
|
|
223
|
+
} else if (ids.some((id) => !seen.current!.ids.has(id))) {
|
|
224
|
+
ids.forEach((id) => seen.current!.ids.add(id));
|
|
225
|
+
void sweep();
|
|
226
|
+
}
|
|
227
|
+
}, [data, source]);
|
|
228
|
+
const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
|
|
229
|
+
const entries = data?.entries ?? [];
|
|
230
|
+
const sales = data?.sales ?? [];
|
|
231
|
+
const expected = session
|
|
232
|
+
? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) })
|
|
233
|
+
: {};
|
|
234
|
+
const now = new Date();
|
|
235
|
+
const [hour, minute] = String(expectedCloseTime ?? '').split(':').map(Number);
|
|
236
|
+
const overdue = session?.status === 'open' && !!expectedCloseTime
|
|
237
|
+
&& now.getTime() > new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute).getTime();
|
|
238
|
+
|
|
239
|
+
const requireOpen = () => store.requireOpenSession(sessions ?? undefined, registerId, enabled);
|
|
240
|
+
// A non-null id means `sessions` was there to find it.
|
|
241
|
+
const requireSaleSession = async () => {
|
|
242
|
+
const id = await requireOpen();
|
|
243
|
+
return id === null ? null : { id, sessions: sessions as RegisterSessionCollection };
|
|
244
|
+
};
|
|
245
|
+
const live = () => {
|
|
246
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) throw new RegisterSessionRequiredError();
|
|
247
|
+
return { sessions, movements, closures, orders, registerId, register };
|
|
248
|
+
};
|
|
249
|
+
const current = () => {
|
|
250
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
251
|
+
return session;
|
|
252
|
+
};
|
|
253
|
+
const refuseDuringTender = () => {
|
|
254
|
+
if (tender.current) throw new RegisterTenderInProgressError();
|
|
255
|
+
};
|
|
256
|
+
const nameOf = (id: string | null | undefined) => !id ? '' : id === actor.id ? actor.name : labels?.resolveCashierName?.(id) ?? '';
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
session,
|
|
260
|
+
movements: entries,
|
|
261
|
+
expected,
|
|
262
|
+
salesCount: sales.length,
|
|
263
|
+
overdue,
|
|
264
|
+
/** A `closeSession` for this register is in flight, in any hook instance: the session can already be stored `closed` while its closure isn't written yet. */
|
|
265
|
+
closing,
|
|
266
|
+
lastClosure: [...(data?.closureRows ?? [])].sort((a, b) => b.closed_at.localeCompare(a.closed_at))[0] ?? null,
|
|
267
|
+
lastClosed: (data?.rows ?? []).filter((row) => row.status === 'closed')
|
|
268
|
+
.sort((a, b) => (b.closed_at_gmt ?? '').localeCompare(a.closed_at_gmt ?? ''))[0] ?? null,
|
|
269
|
+
varianceThreshold: options.varianceThreshold,
|
|
270
|
+
enabled,
|
|
271
|
+
blind: options.blind ?? false,
|
|
272
|
+
/** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
|
|
273
|
+
saleSession: session && sessions && session.status !== 'closed' ? { id: session.id, sessions } : undefined,
|
|
274
|
+
/** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
|
|
275
|
+
requireOpen,
|
|
276
|
+
/** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
|
|
277
|
+
requireSaleSession,
|
|
278
|
+
actions: {
|
|
279
|
+
/**
|
|
280
|
+
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
281
|
+
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
282
|
+
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
283
|
+
*/
|
|
284
|
+
openSession: async (input: { expectedFloatMinor: number | null; countedFloatMinor: number }) => {
|
|
285
|
+
// A double tap: the second call sees the first's promise, set before its first await.
|
|
286
|
+
const key = registerId ?? '';
|
|
287
|
+
if (openingByRegister.has(key)) throw new RegisterSessionAlreadyOpenError();
|
|
288
|
+
const run = (async () => {
|
|
289
|
+
const { sessions, closures, registerId, register } = live();
|
|
290
|
+
// The storage, not the rendered snapshot or a cached query, which can lag a new write.
|
|
291
|
+
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
292
|
+
if (rows.some((row) => row.status !== 'closed')) throw new RegisterSessionAlreadyOpenError();
|
|
293
|
+
// Closure rows before the reservation: a close reserves, then inserts its row, then applies the
|
|
294
|
+
// reservation, so a close landing between the two reads is still caught by one of them.
|
|
295
|
+
const closureRows = await readFresh(closures, { selector: { register_id: registerId } });
|
|
296
|
+
const reservation = reservationOf(await readRegister(register), storeKey, registerId);
|
|
297
|
+
if ((reservation && !reservation.applied) || rows.some((row) => unwritten(row, closureRows))) {
|
|
298
|
+
throw new RegisterCloseIncompleteError();
|
|
299
|
+
}
|
|
300
|
+
const [year, month, day] = store.businessDayOf(new Date().toISOString(), timezone).split('-').map(Number);
|
|
301
|
+
const row = await store.openSession(sessions, {
|
|
302
|
+
...input, registerId, openedBy: actor.id, businessDay: { year, month, day }, storeKey: options.storeKey,
|
|
303
|
+
});
|
|
304
|
+
recordRegisterFact({
|
|
305
|
+
kind: 'session-opened', actor, sessionId: row.id, registerId: row.register_id,
|
|
306
|
+
amount: row.counted_float_minor, variance: row.opening_variance_minor,
|
|
307
|
+
});
|
|
308
|
+
return row;
|
|
309
|
+
})();
|
|
310
|
+
openingByRegister.set(key, run);
|
|
311
|
+
try {
|
|
312
|
+
return await run;
|
|
313
|
+
} finally {
|
|
314
|
+
openingByRegister.delete(key);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
startCounting: async () => {
|
|
318
|
+
refuseDuringTender();
|
|
319
|
+
const row = await store.startCounting(live().sessions, current().id);
|
|
320
|
+
recordRegisterFact({ kind: 'counting-started', actor, sessionId: row.id, registerId: row.register_id });
|
|
321
|
+
return row;
|
|
322
|
+
},
|
|
323
|
+
backToSelling: async () => {
|
|
324
|
+
const row = await store.backToSelling(live().sessions, current().id);
|
|
325
|
+
recordRegisterFact({ kind: 'counting-abandoned', actor, sessionId: row.id, registerId: row.register_id });
|
|
326
|
+
return row;
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* `counted` maps each tender to its counted minor units. An interrupted close resumes with
|
|
330
|
+
* the count persisted on the session, not the one passed to the retry (WCPOS uses the retry's).
|
|
331
|
+
* Over `varianceThreshold` (`closeNeedsApproval`, as `RegisterCount`), a close without
|
|
332
|
+
* `approvedBy` throws `RegisterApprovalRequiredError` before any write, blind or not; a
|
|
333
|
+
* resumed close (the stored session already closed) isn't gated again. `approvedBy` reaches the Z
|
|
334
|
+
* (`breakdowns.approved_by`), and `approvedByName` its `approved_by_name`.
|
|
335
|
+
* One close per register runs at a time, in any hook instance: a call while one is in flight joins
|
|
336
|
+
* it, getting its closure or error, and its own `counted`, `approvedBy` and `approvedByName` are ignored.
|
|
337
|
+
*/
|
|
338
|
+
closeSession: async (input: { counted: Record<string, number>; approvedBy?: string; approvedByName?: string }) => {
|
|
339
|
+
refuseDuringTender();
|
|
340
|
+
const key = registerId ?? '';
|
|
341
|
+
if (closingByRegister.has(key)) return closingByRegister.get(key)!;
|
|
342
|
+
const run = (async () => {
|
|
343
|
+
const { sessions, movements, closures, orders, register } = live();
|
|
344
|
+
const open = current();
|
|
345
|
+
// The gate reads the stored session and the Z's own inputs past the query cache, not this
|
|
346
|
+
// render's snapshot, which can lag a sale or a close that already landed (#168 review).
|
|
347
|
+
const stored = await store.readSession(sessions, open.id);
|
|
348
|
+
if (!stored) throw new RegisterSessionRequiredError();
|
|
349
|
+
if (stored.status !== 'closed' && !input.approvedBy) {
|
|
350
|
+
// As `writeClosure` derives the Z's expected figures, from the same rows.
|
|
351
|
+
const fresh = deriveExpected({
|
|
352
|
+
session: { id: stored.id, countedFloatMinor: stored.counted_float_minor },
|
|
353
|
+
movements: await readFresh(movements, { selector: { session_id: stored.id } }),
|
|
354
|
+
ledgerRowsBySession: ledgerRows(await readFresh(orders, { selector: { sessionId: stored.id } })),
|
|
355
|
+
});
|
|
356
|
+
if (closeNeedsApproval(input.counted.cash ?? 0, fresh.cash ?? 0, options.varianceThreshold)) throw new RegisterApprovalRequiredError();
|
|
357
|
+
}
|
|
358
|
+
// By id, not `open` (plain data): `store.closeSession`'s guard runs again inside
|
|
359
|
+
// `incrementalModify` on the stored document, so a close that already landed keeps its count, time, actor and approver.
|
|
360
|
+
const closed = await store.closeSession(sessions, open.id, { counted: input.counted, closedBy: actor.id, approvedBy: input.approvedBy, timezone });
|
|
361
|
+
// The session stores no approver name, so a close resumed without this call's name (after
|
|
362
|
+
// a restart, say) falls back to `nameOf`.
|
|
363
|
+
const approvedByName = (input.approvedBy && closed.approved_by === input.approvedBy && input.approvedByName) || nameOf(closed.approved_by);
|
|
364
|
+
const { cash = 0, ...otherTenders } = closed.counted ?? input.counted;
|
|
365
|
+
// Read past the query cache: the Z's figures are derived from these rows.
|
|
366
|
+
const closure = await store.writeClosure({
|
|
367
|
+
closures, register, storeKey, session: closed,
|
|
368
|
+
counted: cash, otherTenders, timezone, softwareVersion: options.softwareVersion,
|
|
369
|
+
movements: await readFresh(movements, { selector: { session_id: closed.id } }),
|
|
370
|
+
orders: await readFresh(orders, { selector: { sessionId: closed.id } }),
|
|
371
|
+
resolveCashierName: labels?.resolveCashierName,
|
|
372
|
+
labels: {
|
|
373
|
+
register_name: labels?.registerName ?? '', closed_by_name: nameOf(closed.closed_by),
|
|
374
|
+
opened_by_name: nameOf(closed.opened_by), approved_by_name: approvedByName,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
// A stamp that raced this close, a resumed one included, is swept before the close returns.
|
|
378
|
+
await sweep();
|
|
379
|
+
// The fact's operationId is the closure id: a repeated or concurrent close logs it once.
|
|
380
|
+
if (!closedFacts.has(closure.id)) {
|
|
381
|
+
closedFacts.add(closure.id);
|
|
382
|
+
// The approver the Z froze, not this call's: a repeat close never replaces it.
|
|
383
|
+
if (closed.approved_by) {
|
|
384
|
+
recordRegisterFact({ kind: 'approval-granted', actor, sessionId: closed.id, registerId: closed.register_id, approvedBy: closed.approved_by });
|
|
385
|
+
}
|
|
386
|
+
recordRegisterFact({
|
|
387
|
+
kind: 'session-closed', actor, sessionId: closed.id, registerId: closed.register_id,
|
|
388
|
+
closureId: closure.id, number: closure.number, counted: closure.counted, variance: closure.variance,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return closure;
|
|
392
|
+
})();
|
|
393
|
+
closingByRegister.set(key, run); // Before this call's first await, so a second call sees it.
|
|
394
|
+
notifyClosing();
|
|
395
|
+
try {
|
|
396
|
+
return await run;
|
|
397
|
+
} finally {
|
|
398
|
+
closingByRegister.delete(key);
|
|
399
|
+
notifyClosing();
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
recordMovement: async (input: { type: 'paid_in' | 'paid_out' | 'no_sale'; amountMinor: number; reason: string }) => {
|
|
403
|
+
// `live()` first: missing collections or host refuse before `requireOpen` writes anything.
|
|
404
|
+
const { sessions, movements, closures, registerId } = live();
|
|
405
|
+
const sessionId = await requireOpen();
|
|
406
|
+
const row = await store.recordMovement(sessions, movements, closures, { ...input, sessionId: sessionId!, actor: actor.id });
|
|
407
|
+
recordRegisterFact({
|
|
408
|
+
kind: 'movement-recorded', actor, sessionId: row.session_id, registerId,
|
|
409
|
+
movementId: row.id, movementType: row.type, amount: row.amountMinor,
|
|
410
|
+
});
|
|
411
|
+
return row;
|
|
412
|
+
},
|
|
413
|
+
voidMovement: async (id: string) => {
|
|
414
|
+
const { sessions, movements, closures, registerId } = live();
|
|
415
|
+
await requireOpen();
|
|
416
|
+
const row = await store.voidMovement(sessions, movements, id, actor.id, closures);
|
|
417
|
+
recordRegisterFact({
|
|
418
|
+
kind: 'movement-voided', actor, sessionId: row.session_id, registerId,
|
|
419
|
+
movementId: row.id, movementType: row.type, amount: row.amountMinor, voids: id,
|
|
420
|
+
});
|
|
421
|
+
return row;
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
package/src/sale/cart.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolvePrice, type ProductTraits } from '@tallyui/core';
|
|
2
|
+
import type { OrderBuilder } from '../order';
|
|
3
|
+
import type { CatalogueEntry } from './catalogue';
|
|
4
|
+
|
|
5
|
+
export class CartError extends Error {}
|
|
6
|
+
|
|
7
|
+
export function addEntryToCart<Doc>(builder: OrderBuilder, entry: CatalogueEntry<Doc>,
|
|
8
|
+
traits: ProductTraits<Doc>, currency: string): string {
|
|
9
|
+
const { product, variant } = entry;
|
|
10
|
+
const existing = builder.getSnapshot().lineItems.find((line) => line.variantId === variant.id);
|
|
11
|
+
if (existing) {
|
|
12
|
+
builder.updateQuantity(existing.id, existing.quantity + 1);
|
|
13
|
+
return existing.id;
|
|
14
|
+
}
|
|
15
|
+
const name = traits.getName(product) + (traits.getVariantCount(product) > 1 ? ` · ${variant.title}` : '');
|
|
16
|
+
const unitPrice = resolvePrice(variant.prices, currency)?.current;
|
|
17
|
+
if (!unitPrice) throw new CartError(`No ${currency} price for ${name}`);
|
|
18
|
+
return builder.addLine({
|
|
19
|
+
productId: traits.getId(product), variantId: variant.id, name,
|
|
20
|
+
sku: variant.sku ?? '', imageUrl: traits.getImageUrl?.(product), unitPrice,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { findVariantByCode, formatMoney, resolvePrice } from '@tallyui/core';
|
|
2
|
+
import type { ProductTraits, VariantSummary } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
export type CatalogueEntry<Doc> = { product: Doc; variant: VariantSummary };
|
|
5
|
+
|
|
6
|
+
/** Every variant of every product, in product order then variant order. */
|
|
7
|
+
export function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>): CatalogueEntry<Doc>[] {
|
|
8
|
+
return products.flatMap((product) => traits.getVariants!(product).map((variant) => ({ product, variant })));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Barcode-then-SKU lookup across all products. */
|
|
12
|
+
export function findEntryByCode<Doc>(entries: CatalogueEntry<Doc>[], code: string): CatalogueEntry<Doc> | undefined {
|
|
13
|
+
const variant = findVariantByCode(entries.map((entry) => entry.variant), code);
|
|
14
|
+
return variant ? entries.find((entry) => entry.variant === variant) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Display the resolved price in the requested currency. */
|
|
18
|
+
export function variantPriceLabel(variant: VariantSummary, currency: string, locale?: string): string | undefined {
|
|
19
|
+
const price = resolvePrice(variant.prices, currency);
|
|
20
|
+
return price ? formatMoney(price.current, locale) : undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { useSale, DISCOUNTS_UNSUPPORTED, SALE_SAVING, saleLogger } from './use-sale';
|
|
2
|
+
export type { SaleStage } from './use-sale';
|
|
3
|
+
export { addEntryToCart, CartError } from './cart';
|
|
4
|
+
export { catalogueEntries, findEntryByCode, variantPriceLabel } from './catalogue';
|
|
5
|
+
export type { CatalogueEntry } from './catalogue';
|