@tallyui/pos 3.8.0 → 3.9.1
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 +112 -17
- package/dist/index.js +303 -35
- package/package.json +8 -7
- package/src/index.ts +3 -1
- 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/types.test-d.ts +5 -0
- 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/register-adoption.ts +7 -4
- package/src/register/schemas.ts +1 -1
- package/src/register/session-store.ts +21 -5
- package/src/register/use-register-session.ts +18 -13
|
@@ -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.
|
|
8
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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),
|