@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
|
@@ -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
|
+
}
|
package/src/register/expected.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `refunded_amount`
|
|
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
|
}
|
package/src/register/index.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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')
|
package/src/register/schemas.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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';
|
|
@@ -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.
|
|
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:
|
|
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:
|
|
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,
|