@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.
@@ -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 records, refund parents and their demand and close-time wait: no refund model.
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 { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
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 !== 'closed')
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
- ]).pipe(map(([entries, sales]) => ({ rows, closureRows, reservation, entries, sales })));
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 !== 'closed' ? { id: session.id, sessions } : undefined,
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 !== 'closed')) throw new RegisterSessionAlreadyOpenError();
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),