@tallyui/pos 3.8.0 → 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.
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Everything here writes local-only collections (`schemas.ts`). WCPOS's outbox (`pending`,
6
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
7
+ * whose `sessionId` is the session's id, and their payments are its ledger rows.
8
+ * A session's refunds are its applied `pos_refunds` records, when the app passes them (ADR-080 amendment 1). The frozen closure's money
9
9
  * breakdowns (`payment_methods`, `opening_float`, `movements`, `tax_rates`) are minor-unit
10
10
  * integers, the same convention as the closure row itself; `closure-document.ts` converts them
11
11
  * to the decimal strings its envelope carries.
@@ -13,8 +13,9 @@
13
13
  import type { RxCollection } from 'rxdb';
14
14
  import { DEFAULT_TAX_ROUNDING, taxLinesByRate } from '../tax/exact';
15
15
  import type { PosOrder } from '../pos-order/types';
16
+ import type { PosRefund } from '../refund/pos-refund';
16
17
  import { readFresh } from '../rxdb';
17
- import { deriveExpected, type LedgerRow } from './expected';
18
+ import { deriveExpected, refundAmounts, type LedgerRow } from './expected';
18
19
  import { recordRegisterFact } from './facts';
19
20
  import { MAX_REASON_LENGTH } from './movement-input';
20
21
  import { countVariance } from './register-count.helpers';
@@ -440,6 +441,7 @@ export async function voidMovement(
440
441
  * Only a closed session (`closed` with `closed_at_gmt`) has a closure, and a closed session is
441
442
  * final, so its existing closure is returned as it was frozen. Orders the server rejected still
442
443
  * count in the drawer, because the cash was taken.
444
+ * A refund still pending at close is not counted, and the Z lists it.
443
445
  */
444
446
  export async function writeClosure({
445
447
  closures,
@@ -450,6 +452,7 @@ export async function writeClosure({
450
452
  otherTenders,
451
453
  movements,
452
454
  orders,
455
+ refunds,
453
456
  tillExpected,
454
457
  labels,
455
458
  resolveCashierName,
@@ -467,6 +470,8 @@ export async function writeClosure({
467
470
  movements: readonly CashMovement[];
468
471
  /** Any `pos_orders`; only those whose `sessionId` is this session's are counted. */
469
472
  orders: readonly PosOrder[];
473
+ /** `pos_refunds` rows; only this session's applied ones are counted (ADR-080 amendment 1). */
474
+ refunds?: readonly PosRefund[];
470
475
  tillExpected?: Record<string, number>;
471
476
  labels?: { register_name: string; closed_by_name: string; opened_by_name?: string; approved_by_name?: string };
472
477
  resolveCashierName?: (id: string) => string;
@@ -486,6 +491,7 @@ export async function writeClosure({
486
491
  return existing;
487
492
  }
488
493
  const bound = orders.filter((order) => order.sessionId === session.id);
494
+ const countedRefunds = (refunds ?? []).filter((refund) => refund.status === 'applied' && refund.sessionId === session.id);
489
495
  // Each payment is captured, and a cash payment's amount is already net of change.
490
496
  const rows: LedgerRow[] = bound.flatMap((order) =>
491
497
  order.payments.map((payment) => ({
@@ -503,6 +509,7 @@ export async function writeClosure({
503
509
  session: { id: session.id, countedFloatMinor: session.counted_float_minor },
504
510
  movements: entries,
505
511
  ledgerRowsBySession: rows,
512
+ refunds,
506
513
  });
507
514
  const counts = { cash: counted, ...otherTenders };
508
515
  const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
@@ -511,6 +518,11 @@ export async function writeClosure({
511
518
  const method = row.kind === 'cash' ? 'cash' : row.method_id;
512
519
  payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row.amountMinor, refunds_minor: 0 };
513
520
  }
521
+ for (const refund of countedRefunds) {
522
+ for (const [method, amount] of refundAmounts(refund)) {
523
+ payment_methods[method] = { sales_minor: payment_methods[method]?.sales_minor ?? 0, refunds_minor: (payment_methods[method]?.refunds_minor ?? 0) + amount };
524
+ }
525
+ }
514
526
  // Same per-rate split a receipt shows (`taxLinesByRate`), run per order so each order's rates
515
527
  // add up to its own taxMinor, then summed across the session's orders by rate. A line's own
516
528
  // taxInclusive overrides the order's, matching PosOrderLine's own fallback convention. Each order is split by the
@@ -552,7 +564,7 @@ export async function writeClosure({
552
564
  Object.entries(counts).map(([method, value]) => [method, countVariance(value, till_expected[method] ?? 0)]),
553
565
  ),
554
566
  period_sales_total_minor: sum(rows.map((row) => row.amountMinor)),
555
- period_refunds_total_minor: 0,
567
+ period_refunds_total_minor: sum(countedRefunds.flatMap((refund) => refundAmounts(refund).map(([, amount]) => amount))),
556
568
  perpetual_sales_total_minor: 0,
557
569
  perpetual_refunds_total_minor: 0,
558
570
  unsynced_count: unsynced.length,
@@ -581,7 +593,11 @@ export async function writeClosure({
581
593
  id, type, amountMinor, reason, voids: voids ?? null, created_at_gmt, created_by: created_by ?? null, voided_by: voided_by ?? null,
582
594
  })),
583
595
  transaction_count: bound.length,
584
- refund_count: 0,
596
+ refund_count: countedRefunds.length,
597
+ ...(refunds !== undefined ? {
598
+ refund_ids: countedRefunds.map((refund) => refund.id),
599
+ pending_refund_ids: refunds.filter((refund) => refund.status === 'pending' && refund.sessionId === session.id).map((refund) => refund.id),
600
+ } : {}),
585
601
  cashiers: [...new Set(bound.map((order) => order.cashierRef ?? '').filter(Boolean))].map((id) => ({
586
602
  id,
587
603
  name: resolveCashierName?.(id) || id,
@@ -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.
@@ -19,6 +19,7 @@ import { combineLatest, map, of, switchMap } from 'rxjs';
19
19
  import type { RxCollection } from 'rxdb';
20
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';
@@ -80,6 +81,8 @@ export interface UseRegisterSessionOptions {
80
81
  sessions: RegisterSessionCollection | null;
81
82
  /** `cash_movements`; `null` while it opens. */
82
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;
83
86
  /** `closures`; `null` while it opens. */
84
87
  closures: ClosureCollection | null;
85
88
  /** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
@@ -118,7 +121,7 @@ export interface UseRegisterSessionOptions {
118
121
  type Reservation = RegisterBucket['closure_reservation'];
119
122
  type Snapshot = {
120
123
  rows: RegisterSession[]; closureRows: Closure[]; reservation: Reservation;
121
- entries: CashMovement[]; sales: PosOrder[];
124
+ entries: CashMovement[]; sales: PosOrder[]; refundRows: PosRefund[];
122
125
  open?: RegisterCommand;
123
126
  };
124
127
 
@@ -153,7 +156,7 @@ function ledgerRows(orders: readonly PosOrder[]): LedgerRow[] {
153
156
 
154
157
  export function useRegisterSession(options: UseRegisterSessionOptions) {
155
158
  const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
156
- const { commands } = options;
159
+ const { commands, refunds } = options;
157
160
  const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
158
161
  const commandTarget = useMemo(() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId
159
162
  ? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
@@ -180,21 +183,22 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
180
183
  return () => subscription.unsubscribe();
181
184
  }, [commandTarget]);
182
185
  const source = useMemo(() => {
183
- if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
186
+ if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register || refunds === null) return null;
184
187
  return combineLatest([
185
188
  watchFresh(sessions, { selector: { register_id: registerId } }),
186
189
  watchFresh(closures, { selector: { register_id: registerId } }),
187
190
  observeRegister$(register).pipe(map((document) => reservationOf(document, storeKey, registerId))),
188
191
  ]).pipe(switchMap(([rows, closureRows, reservation]) => {
189
192
  const current = currentSession(rows, closureRows, reservation);
190
- if (!current) return of({ rows, closureRows, reservation, entries: [], sales: [] } as Snapshot);
193
+ if (!current) return of({ rows, closureRows, reservation, entries: [], sales: [], refundRows: [] } as Snapshot);
191
194
  return combineLatest([
192
195
  watchFresh(movements, { selector: { session_id: current.id } }),
193
196
  watchFresh(orders, { selector: { sessionId: current.id } }),
194
197
  commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current.id}` } }) : of([]),
195
- ]).pipe(map(([entries, sales, opens]) => ({ rows, closureRows, reservation, entries, sales, open: opens[0] })));
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 })));
196
200
  }));
197
- }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget]);
201
+ }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget, refunds]);
198
202
  // A latest-value ref: an `actions` object from an earlier render still sees the current flag.
199
203
  const tender = useRef(tenderInProgress);
200
204
  tender.current = tenderInProgress;
@@ -273,7 +277,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
273
277
  const entries = data?.entries ?? [];
274
278
  const sales = data?.sales ?? [];
275
279
  const expected = session
276
- ? 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 ?? [] })
277
281
  : {};
278
282
  const now = new Date();
279
283
  const [hour, minute] = String(expectedCloseTime ?? '').split(':').map(Number);
@@ -287,8 +291,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
287
291
  return id === null ? null : { id, sessions: sessions as RegisterSessionCollection };
288
292
  };
289
293
  const live = () => {
290
- if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) throw new RegisterSessionRequiredError();
291
- 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 };
292
296
  };
293
297
  const current = () => {
294
298
  if (!session) throw new RegisterSessionRequiredError();
@@ -330,8 +334,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
330
334
  await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
331
335
  reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
332
336
  if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
333
- if ((options.capabilities?.register ?? 0) < 2) throw new store.RegisterTakeOverError('REGISTER_TAKEOVER_UNSUPPORTED');
334
- await takeOverSession({ ...commandTarget, sessionId: row.id });
337
+ await takeOverSession({ ...commandTarget, sessionId: row.id, registerContract: options.capabilities?.register });
335
338
  },
336
339
  chooseAnotherRegister: async () => {
337
340
  if (!commandTarget) throw new RegisterSessionRequiredError();
@@ -420,7 +423,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
420
423
  const key = registerId ?? '';
421
424
  if (closingByRegister.has(key)) return closingByRegister.get(key)!;
422
425
  const run = (async () => {
423
- const { sessions, movements, closures, orders, register } = live();
426
+ const { sessions, movements, closures, orders, register, refunds } = live();
424
427
  const open = current();
425
428
  // The gate reads the stored session and the Z's own inputs past the query cache, not this
426
429
  // render's snapshot, which can lag a sale or a close that already landed (#168 review).
@@ -432,6 +435,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
432
435
  session: { id: stored.id, countedFloatMinor: stored.counted_float_minor },
433
436
  movements: await readFresh(movements, { selector: { session_id: stored.id } }),
434
437
  ledgerRowsBySession: ledgerRows(await readFresh(orders, { selector: { sessionId: stored.id } })),
438
+ refunds: refunds ? await readFresh(refunds, { selector: { sessionId: stored.id } }) : undefined,
435
439
  });
436
440
  if (closeNeedsApproval(input.counted.cash ?? 0, fresh.cash ?? 0, options.varianceThreshold)) throw new RegisterApprovalRequiredError();
437
441
  }
@@ -448,6 +452,7 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
448
452
  counted: cash, otherTenders, timezone, softwareVersion: options.softwareVersion,
449
453
  movements: await readFresh(movements, { selector: { session_id: closed.id } }),
450
454
  orders: await readFresh(orders, { selector: { sessionId: closed.id } }),
455
+ refunds: refunds ? await readFresh(refunds, { selector: { sessionId: closed.id } }) : undefined,
451
456
  resolveCashierName: labels?.resolveCashierName,
452
457
  labels: {
453
458
  register_name: labels?.registerName ?? '', closed_by_name: nameOf(closed.closed_by),