@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.
@@ -0,0 +1,85 @@
1
+ import type { OrderRefundEnvelope } from '@tallyui/core';
2
+ import { RxError } from 'rxdb';
3
+ import type { CommandTransport } from '../outbox/types';
4
+ import type { PosRefund, PosRefundCollection } from './pos-refund';
5
+ import { sendOrderRefund, type OrderRefundOutcome } from './send-order-refund';
6
+
7
+ export class RefundAnswerPendingError extends Error {
8
+ readonly code = 'REFUND_ANSWER_PENDING';
9
+ constructor(public readonly record: PosRefund) {
10
+ super("This refund's answer is still unknown. Send it again before trying a new refund attempt.");
11
+ this.name = 'RefundAnswerPendingError';
12
+ }
13
+ }
14
+
15
+ /** Online only, never queued; the record is written before the send.
16
+ * An applied record is final; a pending one is resent only with its own envelope
17
+ * (ADR-080 amendment 1). */
18
+ export async function submitOrderRefund({ refunds, transport, envelope, now = () => new Date().toISOString() }: {
19
+ refunds: PosRefundCollection; transport: CommandTransport<OrderRefundEnvelope>;
20
+ envelope: OrderRefundEnvelope; now?: () => string;
21
+ }): Promise<{ outcome: OrderRefundOutcome; record: PosRefund }> {
22
+ const id = envelope.payload.clientRefundId;
23
+ let doc = await refunds.findOne(id).exec();
24
+ if (!doc) {
25
+ const at = now();
26
+ const { orderId, sessionId, registerId } = envelope.payload;
27
+ try {
28
+ doc = await refunds.insert({
29
+ id, commandId: envelope.id, orderId, sessionId, registerId, envelope,
30
+ status: 'pending', createdAt: at, updatedAt: at,
31
+ });
32
+ } catch (error) {
33
+ if (!(error instanceof RxError) || error.code !== 'CONFLICT') throw error;
34
+ doc = (await refunds.findOne(id).exec())!;
35
+ }
36
+ }
37
+ let record = doc.toJSON() as PosRefund;
38
+ // Re-evaluate the stored state at most once after a guarded attempt write.
39
+ for (let pass = 0; ; pass++) {
40
+ if (pass === 2) throw new RefundAnswerPendingError(record);
41
+ if (record.status === 'applied') {
42
+ return { outcome: { kind: 'applied', refund: record.result!, duplicate: true }, record };
43
+ }
44
+ if (record.status === 'pending') {
45
+ if (record.commandId !== envelope.id) throw new RefundAnswerPendingError(record);
46
+ break;
47
+ }
48
+ if (record.status === 'rejected' && record.commandId === envelope.id) {
49
+ return { outcome: { kind: 'rejected', error: record.error! }, record };
50
+ }
51
+ if (record.status === 'unsent' && record.commandId === envelope.id) {
52
+ doc = await doc.incrementalModify((stored) => {
53
+ if (stored.status !== 'unsent' || stored.commandId !== envelope.id) return stored;
54
+ const { error, ...rest } = stored;
55
+ return { ...rest, status: 'pending', updatedAt: now() };
56
+ });
57
+ record = doc.toJSON() as PosRefund;
58
+ continue;
59
+ }
60
+ doc = await doc.incrementalModify((stored) => {
61
+ if (stored.status !== 'rejected' && stored.status !== 'unsent') return stored;
62
+ const { result, duplicate, error, ...rest } = stored;
63
+ return { ...rest, commandId: envelope.id, envelope, status: 'pending', updatedAt: now() };
64
+ });
65
+ record = doc.toJSON() as PosRefund;
66
+ if (record.commandId === envelope.id) break;
67
+ }
68
+ const outcome = await sendOrderRefund(transport, envelope);
69
+ if (outcome.kind !== 'unknown') {
70
+ doc = await doc.incrementalModify((stored) => {
71
+ if (stored.status !== 'pending' || stored.commandId !== envelope.id) return stored;
72
+ const updatedAt = now();
73
+ if (outcome.kind === 'applied') {
74
+ const { error, ...rest } = stored;
75
+ return { ...rest, status: 'applied', result: outcome.refund, duplicate: outcome.duplicate, updatedAt };
76
+ }
77
+ if (outcome.kind === 'rejected') return { ...stored, status: 'rejected', error: outcome.error, updatedAt };
78
+ const error = outcome.kind === 'refused'
79
+ ? { code: 'refused', message: outcome.reason, data: { status: outcome.status } }
80
+ : { code: 'unauthorized', message: 'unauthorized' };
81
+ return { ...stored, status: 'unsent', error, updatedAt };
82
+ });
83
+ }
84
+ return { outcome, record: doc.getLatest().toJSON() as PosRefund };
85
+ }
@@ -3,12 +3,23 @@
3
3
  * captured payments and cash movements — never what the cashier counts. Port provenance
4
4
  * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
5
5
  *
6
- * **Refund attribution is deferred.** WCPOS's `attributeRefunds` debits a session's drawer for
7
- * refunds processed against its captured payments (by stamped session, then by a legacy
8
- * `refunded_amount` fallback). TallyUI has no refund model yet (ADR-032 amendment 1), so
9
- * `deriveExpected` covers only the float, the session's captured payment rows, and its
10
- * paid-in/paid-out cash movements under WCPOS's void rules.
6
+ * Refunds come from the till's applied `pos_refunds`, counted in the record's own `sessionId`
7
+ * (ADR-080 amendment 1), by the server's rule. WCPOS's `attributeRefunds` fallbacks (allocations,
8
+ * `refunded_amount`) are not ported: the store attributes each refund to a tender in `byMethod`.
11
9
  */
10
+ import type { PosRefund } from '../refund/pos-refund';
11
+
12
+ export type RefundRow = Pick<PosRefund, 'id' | 'sessionId' | 'status' | 'result'>;
13
+
14
+ export function refundAmounts(refund: RefundRow): Array<[string, number]> {
15
+ const result = refund.result;
16
+ if (typeof result !== 'object' || result === null ||
17
+ (Object.getPrototypeOf(result) !== Object.prototype && Object.getPrototypeOf(result) !== null)) return [];
18
+ const byMethod = result.byMethod;
19
+ if (typeof byMethod !== 'object' || byMethod === null ||
20
+ (Object.getPrototypeOf(byMethod) !== Object.prototype && Object.getPrototypeOf(byMethod) !== null)) return [];
21
+ return Object.entries(byMethod).filter(([method, amount]) => method.length > 0 && Number.isSafeInteger(amount) && amount >= 0);
22
+ }
12
23
 
13
24
  /** `session_id`, `kind`, `method_id`, `status` kept as WCPOS names them; money is TallyUI's integer-minor-units convention. */
14
25
  export type LedgerRow = {
@@ -32,7 +43,7 @@ export type Movement = {
32
43
  /**
33
44
  * Expected totals per tender method: the counted float, plus every captured ledger row for the
34
45
  * session (grouped under `cash` for cash rows, else `method_id`), plus its non-voided
35
- * paid-in/paid-out movements.
46
+ * paid-in/paid-out movements, minus its applied refunds.
36
47
  *
37
48
  * A movement is excluded when its own `voided_by` is set, or when a `type: 'void'` row's
38
49
  * `voids` names its id; the `void` row itself carries no amount of its own.
@@ -41,10 +52,12 @@ export function deriveExpected({
41
52
  session,
42
53
  movements,
43
54
  ledgerRowsBySession,
55
+ refunds,
44
56
  }: {
45
57
  session: { id: string; countedFloatMinor: number };
46
58
  movements: readonly Movement[];
47
59
  ledgerRowsBySession: readonly LedgerRow[];
60
+ refunds?: readonly RefundRow[];
48
61
  }): Record<string, number> {
49
62
  const totals: Record<string, number> = { cash: session.countedFloatMinor };
50
63
  for (const row of ledgerRowsBySession) {
@@ -62,5 +75,9 @@ export function deriveExpected({
62
75
  if (row.type === 'paid_in') totals.cash += row.amountMinor;
63
76
  if (row.type === 'paid_out') totals.cash -= row.amountMinor;
64
77
  }
78
+ for (const refund of refunds ?? []) {
79
+ if (refund.status !== 'applied' || refund.sessionId !== session.id) continue;
80
+ for (const [method, amount] of refundAmounts(refund)) totals[method] = (totals[method] ?? 0) - amount;
81
+ }
65
82
  return totals;
66
83
  }
@@ -13,7 +13,7 @@ export {
13
13
  } from './register-document';
14
14
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
15
15
  export {
16
- RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
16
+ RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
17
17
  requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
18
18
  } from './session-store';
19
19
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
@@ -30,4 +30,5 @@ export { registerFactsLogger, recordRegisterFact, type Actor, type RegisterFact
30
30
  export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError } from './use-register-session';
31
31
  export type { UseRegisterSessionOptions } from './use-register-session';
32
32
  export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register-commands';
33
+ export { adoptRegisterResults, takeOverSession, abandonSession } from './register-adoption';
33
34
  export type { RegisterCommand, RegisterCommandCollection } from './register-commands';
@@ -0,0 +1,105 @@
1
+ import { readFresh } from '../rxdb';
2
+ import { uuidv7 } from '../pos-order/uuidv7';
3
+ import { registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
4
+ import type { RegisterSession } from './schemas';
5
+ import { isKnownSessionStatus, RegisterSessionRequiredError, RegisterTakeOverError, type RegisterSessionCollection } from './session-store';
6
+
7
+ const chains = new WeakMap<RegisterCommandCollection, Promise<void>>();
8
+ type SessionTarget = { commands: RegisterCommandCollection; sessions: RegisterSessionCollection; sessionId: string; now?: string };
9
+
10
+ /**
11
+ * Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
12
+ * Superseded rejections make open/counting sessions terminal; a conflict session leaves only by an applied open or abandon (ADR-078 decision 9); terminal/unknown states stay untouched.
13
+ * Applied conflict opens restore the latest transition's counting state, otherwise open; pending rows do nothing.
14
+ * Answers are walked in seq/key order; adoption is idempotent and safe to run at any time.
15
+ */
16
+ export async function adoptRegisterResults({ commands, sessions, registerId }: {
17
+ commands: RegisterCommandCollection; sessions: RegisterSessionCollection; registerId: string;
18
+ }): Promise<void> {
19
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
20
+ const [answers, transitions, rows] = await Promise.all([
21
+ readFresh(commands, { selector: { registerId, syncStatus: { $in: ['applied', 'rejected'] } }, sort: [{ seq: 'asc' }, { key: 'asc' }] }),
22
+ readFresh(commands, { selector: { registerId, type: 'register.session.transition' }, sort: [{ seq: 'desc' }, { key: 'desc' }] }),
23
+ readFresh(sessions, { selector: { register_id: registerId } }),
24
+ ]);
25
+ for (const session of rows) {
26
+ if (session.status === 'conflict' && answers.some((answer) => answer.payload.sessionId === session.id
27
+ && answer.syncStatus === 'rejected' && answer.error?.code === 'register_session_abandoned')) {
28
+ await finishAbandon({ commands, sessions, sessionId: session.id });
29
+ continue;
30
+ }
31
+ const latest = transitions.find(({ payload }) => payload.sessionId === session.id);
32
+ const modify = (doc: RegisterSession): RegisterSession => {
33
+ let next = doc;
34
+ for (const answer of answers) {
35
+ if (!isKnownSessionStatus(next.status) || next.status === 'closed' || next.status === 'superseded' || next.status === 'abandoned') break;
36
+ if (answer.payload.sessionId !== doc.id) continue;
37
+ if (answer.syncStatus === 'rejected') {
38
+ if (answer.error?.code === 'register_session_superseded'
39
+ && (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'superseded' };
40
+ else if (answer.type === 'register.session.open' && answer.error?.code === 'register_session_already_open'
41
+ && (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'conflict' };
42
+ } else if (answer.type === 'register.session.open') {
43
+ const resumed = answer.result?.resumed?.fromSessionId;
44
+ if (typeof resumed === 'string' && next.server_session_id == null) next = { ...next, server_session_id: resumed };
45
+ if (next.status === 'conflict') next = { ...next, status: latest?.payload.status === 'counting' ? 'counting' : 'open' };
46
+ }
47
+ }
48
+ return next;
49
+ };
50
+ if (modify(session) === session) continue;
51
+ const row = await sessions.findOne(session.id).exec();
52
+ if (row) await row.incrementalModify(modify);
53
+ }
54
+ }).catch((error: unknown) => {
55
+ registerCommandsLogger.error('Register result adoption failed', { context: { registerId, error: String(error) } });
56
+ });
57
+ chains.set(commands, run.then(() => undefined, () => undefined));
58
+ return run;
59
+ }
60
+
61
+ export async function takeOverSession({ commands, sessions, sessionId, now, registerContract }: SessionTarget & {
62
+ /** The store's register contract (`capabilities.register`); take over needs 2, and a missing value counts as below 2. */
63
+ registerContract?: number;
64
+ }): Promise<void> {
65
+ if ((registerContract ?? 0) < 2) throw new RegisterTakeOverError('REGISTER_TAKEOVER_UNSUPPORTED');
66
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
67
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
68
+ const row = await commands.findOne(`session.open:${sessionId}`).exec();
69
+ if (session?.status !== 'conflict' || !row) throw new RegisterSessionRequiredError();
70
+ await row.incrementalModify((doc) => {
71
+ if (doc.syncStatus !== 'rejected' || doc.error?.code !== 'register_session_already_open'
72
+ || typeof doc.error.data?.sessionId !== 'string') throw new RegisterSessionRequiredError();
73
+ const { error, ...rest } = doc;
74
+ return { ...rest, commandId: uuidv7(), version: 2, syncStatus: 'pending',
75
+ payload: { ...doc.payload, supersedes: error.data!.sessionId }, updatedAt: now ?? new Date().toISOString() };
76
+ });
77
+ });
78
+ chains.set(commands, run.then(() => undefined, () => undefined));
79
+ return run;
80
+ }
81
+
82
+ async function finishAbandon({ commands, sessions, sessionId, now }: SessionTarget) {
83
+ const rows = await readFresh(commands, { selector: { 'payload.sessionId': sessionId, syncStatus: 'pending' } });
84
+ for (const row of rows) {
85
+ await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== 'pending' ? doc : ({
86
+ ...doc, syncStatus: 'rejected', error: { code: 'register_session_abandoned', message: 'The cashier chose another register.' },
87
+ updatedAt: now ?? new Date().toISOString(),
88
+ }));
89
+ }
90
+ await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === 'conflict' ? { ...doc, status: 'abandoned' } : doc);
91
+ }
92
+
93
+ export async function abandonSession(input: SessionTarget): Promise<void> {
94
+ const { commands, sessions, sessionId } = input;
95
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
96
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
97
+ if (session?.status === 'abandoned') return;
98
+ if (session?.status !== 'conflict') throw new RegisterSessionRequiredError();
99
+ const [open] = await readFresh(commands, { selector: { key: `session.open:${sessionId}` } });
100
+ if (open?.syncStatus === 'pending') throw new RegisterTakeOverError('REGISTER_TAKEOVER_PENDING');
101
+ await finishAbandon(input);
102
+ });
103
+ chains.set(commands, run.then(() => undefined, () => undefined));
104
+ return run;
105
+ }
@@ -38,16 +38,28 @@ export const registerCommandSchema: RxJsonSchema<RegisterCommand> = {
38
38
  };
39
39
  export const registerCommandCollection = () => ({ schema: registerCommandSchema });
40
40
 
41
- type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 };
41
+ type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 | 2 };
42
42
 
43
- export function sessionOpenCommand(s: RegisterSession): BuiltCommand {
44
- return { key: `session.open:${s.id}`, type: 'register.session.open', version: 1, payload: {
43
+ /** ADR-078: 1 to 64 characters after trim. */
44
+ export function deviceNameOf(name: string | null | undefined): string | undefined {
45
+ let trimmed = name?.trim();
46
+ if (trimmed && trimmed.length > 64) {
47
+ const last = trimmed.charCodeAt(63);
48
+ trimmed = trimmed.slice(0, last >= 0xd800 && last <= 0xdbff ? 63 : 64).trimEnd();
49
+ }
50
+ return trimmed || undefined;
51
+ }
52
+
53
+ export function sessionOpenCommand(s: RegisterSession, v2?: { deviceName?: string | null }): BuiltCommand {
54
+ const deviceName = deviceNameOf(v2?.deviceName);
55
+ return { key: `session.open:${s.id}`, type: 'register.session.open', version: v2 ? 2 : 1, payload: {
45
56
  sessionId: s.id, registerId: s.register_id, openedAt: s.opened_at_gmt, countedFloatMinor: s.counted_float_minor,
46
57
  ...(s.store_key == null ? {} : { storeKey: s.store_key }),
47
58
  ...(s.business_day == null ? {} : { businessDay: s.business_day }),
48
59
  ...(s.opened_by == null ? {} : { openedBy: s.opened_by }),
49
60
  ...(s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor }),
50
61
  ...(s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }),
62
+ ...(deviceName === undefined ? {} : { deviceName }),
51
63
  } satisfies RegisterSessionOpenPayload };
52
64
  }
53
65
 
@@ -88,9 +100,10 @@ export function closureCommand(c: Closure): BuiltCommand {
88
100
  const chains = new WeakMap<RegisterCommandCollection, Map<string, Promise<void>>>();
89
101
 
90
102
  /** Appends missing facts in dependency order; the bytes of an existing key never change. */
91
- export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
103
+ export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }: {
92
104
  commands: RegisterCommandCollection; sessions: RegisterSessionCollection; movements: CashMovementCollection;
93
105
  closures: ClosureCollection; host: RegisterHost; storeKey: string; registerId: string; now?: string; observed?: RegisterSession[];
106
+ registerContract?: number; deviceName?: string | null;
94
107
  }): Promise<string[]> {
95
108
  // `conflict` and `superseded` are local states derived from the store's answers (ADR-078 decision 9), never sent as a transition.
96
109
  const sentStatus = (s: RegisterSession): s is RegisterSession & { status: RegisterSessionTransitionPayload['status'] } => s.status === 'open' || s.status === 'counting' || s.status === 'closed';
@@ -106,8 +119,8 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
106
119
  const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) =>
107
120
  [`session.open:${session.id}`, ...(session.status === 'closed' && session.closure_id ? [`closure.submit:${session.closure_id}`] : [])]), false)).map(({ key }) => key));
108
121
  const facts = await Promise.all(rows.filter((session) =>
109
- session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
110
- && !complete.has(`closure.submit:${session.closure_id}`)))
122
+ session.status !== 'abandoned' && (session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
123
+ && !complete.has(`closure.submit:${session.closure_id}`))))
111
124
  .map(async (session) => {
112
125
  const [entries, closureRows] = await Promise.all([
113
126
  readFresh(movements, { selector: { session_id: session.id } }),
@@ -119,7 +132,7 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
119
132
  events.push(...entries.filter((entry) => entry.type !== 'void').map(movementCommand),
120
133
  ...entries.filter((entry) => entry.type === 'void').map(movementCommand));
121
134
  if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
122
- return { session, number: closureRows[0]?.number, built: [sessionOpenCommand(session), ...events,
135
+ return { session, number: closureRows[0]?.number, built: [(registerContract ?? 0) >= 2 ? sessionOpenCommand(session, { deviceName }) : sessionOpenCommand(session), ...events,
123
136
  ...closureRows.map((closure) => closureCommand(closure))] };
124
137
  }));
125
138
  facts.sort((a, b) => Number(a.session.status !== 'closed') - Number(b.session.status !== 'closed')
@@ -24,8 +24,8 @@ export interface RegisterSession {
24
24
  register_id: string;
25
25
  /** The app's neutral store key (see `bindRegister`). */
26
26
  store_key?: string | null;
27
- /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
28
- status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
27
+ /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. `abandoned` is local only and terminal (the cashier chose another register while in conflict). */
28
+ status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded' | 'abandoned';
29
29
  /** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
30
30
  server_session_id?: string | null;
31
31
  /** The store's day the session opened, `yyyy-MM-dd`. */
@@ -82,7 +82,7 @@ export interface Closure {
82
82
  counted: TenderMap;
83
83
  variance: TenderMap;
84
84
  period_sales_total_minor: number;
85
- /** Always 0 until TallyUI has a refund model (ADR-032 amendment 1). */
85
+ /** The session's applied refunds (ADR-080 amendment 1); 0 when the app passes none. */
86
86
  period_refunds_total_minor: number;
87
87
  perpetual_sales_total_minor: number;
88
88
  perpetual_refunds_total_minor: number;
@@ -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';
@@ -49,6 +50,24 @@ export class RegisterNeedsUpgradeError extends Error {
49
50
  }
50
51
  }
51
52
 
53
+ /** Opening is refused while the register has a `conflict` session (ADR-078); its message can be shown to a cashier as it is. */
54
+ export class RegisterSessionConflictError extends Error {
55
+ readonly code = 'REGISTER_SESSION_CONFLICT';
56
+ constructor() {
57
+ super('This register is open on another till. Take it over or choose another register.');
58
+ this.name = 'RegisterSessionConflictError';
59
+ }
60
+ }
61
+
62
+ export class RegisterTakeOverError extends Error {
63
+ constructor(readonly code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING') {
64
+ super(code === 'REGISTER_TAKEOVER_UNSUPPORTED'
65
+ ? 'This store cannot take over a register. Choose another register.'
66
+ : 'Taking over this register is still waiting for the store.');
67
+ this.name = 'RegisterTakeOverError';
68
+ }
69
+ }
70
+
52
71
  /** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
53
72
  export class RegisterMovementAmountError extends Error {
54
73
  constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number) {
@@ -80,7 +99,7 @@ export class RegisterMovementStrandedError extends Error {
80
99
  }
81
100
  }
82
101
 
83
- /** Every move a session may make. Nothing leaves `closed`; everything else is refused.
102
+ /** Every move a session may make. `closed` and `abandoned` are terminal: nothing leaves them; everything else is refused.
84
103
  * No cashier move leaves `conflict` or `superseded`; the store's answers set them (ADR-078). */
85
104
  const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['status'][]> = {
86
105
  open: ['counting', 'closed'],
@@ -88,6 +107,7 @@ const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['s
88
107
  closed: [],
89
108
  conflict: [],
90
109
  superseded: [],
110
+ abandoned: [],
91
111
  };
92
112
 
93
113
  /** Whether this build knows the stored session status. */
@@ -116,6 +136,7 @@ async function requireLiveSession(sessions: RegisterSessionCollection, id: strin
116
136
  if (!session) throw new RegisterSessionRequiredError();
117
137
  if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
118
138
  if (session.status === 'closed') throw new RegisterSessionClosedError();
139
+ if (session.status === 'abandoned') throw new RegisterSessionClosedError();
119
140
  return session;
120
141
  }
121
142
 
@@ -420,6 +441,7 @@ export async function voidMovement(
420
441
  * Only a closed session (`closed` with `closed_at_gmt`) has a closure, and a closed session is
421
442
  * final, so its existing closure is returned as it was frozen. Orders the server rejected still
422
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.
423
445
  */
424
446
  export async function writeClosure({
425
447
  closures,
@@ -430,6 +452,7 @@ export async function writeClosure({
430
452
  otherTenders,
431
453
  movements,
432
454
  orders,
455
+ refunds,
433
456
  tillExpected,
434
457
  labels,
435
458
  resolveCashierName,
@@ -447,6 +470,8 @@ export async function writeClosure({
447
470
  movements: readonly CashMovement[];
448
471
  /** Any `pos_orders`; only those whose `sessionId` is this session's are counted. */
449
472
  orders: readonly PosOrder[];
473
+ /** `pos_refunds` rows; only this session's applied ones are counted (ADR-080 amendment 1). */
474
+ refunds?: readonly PosRefund[];
450
475
  tillExpected?: Record<string, number>;
451
476
  labels?: { register_name: string; closed_by_name: string; opened_by_name?: string; approved_by_name?: string };
452
477
  resolveCashierName?: (id: string) => string;
@@ -466,6 +491,7 @@ export async function writeClosure({
466
491
  return existing;
467
492
  }
468
493
  const bound = orders.filter((order) => order.sessionId === session.id);
494
+ const countedRefunds = (refunds ?? []).filter((refund) => refund.status === 'applied' && refund.sessionId === session.id);
469
495
  // Each payment is captured, and a cash payment's amount is already net of change.
470
496
  const rows: LedgerRow[] = bound.flatMap((order) =>
471
497
  order.payments.map((payment) => ({
@@ -483,6 +509,7 @@ export async function writeClosure({
483
509
  session: { id: session.id, countedFloatMinor: session.counted_float_minor },
484
510
  movements: entries,
485
511
  ledgerRowsBySession: rows,
512
+ refunds,
486
513
  });
487
514
  const counts = { cash: counted, ...otherTenders };
488
515
  const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
@@ -491,6 +518,11 @@ export async function writeClosure({
491
518
  const method = row.kind === 'cash' ? 'cash' : row.method_id;
492
519
  payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row.amountMinor, refunds_minor: 0 };
493
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
+ }
494
526
  // Same per-rate split a receipt shows (`taxLinesByRate`), run per order so each order's rates
495
527
  // add up to its own taxMinor, then summed across the session's orders by rate. A line's own
496
528
  // taxInclusive overrides the order's, matching PosOrderLine's own fallback convention. Each order is split by the
@@ -532,7 +564,7 @@ export async function writeClosure({
532
564
  Object.entries(counts).map(([method, value]) => [method, countVariance(value, till_expected[method] ?? 0)]),
533
565
  ),
534
566
  period_sales_total_minor: sum(rows.map((row) => row.amountMinor)),
535
- period_refunds_total_minor: 0,
567
+ period_refunds_total_minor: sum(countedRefunds.flatMap((refund) => refundAmounts(refund).map(([, amount]) => amount))),
536
568
  perpetual_sales_total_minor: 0,
537
569
  perpetual_refunds_total_minor: 0,
538
570
  unsynced_count: unsynced.length,
@@ -561,7 +593,11 @@ export async function writeClosure({
561
593
  id, type, amountMinor, reason, voids: voids ?? null, created_at_gmt, created_by: created_by ?? null, voided_by: voided_by ?? null,
562
594
  })),
563
595
  transaction_count: bound.length,
564
- 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
+ } : {}),
565
601
  cashiers: [...new Set(bound.map((order) => order.cashierRef ?? '').filter(Boolean))].map((id) => ({
566
602
  id,
567
603
  name: resolveCashierName?.(id) || id,