@tallyui/pos 3.7.1 → 3.9.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 +167 -16
- package/dist/index.js +487 -49
- package/package.json +8 -7
- package/src/index.ts +5 -3
- package/src/order/index.ts +2 -1
- package/src/order/order-builder.ts +41 -9
- package/src/order/order-drafts.ts +3 -1
- package/src/order/types.ts +11 -1
- package/src/order/woocommerce-tax.ts +61 -4
- package/src/outbox/register-outbox.ts +26 -3
- package/src/outbox/types.test-d.ts +5 -0
- package/src/outbox/use-register-outbox.ts +6 -2
- package/src/refund/index.ts +3 -0
- package/src/refund/pos-refund.ts +35 -0
- package/src/refund/send-order-refund.ts +43 -0
- package/src/refund/submit-order-refund.ts +85 -0
- package/src/register/expected.ts +23 -6
- package/src/register/index.ts +2 -1
- package/src/register/register-adoption.ts +105 -0
- package/src/register/register-commands.ts +20 -7
- package/src/register/schemas.ts +3 -3
- package/src/register/session-store.ts +42 -6
- package/src/register/use-register-session.ts +64 -19
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Not ported (registers job c2 owns the server side; TallyUI has no refund model yet):
|
|
11
11
|
* - `retryMovement` and `refusedMovements`: there's no outbox to refuse a movement (c2).
|
|
12
|
-
* - Refund
|
|
12
|
+
* - Refund parents and the close-time wait: a refund's record is the app's optional `refunds` collection, and one still pending at close is listed on the Z instead (ADR-080 amendment 1).
|
|
13
13
|
* - Anchor invalidation, `server_expected` and `server_sales_count`: no server figures (c2).
|
|
14
14
|
* - `unsyncedCount` and the `sync_status` filters: nothing is sent (c2).
|
|
15
15
|
* - The binding (`useRegisterBinding`) and `blind` from WooCommerce capabilities: app inputs.
|
|
@@ -17,13 +17,15 @@
|
|
|
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
|
+
import type { RegisterSessionAlreadyOpenData, ServerCapabilities } from '@tallyui/core';
|
|
21
21
|
import type { PosOrder } from '../pos-order/types';
|
|
22
|
+
import type { PosRefund, PosRefundCollection } from '../refund/pos-refund';
|
|
22
23
|
import { readFresh, watchFresh } from '../rxdb';
|
|
23
24
|
import { deriveExpected, type LedgerRow } from './expected';
|
|
24
25
|
import { recordRegisterFact, registerFactsLogger, type Actor } from './facts';
|
|
25
26
|
import { closeNeedsApproval } from './register-count.helpers';
|
|
26
|
-
import {
|
|
27
|
+
import { abandonSession, adoptRegisterResults, takeOverSession } from './register-adoption';
|
|
28
|
+
import { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommand, type RegisterCommandCollection } from './register-commands';
|
|
27
29
|
import { observeRegister$, readRegister, type RegisterBucket, type RegisterDocument, type RegisterHost } from './register-document';
|
|
28
30
|
import type { CashMovement, Closure, RegisterSession } from './schemas';
|
|
29
31
|
import * as store from './session-store';
|
|
@@ -79,11 +81,15 @@ export interface UseRegisterSessionOptions {
|
|
|
79
81
|
sessions: RegisterSessionCollection | null;
|
|
80
82
|
/** `cash_movements`; `null` while it opens. */
|
|
81
83
|
movements: CashMovementCollection | null;
|
|
84
|
+
/** `pos_refunds` (`posRefundCollection()`); `null` while it opens. Leave it out when the app takes no refunds (ADR-080 amendment 1). */
|
|
85
|
+
refunds?: PosRefundCollection | null;
|
|
82
86
|
/** `closures`; `null` while it opens. */
|
|
83
87
|
closures: ClosureCollection | null;
|
|
84
88
|
/** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
|
|
85
89
|
commands?: RegisterCommandCollection | null;
|
|
86
90
|
capabilities?: ServerCapabilities;
|
|
91
|
+
/** The till's name from the app's settings, sent on a register v2 open; the app supplies its platform fallback. */
|
|
92
|
+
deviceName?: string | null;
|
|
87
93
|
/** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
|
|
88
94
|
orders: RxCollection<PosOrder> | null;
|
|
89
95
|
/** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
|
|
@@ -115,7 +121,8 @@ export interface UseRegisterSessionOptions {
|
|
|
115
121
|
type Reservation = RegisterBucket['closure_reservation'];
|
|
116
122
|
type Snapshot = {
|
|
117
123
|
rows: RegisterSession[]; closureRows: Closure[]; reservation: Reservation;
|
|
118
|
-
entries: CashMovement[]; sales: PosOrder[];
|
|
124
|
+
entries: CashMovement[]; sales: PosOrder[]; refundRows: PosRefund[];
|
|
125
|
+
open?: RegisterCommand;
|
|
119
126
|
};
|
|
120
127
|
|
|
121
128
|
const reservationOf = (register: RegisterDocument | null, storeKey: string, registerId: string) =>
|
|
@@ -128,12 +135,13 @@ const unwritten = (row: RegisterSession, closureRows: readonly { id: string }[])
|
|
|
128
135
|
/**
|
|
129
136
|
* The session for this register: first the one an unapplied closure reservation names (its close
|
|
130
137
|
* was interrupted after the number was reserved, perhaps after its row was written), so it can
|
|
131
|
-
* finish; then the open or counting one; then a closed one whose closure row was never written; null if any status is unknown.
|
|
138
|
+
* finish; then the open or counting one; then a conflict one; then a closed one whose closure row was never written; null if any status is unknown.
|
|
132
139
|
*/
|
|
133
140
|
function currentSession(rows: RegisterSession[], closureRows: Closure[], reservation: Reservation) {
|
|
134
141
|
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) return null;
|
|
135
142
|
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : undefined)
|
|
136
|
-
?? rows.find((row) => row.status
|
|
143
|
+
?? rows.find((row) => row.status === 'open' || row.status === 'counting')
|
|
144
|
+
?? rows.find((row) => row.status === 'conflict')
|
|
137
145
|
?? rows.find((row) => unwritten(row, closureRows))
|
|
138
146
|
?? null;
|
|
139
147
|
}
|
|
@@ -148,11 +156,11 @@ function ledgerRows(orders: readonly PosOrder[]): LedgerRow[] {
|
|
|
148
156
|
|
|
149
157
|
export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
150
158
|
const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
|
|
151
|
-
const { commands } = options;
|
|
159
|
+
const { commands, refunds } = options;
|
|
152
160
|
const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
|
|
153
161
|
const commandTarget = useMemo(() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId
|
|
154
|
-
? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
|
|
155
|
-
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]);
|
|
162
|
+
? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
|
|
163
|
+
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId, options.capabilities?.register, options.deviceName]);
|
|
156
164
|
const commandTargetRef = useRef(commandTarget);
|
|
157
165
|
commandTargetRef.current = commandTarget;
|
|
158
166
|
const reconcile = (row?: RegisterSession) => {
|
|
@@ -167,21 +175,30 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
167
175
|
});
|
|
168
176
|
};
|
|
169
177
|
useEffect(reconcile, [commandTarget]);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!commandTarget) return;
|
|
180
|
+
const subscription = commandTarget.commands.find({ selector: { registerId: commandTarget.registerId } }).$.subscribe(() => {
|
|
181
|
+
void adoptRegisterResults(commandTarget);
|
|
182
|
+
});
|
|
183
|
+
return () => subscription.unsubscribe();
|
|
184
|
+
}, [commandTarget]);
|
|
170
185
|
const source = useMemo(() => {
|
|
171
|
-
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
186
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register || refunds === null) return null;
|
|
172
187
|
return combineLatest([
|
|
173
188
|
watchFresh(sessions, { selector: { register_id: registerId } }),
|
|
174
189
|
watchFresh(closures, { selector: { register_id: registerId } }),
|
|
175
190
|
observeRegister$(register).pipe(map((document) => reservationOf(document, storeKey, registerId))),
|
|
176
191
|
]).pipe(switchMap(([rows, closureRows, reservation]) => {
|
|
177
192
|
const current = currentSession(rows, closureRows, reservation);
|
|
178
|
-
if (!current) return of({ rows, closureRows, reservation, entries: [], sales: [] } as Snapshot);
|
|
193
|
+
if (!current) return of({ rows, closureRows, reservation, entries: [], sales: [], refundRows: [] } as Snapshot);
|
|
179
194
|
return combineLatest([
|
|
180
195
|
watchFresh(movements, { selector: { session_id: current.id } }),
|
|
181
196
|
watchFresh(orders, { selector: { sessionId: current.id } }),
|
|
182
|
-
|
|
197
|
+
commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current.id}` } }) : of([]),
|
|
198
|
+
refunds ? watchFresh(refunds, { selector: { sessionId: current.id } }) : of([]),
|
|
199
|
+
]).pipe(map(([entries, sales, opens, refundRows]) => ({ rows, closureRows, reservation, entries, sales, open: opens[0], refundRows })));
|
|
183
200
|
}));
|
|
184
|
-
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
|
|
201
|
+
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget, refunds]);
|
|
185
202
|
// A latest-value ref: an `actions` object from an earlier render still sees the current flag.
|
|
186
203
|
const tender = useRef(tenderInProgress);
|
|
187
204
|
tender.current = tenderInProgress;
|
|
@@ -251,10 +268,16 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
251
268
|
}
|
|
252
269
|
}, [data, source]);
|
|
253
270
|
const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
|
|
271
|
+
const refusal = data?.open?.error?.data as RegisterSessionAlreadyOpenData | undefined;
|
|
272
|
+
const conflict = session?.status !== 'conflict' || !commandTarget ? undefined
|
|
273
|
+
: data?.open?.syncStatus === 'pending' && typeof data.open.payload.supersedes === 'string'
|
|
274
|
+
? { sessionId: data.open.payload.supersedes, takingOver: true }
|
|
275
|
+
: data?.open?.syncStatus === 'rejected' && refusal
|
|
276
|
+
? { sessionId: refusal.sessionId, openedAt: refusal.openedAt, openedBy: refusal.openedBy, deviceName: refusal.deviceName, takingOver: false } : undefined;
|
|
254
277
|
const entries = data?.entries ?? [];
|
|
255
278
|
const sales = data?.sales ?? [];
|
|
256
279
|
const expected = session
|
|
257
|
-
? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) })
|
|
280
|
+
? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales), refunds: data?.refundRows ?? [] })
|
|
258
281
|
: {};
|
|
259
282
|
const now = new Date();
|
|
260
283
|
const [hour, minute] = String(expectedCloseTime ?? '').split(':').map(Number);
|
|
@@ -268,8 +291,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
268
291
|
return id === null ? null : { id, sessions: sessions as RegisterSessionCollection };
|
|
269
292
|
};
|
|
270
293
|
const live = () => {
|
|
271
|
-
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) throw new RegisterSessionRequiredError();
|
|
272
|
-
return { sessions, movements, closures, orders, registerId, register };
|
|
294
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register || refunds === null) throw new RegisterSessionRequiredError();
|
|
295
|
+
return { sessions, movements, closures, orders, registerId, register, refunds };
|
|
273
296
|
};
|
|
274
297
|
const current = () => {
|
|
275
298
|
if (!session) throw new RegisterSessionRequiredError();
|
|
@@ -282,6 +305,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
282
305
|
|
|
283
306
|
return {
|
|
284
307
|
session,
|
|
308
|
+
/** The other till's live session, or the session this till is waiting to take over. */
|
|
309
|
+
conflict,
|
|
285
310
|
/** Any unknown session status in the current snapshot means the register needs upgrade. */
|
|
286
311
|
needsUpgrade: data?.rows.some((row) => !store.isKnownSessionStatus(row.status)) ?? false,
|
|
287
312
|
movements: entries,
|
|
@@ -297,17 +322,34 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
297
322
|
enabled,
|
|
298
323
|
blind: options.blind ?? false,
|
|
299
324
|
/** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
|
|
300
|
-
saleSession: session && sessions && session.status
|
|
325
|
+
saleSession: session && sessions && (session.status === 'open' || session.status === 'counting') ? { id: session.id, sessions } : undefined,
|
|
301
326
|
/** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
|
|
302
327
|
requireOpen,
|
|
303
328
|
/** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
|
|
304
329
|
requireSaleSession,
|
|
305
330
|
actions: {
|
|
331
|
+
takeOver: async () => {
|
|
332
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
333
|
+
const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
334
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
335
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
|
|
336
|
+
if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
|
|
337
|
+
await takeOverSession({ ...commandTarget, sessionId: row.id, registerContract: options.capabilities?.register });
|
|
338
|
+
},
|
|
339
|
+
chooseAnotherRegister: async () => {
|
|
340
|
+
if (!commandTarget) throw new RegisterSessionRequiredError();
|
|
341
|
+
const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
|
|
342
|
+
await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
|
|
343
|
+
reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
|
|
344
|
+
if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
|
|
345
|
+
await abandonSession({ ...commandTarget, sessionId: row.id });
|
|
346
|
+
},
|
|
306
347
|
/**
|
|
307
348
|
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
308
349
|
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
309
350
|
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
310
351
|
* An unknown stored status refuses with `RegisterNeedsUpgradeError`.
|
|
352
|
+
* A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
|
|
311
353
|
*/
|
|
312
354
|
openSession: async (input: { expectedFloatMinor: number | null; countedFloatMinor: number }) => {
|
|
313
355
|
// A double tap: the second call sees the first's promise, set before its first await.
|
|
@@ -318,7 +360,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
318
360
|
// The storage, not the rendered snapshot or a cached query, which can lag a new write.
|
|
319
361
|
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
320
362
|
if (rows.some((row) => !store.isKnownSessionStatus(row.status))) throw new store.RegisterNeedsUpgradeError();
|
|
321
|
-
if (rows.some((row) => row.status
|
|
363
|
+
if (rows.some((row) => row.status === 'conflict')) throw new store.RegisterSessionConflictError();
|
|
364
|
+
if (rows.some((row) => row.status === 'open' || row.status === 'counting')) throw new RegisterSessionAlreadyOpenError();
|
|
322
365
|
// Closure rows before the reservation: a close reserves, then inserts its row, then applies the
|
|
323
366
|
// reservation, so a close landing between the two reads is still caught by one of them.
|
|
324
367
|
const closureRows = await readFresh(closures, { selector: { register_id: registerId } });
|
|
@@ -380,7 +423,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
380
423
|
const key = registerId ?? '';
|
|
381
424
|
if (closingByRegister.has(key)) return closingByRegister.get(key)!;
|
|
382
425
|
const run = (async () => {
|
|
383
|
-
const { sessions, movements, closures, orders, register } = live();
|
|
426
|
+
const { sessions, movements, closures, orders, register, refunds } = live();
|
|
384
427
|
const open = current();
|
|
385
428
|
// The gate reads the stored session and the Z's own inputs past the query cache, not this
|
|
386
429
|
// render's snapshot, which can lag a sale or a close that already landed (#168 review).
|
|
@@ -392,6 +435,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
392
435
|
session: { id: stored.id, countedFloatMinor: stored.counted_float_minor },
|
|
393
436
|
movements: await readFresh(movements, { selector: { session_id: stored.id } }),
|
|
394
437
|
ledgerRowsBySession: ledgerRows(await readFresh(orders, { selector: { sessionId: stored.id } })),
|
|
438
|
+
refunds: refunds ? await readFresh(refunds, { selector: { sessionId: stored.id } }) : undefined,
|
|
395
439
|
});
|
|
396
440
|
if (closeNeedsApproval(input.counted.cash ?? 0, fresh.cash ?? 0, options.varianceThreshold)) throw new RegisterApprovalRequiredError();
|
|
397
441
|
}
|
|
@@ -408,6 +452,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
|
|
|
408
452
|
counted: cash, otherTenders, timezone, softwareVersion: options.softwareVersion,
|
|
409
453
|
movements: await readFresh(movements, { selector: { session_id: closed.id } }),
|
|
410
454
|
orders: await readFresh(orders, { selector: { sessionId: closed.id } }),
|
|
455
|
+
refunds: refunds ? await readFresh(refunds, { selector: { sessionId: closed.id } }) : undefined,
|
|
411
456
|
resolveCashierName: labels?.resolveCashierName,
|
|
412
457
|
labels: {
|
|
413
458
|
register_name: labels?.registerName ?? '', closed_by_name: nameOf(closed.closed_by),
|