@tallyui/pos 2.0.0 → 3.0.0-next.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 +321 -61
- package/dist/index.js +1391 -185
- package/package.json +8 -8
- package/src/index.ts +15 -7
- package/src/order/index.ts +3 -0
- package/src/order/order-builder.ts +23 -7
- package/src/order/order-manager.ts +3 -1
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +9 -3
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +5 -4
- package/src/outbox/index.ts +2 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +331 -27
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +17 -3
- package/src/outbox/use-order-outbox.ts +20 -6
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +99 -11
- package/src/pos-order/finalize.ts +145 -7
- package/src/pos-order/index.ts +2 -2
- package/src/pos-order/needs-attention.ts +9 -4
- package/src/pos-order/open.ts +100 -37
- package/src/pos-order/schema.ts +35 -5
- package/src/pos-order/types.ts +31 -5
- package/src/product/search-products.ts +5 -2
- package/src/receipt/build-receipt-data.ts +5 -3
- package/src/receipt/types.ts +1 -0
- package/src/register/closure-document.ts +7 -0
- package/src/register/index.ts +5 -3
- package/src/register/movement-input.ts +1 -1
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-document.ts +26 -1
- package/src/register/session-store.ts +41 -12
- package/src/register/use-register-session.ts +46 -2
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +97 -40
- package/src/store-settings/map-store-settings.ts +7 -2
- package/src/store-settings/use-store-settings.ts +22 -7
- package/src/tax/exact.ts +74 -11
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +26 -4
- package/src/tax/types.ts +6 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { CommandResult, RegisterCommandEnvelope } from '@tallyui/core';
|
|
2
|
+
import type { RxCollection } from 'rxdb';
|
|
3
|
+
import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
|
|
4
|
+
import { registerCommandsLogger, type RegisterCommand } from '../register/register-commands';
|
|
5
|
+
import { countFresh, readFresh } from '../rxdb';
|
|
6
|
+
import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
|
|
7
|
+
import { STUCK_AFTER_MS } from './order-outbox';
|
|
8
|
+
import type { CommandTransport, OutboxState } from './types';
|
|
9
|
+
|
|
10
|
+
export interface RegisterOutboxOptions {
|
|
11
|
+
collection: RxCollection<RegisterCommand>;
|
|
12
|
+
transport: CommandTransport<RegisterCommandEnvelope>;
|
|
13
|
+
deviceId: string;
|
|
14
|
+
/** Checked at the start of every run; false sends nothing. */
|
|
15
|
+
isEnabled?: () => boolean;
|
|
16
|
+
/** Runs before marking, so a crash resends; a throw is logged and marking continues. */
|
|
17
|
+
onResult?: (command: RegisterCommand, result: CommandResult) => Promise<void> | void;
|
|
18
|
+
batchSize?: number;
|
|
19
|
+
initialBackoffMs?: number;
|
|
20
|
+
maxBackoffMs?: number;
|
|
21
|
+
random?: () => number;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
/** Tests only: overrides STUCK_AFTER_MS. */
|
|
24
|
+
stuckAfterMs?: number;
|
|
25
|
+
/** Counts 404s toward OutboxState.backendMissing; pass the order outbox's too, so either one's 404s show one notice. */
|
|
26
|
+
backendNotFound?: BackendNotFound;
|
|
27
|
+
}
|
|
28
|
+
export interface RegisterOutbox {
|
|
29
|
+
flush(): Promise<void>;
|
|
30
|
+
start(): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
state$: Observable<OutboxState>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function plainCommand({ key, registerId, seq, commandId, type, version, payload, createdAt, syncStatus,
|
|
36
|
+
error, result, updatedAt }: RegisterCommand): RegisterCommand {
|
|
37
|
+
return structuredClone({ key, registerId, seq, commandId, type, version, payload, createdAt, syncStatus,
|
|
38
|
+
...(error ? { error } : {}), ...(result ? { result } : {}), updatedAt });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox {
|
|
42
|
+
const { collection, transport, deviceId } = options;
|
|
43
|
+
const size = options.batchSize;
|
|
44
|
+
const batchSize = Math.max(1, Math.min(typeof size === 'number' && Number.isFinite(size) ? Math.floor(size) : 10, 10));
|
|
45
|
+
const initialBackoff = options.initialBackoffMs ?? 1000;
|
|
46
|
+
const maxBackoff = options.maxBackoffMs ?? 60000;
|
|
47
|
+
const random = options.random ?? Math.random;
|
|
48
|
+
const now = options.now ?? Date.now;
|
|
49
|
+
const state$ = new BehaviorSubject<OutboxState>({ pending: 0, sending: false });
|
|
50
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
51
|
+
let missingSubscription: Subscription | undefined; // stop() lets go of the (maybe shared) tracker; start() and flush() take it up again
|
|
52
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
53
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
54
|
+
});
|
|
55
|
+
watchBackendMissing();
|
|
56
|
+
const attempts = new Map<string, number>();
|
|
57
|
+
let backoff = initialBackoff;
|
|
58
|
+
let unauthorizedSinceAccepted = 0;
|
|
59
|
+
let running: Promise<void> | undefined;
|
|
60
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
let subscription: Subscription | undefined;
|
|
62
|
+
let stopped = false;
|
|
63
|
+
let insertedDuringRun = false;
|
|
64
|
+
let stoppedDuringRun = false;
|
|
65
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
66
|
+
// Each command's stuck clock counts only answered time, by the order outbox's rules. It starts when a batch that
|
|
67
|
+
// sent the command fails with a failure the store answered (a `retry` other than `network`, where `timeout` counts
|
|
68
|
+
// like a 503, or `no_progress`); a running clock keeps its `since` and takes the latest reason. A `network` failure
|
|
69
|
+
// pauses every clock (`pausedAt`), and the next answer of any kind resumes them, moving `since` on by the offline
|
|
70
|
+
// gap, so `since` is now minus the answered time. A clock clears when its command is no longer pending under its
|
|
71
|
+
// commandId (applied or rejected), or when a rejected command comes ahead of it in its ledger (it is not being
|
|
72
|
+
// sent: Front desk, 2026-09-30); commands never sent have none. Nothing is isolated: a stuck command at the head
|
|
73
|
+
// of a register's ledger holds up the commands behind it, by design, since register facts apply in `seq` order.
|
|
74
|
+
// In memory only: a restart starts with no clocks, and the first answered failure after it starts them afresh
|
|
75
|
+
// (accepted: register commands are few, and a restart re-sends at once).
|
|
76
|
+
const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
|
|
77
|
+
let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
|
|
78
|
+
|
|
79
|
+
function stuckState(): OutboxState['stuck'] {
|
|
80
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
81
|
+
if (!stuck.length) return undefined;
|
|
82
|
+
return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
83
|
+
reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
|
|
84
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
|
|
85
|
+
}
|
|
86
|
+
function serverFailed(commands: RegisterCommand[], reason: string) {
|
|
87
|
+
const at = now();
|
|
88
|
+
for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function updateState(patch: Partial<OutboxState> = {}) {
|
|
92
|
+
const pending = await countFresh(collection, { syncStatus: 'pending' });
|
|
93
|
+
// Only commands in a register's sendable prefix (pending, before its first rejected command) keep a clock.
|
|
94
|
+
const sendable = new Set<string>();
|
|
95
|
+
const blocked = new Set<string>();
|
|
96
|
+
if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
|
|
97
|
+
sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
|
|
98
|
+
if (command.syncStatus === 'rejected') blocked.add(command.registerId);
|
|
99
|
+
else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
|
|
100
|
+
}
|
|
101
|
+
for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
|
|
102
|
+
state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
|
|
103
|
+
}
|
|
104
|
+
function scheduleRetry(reason: string, retryAfterMs = 0) {
|
|
105
|
+
state$.next({ ...state$.value, lastRetryReason: reason, stuck: stuckState() });
|
|
106
|
+
if (stopped) { stoppedDuringRun = true; return; }
|
|
107
|
+
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
108
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
109
|
+
timer = setTimeout(() => { timer = undefined; flush().catch(() => {}); }, delay);
|
|
110
|
+
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function run() {
|
|
114
|
+
stoppedDuringRun = false;
|
|
115
|
+
while (!stopped) try {
|
|
116
|
+
await updateState({ sending: true, nextAttemptAt: undefined });
|
|
117
|
+
insertedDuringRun = false;
|
|
118
|
+
if (options.isEnabled?.() === false) return;
|
|
119
|
+
const pending = await readFresh(collection, { selector: { syncStatus: 'pending' } });
|
|
120
|
+
const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
|
|
121
|
+
let sent = false;
|
|
122
|
+
for (const registerId of registerIds) {
|
|
123
|
+
const ledger = await readFresh(collection, {
|
|
124
|
+
selector: { registerId, syncStatus: { $in: ['pending', 'rejected'] } },
|
|
125
|
+
sort: [{ seq: 'asc' }, { key: 'asc' }],
|
|
126
|
+
});
|
|
127
|
+
const commands: RegisterCommand[] = [];
|
|
128
|
+
for (const command of ledger) {
|
|
129
|
+
if (command.syncStatus === 'rejected') break;
|
|
130
|
+
commands.push(command);
|
|
131
|
+
if (commands.length >= batchSize) break;
|
|
132
|
+
}
|
|
133
|
+
if (stopped) { stoppedDuringRun = true; return; }
|
|
134
|
+
if (!commands.length) continue;
|
|
135
|
+
sent = true;
|
|
136
|
+
const batch: RegisterCommandEnvelope[] = commands.map((doc) => {
|
|
137
|
+
const attempt = (attempts.get(doc.commandId) ?? 0) + 1;
|
|
138
|
+
attempts.set(doc.commandId, attempt);
|
|
139
|
+
return { id: doc.commandId, type: doc.type, version: doc.version, payload: doc.payload,
|
|
140
|
+
createdAt: doc.createdAt, deviceId, attempt };
|
|
141
|
+
});
|
|
142
|
+
const outcome = await transport.send(batch);
|
|
143
|
+
backendNotFound.record(outcome, now());
|
|
144
|
+
const at = now();
|
|
145
|
+
if (outcome.kind === 'retry' && outcome.reason === 'network') for (const clock of clocks.values()) clock.pausedAt ??= at;
|
|
146
|
+
else for (const clock of clocks.values()) if (clock.pausedAt !== undefined) {
|
|
147
|
+
clock.since += at - clock.pausedAt;
|
|
148
|
+
clock.pausedAt = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (outcome.kind === 'retry') {
|
|
151
|
+
if (outcome.reason !== 'network') serverFailed(commands, outcome.reason);
|
|
152
|
+
return scheduleRetry(outcome.reason, outcome.retryAfterMs);
|
|
153
|
+
}
|
|
154
|
+
if (outcome.kind === 'unauthorized') {
|
|
155
|
+
unauthorizedSinceAccepted++;
|
|
156
|
+
if (unauthorizedSinceAccepted < 3) return scheduleRetry('unauthorized');
|
|
157
|
+
state$.next({ ...state$.value, authRequired: true, lastRetryReason: 'unauthorized',
|
|
158
|
+
sending: false, nextAttemptAt: undefined });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (outcome.kind === 'refused') {
|
|
162
|
+
unauthorizedSinceAccepted = 0;
|
|
163
|
+
state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
|
|
164
|
+
authRequired: false, lastRetryReason: 'refused', sending: false, nextAttemptAt: undefined });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
unauthorizedSinceAccepted = 0;
|
|
168
|
+
state$.next({ ...state$.value, authRequired: false, refused: undefined });
|
|
169
|
+
let progressed = false;
|
|
170
|
+
for (const command of commands) {
|
|
171
|
+
const result = outcome.results.find((entry) => entry.id === command.commandId);
|
|
172
|
+
if (!result) continue;
|
|
173
|
+
// Skip the query cache and ignore a command changed while its send was in flight.
|
|
174
|
+
const [stored] = await collection.storageInstance.findDocumentsById([command.key], false);
|
|
175
|
+
if (stored?.syncStatus !== 'pending' || stored.commandId !== command.commandId) continue;
|
|
176
|
+
const current = await collection.findOne(command.key).exec();
|
|
177
|
+
if (!current) continue;
|
|
178
|
+
const error = result.error && { code: result.error.code, message: result.error.message,
|
|
179
|
+
...(result.error.data ? { data: result.error.data } : {}) };
|
|
180
|
+
const normalized: CommandResult = result.status === 'duplicate' && error
|
|
181
|
+
? { id: result.id, status: 'rejected', error }
|
|
182
|
+
: { ...result, ...(error ? { error } : {}) };
|
|
183
|
+
try { await options.onResult?.(plainCommand(stored), normalized); }
|
|
184
|
+
catch (cause) { registerCommandsLogger.warn('Failed to apply register command result', { commandId: command.commandId, cause }); }
|
|
185
|
+
const updatedAt = new Date(now()).toISOString();
|
|
186
|
+
await current.incrementalModify((row) => {
|
|
187
|
+
if (row.syncStatus !== 'pending' || row.commandId !== command.commandId) return row;
|
|
188
|
+
return Object.assign(row, normalized.status === 'rejected'
|
|
189
|
+
? { syncStatus: 'rejected', error: normalized.error, updatedAt }
|
|
190
|
+
: { syncStatus: 'applied', ...(normalized.register ? { result: normalized.register } : {}), updatedAt });
|
|
191
|
+
});
|
|
192
|
+
attempts.delete(command.commandId);
|
|
193
|
+
progressed = true;
|
|
194
|
+
await updateState();
|
|
195
|
+
}
|
|
196
|
+
if (!progressed) {
|
|
197
|
+
serverFailed(commands, 'no_progress');
|
|
198
|
+
return scheduleRetry('no_progress');
|
|
199
|
+
}
|
|
200
|
+
backoff = initialBackoff;
|
|
201
|
+
state$.next({ ...state$.value, lastRetryReason: undefined });
|
|
202
|
+
}
|
|
203
|
+
if (!sent) return;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
stoppedDuringRun = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function flush(): Promise<void> {
|
|
212
|
+
if (running) return running;
|
|
213
|
+
stopped = false;
|
|
214
|
+
watchBackendMissing();
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
timer = undefined;
|
|
217
|
+
running = Promise.resolve().then(run).finally(async () => {
|
|
218
|
+
try { await updateState({ sending: false }); } catch {}
|
|
219
|
+
running = undefined;
|
|
220
|
+
if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === undefined) flush().catch(() => {});
|
|
221
|
+
});
|
|
222
|
+
return running;
|
|
223
|
+
}
|
|
224
|
+
updateState().catch(() => {});
|
|
225
|
+
return {
|
|
226
|
+
state$,
|
|
227
|
+
flush,
|
|
228
|
+
start() {
|
|
229
|
+
stopped = false;
|
|
230
|
+
watchBackendMissing();
|
|
231
|
+
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
232
|
+
if (event.documentData?.syncStatus === 'pending' &&
|
|
233
|
+
(event.operation === 'INSERT' || event.operation === 'UPDATE')) {
|
|
234
|
+
insertedDuringRun = true;
|
|
235
|
+
flush().catch(() => {});
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
if (!stopped) flush().catch(() => {});
|
|
239
|
+
},
|
|
240
|
+
stop() {
|
|
241
|
+
stopped = true;
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
timer = undefined;
|
|
244
|
+
subscription?.unsubscribe();
|
|
245
|
+
subscription = undefined;
|
|
246
|
+
missingSubscription?.unsubscribe(); missingSubscription = undefined;
|
|
247
|
+
state$.next({ ...state$.value, nextAttemptAt: undefined });
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import type { AnyCommandEnvelope, CommandEnvelope, OrderCreateEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
|
|
3
|
+
import type { CommandTransport } from './types';
|
|
4
|
+
|
|
5
|
+
describe('CommandTransport', () => {
|
|
6
|
+
it('defaults to order.create envelopes', () => {
|
|
7
|
+
expectTypeOf<Parameters<CommandTransport['send']>[0]>().toEqualTypeOf<CommandEnvelope<OrderCreatePayload>[]>();
|
|
8
|
+
const transport: CommandTransport = { send: async () => ({ kind: 'results', results: [] }) };
|
|
9
|
+
const batch: CommandEnvelope<OrderCreatePayload>[] = [];
|
|
10
|
+
transport.send(batch);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('the wide transport is accepted where the order outbox wants the order transport', () => {
|
|
14
|
+
expectTypeOf<CommandTransport<AnyCommandEnvelope>>().toMatchTypeOf<CommandTransport<OrderCreateEnvelope>>();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('the wide transport accepts register command envelopes', () => {
|
|
18
|
+
const transport: CommandTransport<AnyCommandEnvelope> = { send: async () => ({ kind: 'results', results: [] }) };
|
|
19
|
+
const batch: RegisterCommandEnvelope[] = [];
|
|
20
|
+
transport.send(batch);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/outbox/types.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import type { CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
|
|
1
|
+
import type { AnyCommandEnvelope, CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
|
|
2
2
|
|
|
3
3
|
export type TransportOutcome =
|
|
4
4
|
| { kind: 'results'; results: CommandResult[] }
|
|
5
5
|
| { kind: 'unauthorized' }
|
|
6
6
|
| { kind: 'refused'; status: number; reason: string }
|
|
7
|
+
/** `reason` is `network` when the store could not be reached, `timeout` when a sent request got no answer in time,
|
|
8
|
+
* else what the store answered (`status_503`, `bad_body`, ...). The order outbox counts every reason but `network`. */
|
|
7
9
|
| { kind: 'retry'; reason: string; retryAfterMs?: number };
|
|
8
10
|
|
|
9
|
-
export interface CommandTransport {
|
|
10
|
-
send(batch:
|
|
11
|
+
export interface CommandTransport<E extends AnyCommandEnvelope = CommandEnvelope<OrderCreatePayload>> {
|
|
12
|
+
send(batch: E[]): Promise<TransportOutcome>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface OutboxState {
|
|
14
16
|
pending: number;
|
|
17
|
+
/** The order outbox's count of `pos_orders` the store refused (`syncStatus: 'rejected'`), read with `pending`. Each
|
|
18
|
+
* stays until requeue() sends it again. The register outbox leaves it unset. */
|
|
19
|
+
rejected?: number;
|
|
15
20
|
sending: boolean;
|
|
16
21
|
lastRetryReason?: string;
|
|
17
22
|
nextAttemptAt?: number;
|
|
@@ -19,4 +24,13 @@ export interface OutboxState {
|
|
|
19
24
|
authRequired?: boolean;
|
|
20
25
|
/** Set when the server refused a whole batch (HTTP 400, 403, 413, 415, 422). No order is changed; sending pauses until the next flush(). */
|
|
21
26
|
refused?: { status: number; reason: string };
|
|
27
|
+
/** Set while the store (not offline) has kept failing some orders for 15 minutes, each on its own clock. Those
|
|
28
|
+
* orders stay pending and keep retrying. `orders` has each order's own entry: its `since` is when its clock
|
|
29
|
+
* would have started had there been no offline gaps (now minus its answered time; while offline, as of the
|
|
30
|
+
* moment the clock paused), and its latest `reason`. `since` is the earliest of theirs; `reason` the latest. */
|
|
31
|
+
stuck?: { commandIds: string[]; since: number; reason: string; orders: { commandId: string; since: number; reason: string }[] };
|
|
32
|
+
/** Set after 3 consecutive 404 answers (counted across the outboxes sharing one BackendNotFound): the store address
|
|
33
|
+
* may be wrong, or the store's plugin isn't installed or is switched off. The outbox keeps retrying; the next answer
|
|
34
|
+
* that isn't a 404 clears it (offline changes nothing). `since` is when the first of those 404s arrived. */
|
|
35
|
+
backendMissing?: { since: number };
|
|
22
36
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { OrderCreateEnvelope } from '@tallyui/core';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
3
|
import type { RxCollection, RxError } from 'rxdb';
|
|
3
|
-
import {
|
|
4
|
+
import { outboxLogger } from './logger';
|
|
4
5
|
import { OrderContentMismatchError, sameSale, type PosOrder } from '../pos-order';
|
|
5
6
|
import { watchFresh } from '../rxdb';
|
|
6
7
|
import { createOrderOutbox } from './order-outbox';
|
|
7
8
|
import type { CommandTransport, OutboxState } from './types';
|
|
8
9
|
|
|
9
10
|
const idle: OutboxState = { pending: 0, sending: false };
|
|
10
|
-
|
|
11
|
-
export
|
|
11
|
+
const noneStuck: readonly string[] = [];
|
|
12
|
+
export { outboxLogger } from './logger';
|
|
12
13
|
|
|
13
14
|
export interface UseOrderOutboxOptions {
|
|
14
15
|
/** Which order store to use (medusapos: the backend's base URL); `null` means no store. A change reopens. */
|
|
@@ -16,9 +17,13 @@ export interface UseOrderOutboxOptions {
|
|
|
16
17
|
/** Opens the order store for `storeKey`; `close()` is called when the key or device id changes, or on unmount. */
|
|
17
18
|
open(storeKey: string): Promise<{ orders: RxCollection<PosOrder>; close(): Promise<void> }>;
|
|
18
19
|
/** Builds the command transport for `storeKey` (the app's HTTP transport and auth headers). Read once per open. */
|
|
19
|
-
transport(storeKey: string): CommandTransport
|
|
20
|
+
transport(storeKey: string): CommandTransport<OrderCreateEnvelope>;
|
|
20
21
|
/** The device id sent on every command (see `getDeviceId`). A change reopens. */
|
|
21
22
|
deviceId: string;
|
|
23
|
+
/** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
|
|
24
|
+
getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
|
|
25
|
+
/** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
|
|
26
|
+
refreshCapabilities?: () => Promise<void>;
|
|
22
27
|
/** Called with `state.sending`, and with `false` on cleanup (medusapos: live-tab's `markBusy('outbox', …)`). */
|
|
23
28
|
onBusy?(busy: boolean): void;
|
|
24
29
|
/** Called when `open` rejects, even if the key has changed since (medusapos: reports storage worker failures). */
|
|
@@ -55,6 +60,9 @@ export interface UseOrderOutboxResult {
|
|
|
55
60
|
* storage can wait forever, and TallyUI deliberately doesn't bound that close (#155).
|
|
56
61
|
*/
|
|
57
62
|
savesInFlight: number;
|
|
63
|
+
/** The command ids in `state.stuck`: pending orders the store keeps failing. Pass them to `needsAttention` and
|
|
64
|
+
* `OrdersList`. Empty when none, and before the current store is ready. */
|
|
65
|
+
stuckCommandIds: readonly string[];
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
/** Opens the order store for `storeKey`, runs its outbox and watches the recent orders (lifted from medusapos/app, ADR-052). */
|
|
@@ -83,7 +91,9 @@ export function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxRe
|
|
|
83
91
|
if (!storeKey) return;
|
|
84
92
|
void latest.current.open(storeKey).then(async (store) => {
|
|
85
93
|
if (!active) { await store.close(); return; }
|
|
86
|
-
const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey)
|
|
94
|
+
const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey),
|
|
95
|
+
getMaxOrderCreateVersion: latest.current.getMaxOrderCreateVersion ? () => latest.current.getMaxOrderCreateVersion?.() : undefined,
|
|
96
|
+
refreshCapabilities: latest.current.refreshCapabilities ? async () => { await latest.current.refreshCapabilities?.(); } : undefined });
|
|
87
97
|
current.current = { storeKey, orders: store.orders, outbox };
|
|
88
98
|
const status = outbox.state$.subscribe(setState);
|
|
89
99
|
// watchFresh: find().$ can leave this stale forever, hiding a new order (RxDB 16.21.1 bug 4).
|
|
@@ -107,6 +117,9 @@ export function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxRe
|
|
|
107
117
|
}, [state.sending]);
|
|
108
118
|
|
|
109
119
|
const ready = current.current?.storeKey === storeKey && !!storeKey;
|
|
120
|
+
// Each state update carries a new `stuck`; the ids keep their identity while they are unchanged.
|
|
121
|
+
const stuckKey = ready ? state.stuck?.commandIds.join(',') ?? '' : '';
|
|
122
|
+
const stuckCommandIds = useMemo(() => (stuckKey ? stuckKey.split(',') : noneStuck), [stuckKey]);
|
|
110
123
|
return { orders: ready ? orders : null, state: ready ? state : idle, recent: ready ? recent : [],
|
|
111
124
|
async flush() {
|
|
112
125
|
const opened = current.current;
|
|
@@ -117,6 +130,7 @@ export function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxRe
|
|
|
117
130
|
return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
|
|
118
131
|
},
|
|
119
132
|
savesInFlight: savesInFlightCount,
|
|
133
|
+
stuckCommandIds,
|
|
120
134
|
async record(posOrder) {
|
|
121
135
|
savesInFlight.current++;
|
|
122
136
|
setSavesInFlightCount(savesInFlight.current);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "019f6d2e-7800-7000-8000-000000000001",
|
|
3
|
+
"type": "order.create",
|
|
4
|
+
"version": 3,
|
|
5
|
+
"createdAt": "2026-09-28T10:00:00.000Z",
|
|
6
|
+
"deviceId": "device_golden",
|
|
7
|
+
"attempt": 1,
|
|
8
|
+
"payload": {
|
|
9
|
+
"clientOrderId": "019f6d2e-7800-7000-8000-000000000002",
|
|
10
|
+
"createdAt": "2026-09-28T10:00:00.000Z",
|
|
11
|
+
"currency": "EUR",
|
|
12
|
+
"pricesIncludeTax": true,
|
|
13
|
+
"lines": [
|
|
14
|
+
{
|
|
15
|
+
"clientLineId": "line_inclusive",
|
|
16
|
+
"variantId": "variant_inclusive",
|
|
17
|
+
"title": "Inclusive item",
|
|
18
|
+
"quantity": 2,
|
|
19
|
+
"unitPriceMinor": 1200,
|
|
20
|
+
"discountMinor": 203
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"clientLineId": "line_exclusive",
|
|
24
|
+
"variantId": "variant_exclusive",
|
|
25
|
+
"title": "Exclusive item",
|
|
26
|
+
"quantity": 1,
|
|
27
|
+
"unitPriceMinor": 1000,
|
|
28
|
+
"taxInclusive": false,
|
|
29
|
+
"discountMinor": 37
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"payments": [
|
|
33
|
+
{
|
|
34
|
+
"clientPaymentId": "payment_cash",
|
|
35
|
+
"method": "cash",
|
|
36
|
+
"amountMinor": 3256,
|
|
37
|
+
"tenderedMinor": 4000,
|
|
38
|
+
"changeMinor": 744,
|
|
39
|
+
"reference": "cash_receipt_1"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"subtotalMinor": 2794,
|
|
43
|
+
"discountMinor": 240,
|
|
44
|
+
"taxMinor": 462,
|
|
45
|
+
"totalMinor": 3256,
|
|
46
|
+
"display": {
|
|
47
|
+
"currency": "EUR",
|
|
48
|
+
"exponent": 2,
|
|
49
|
+
"taxInclusive": true,
|
|
50
|
+
"subtotalMinor": 3500,
|
|
51
|
+
"discountMinor": 244,
|
|
52
|
+
"taxMinor": 462,
|
|
53
|
+
"totalMinor": 3256,
|
|
54
|
+
"lines": [
|
|
55
|
+
{
|
|
56
|
+
"clientLineId": "line_inclusive",
|
|
57
|
+
"amountMinor": 2400,
|
|
58
|
+
"discounts": [
|
|
59
|
+
{
|
|
60
|
+
"discountId": "discount_line",
|
|
61
|
+
"label": "Line discount",
|
|
62
|
+
"amountMinor": 120
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"clientLineId": "line_exclusive",
|
|
68
|
+
"amountMinor": 1100,
|
|
69
|
+
"discounts": []
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"orderDiscountMinor": 124
|
|
73
|
+
},
|
|
74
|
+
"taxByRate": [
|
|
75
|
+
{
|
|
76
|
+
"ratePpm": 200000,
|
|
77
|
+
"code": "VAT20",
|
|
78
|
+
"netMinor": 1831,
|
|
79
|
+
"taxMinor": 366,
|
|
80
|
+
"grossMinor": 2197
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"ratePpm": 100000,
|
|
84
|
+
"netMinor": 963,
|
|
85
|
+
"taxMinor": 96,
|
|
86
|
+
"grossMinor": 1059
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"customer": {
|
|
90
|
+
"email": "buyer@example.com",
|
|
91
|
+
"customerId": "cus_golden_v3"
|
|
92
|
+
},
|
|
93
|
+
"registerId": "register_golden",
|
|
94
|
+
"cashierRef": "cashier_golden",
|
|
95
|
+
"sessionId": "019f6d2e-7800-7000-8000-000000000003"
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/pos-order/command.ts
CHANGED
|
@@ -1,22 +1,99 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
1
|
+
import type { OrderCreateEnvelope } from '@tallyui/core';
|
|
2
|
+
import { MICROS_PER_MINOR, roundMicrosToMinor, taxMicros } from '../tax/exact';
|
|
3
|
+
import type { PosOrder, PosOrderLine } from './types';
|
|
4
|
+
|
|
5
|
+
export class UnsupportedOrderVersionError extends Error {
|
|
6
|
+
readonly code = 'UNSUPPORTED_ORDER_VERSION';
|
|
7
|
+
constructor(readonly needed: number, readonly supported: number) {
|
|
8
|
+
super(`This sale needs order.create version ${needed}; the server supports up to ${supported}.`);
|
|
9
|
+
this.name = 'UnsupportedOrderVersionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `@tallyui/core/server`'s `payloadBoundErrors` bound, in UTF-16 code units (`String.length`), on
|
|
15
|
+
* every order.create string but `customer.email` (254), `customerId` (64) and `sessionId` (36).
|
|
16
|
+
*/
|
|
17
|
+
export const PAYLOAD_STRING_MAX = 255;
|
|
18
|
+
|
|
19
|
+
/** A non-empty string of at most `max` UTF-16 units with no NUL: what order.create's shape check accepts. */
|
|
20
|
+
export const sendable = (value: unknown, max: number): value is string =>
|
|
21
|
+
typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\u0000');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Why a customer (picked from a search, or restored from a parked order) can't go on a sale, or null: its email
|
|
25
|
+
* (over 254 units, or a NUL) or its id (over 64, or a NUL) would be refused by order.create's shape check.
|
|
26
|
+
*/
|
|
27
|
+
export function customerRefusal(customer: { id?: string; email?: string }): string | null {
|
|
28
|
+
return customer.email !== undefined && customer.email !== '' && !sendable(customer.email, 254) ? CUSTOMER_REFUSALS[0]
|
|
29
|
+
: customer.id !== undefined && customer.id !== '' && !sendable(customer.id, 64) ? CUSTOMER_REFUSALS[1] : null;
|
|
30
|
+
}
|
|
31
|
+
/** Every message `customerRefusal` returns (email, then id). */
|
|
32
|
+
export const CUSTOMER_REFUSALS = (['email', 'id'] as const)
|
|
33
|
+
.map((field) => `This customer's ${field} can't be sent to the store, so they weren't added to the sale.`);
|
|
34
|
+
|
|
35
|
+
/** Text with NUL stripped, and over `max` UTF-16 units cut to `max - 1` plus '…', never inside a surrogate pair. */
|
|
36
|
+
export function cutText(text: string, max: number): string {
|
|
37
|
+
const clean = text.replaceAll('\u0000', '');
|
|
38
|
+
if (clean.length <= max) return clean;
|
|
39
|
+
const high = clean.charCodeAt(max - 2);
|
|
40
|
+
return `${clean.slice(0, high >= 0xd800 && high <= 0xdbff ? max - 2 : max - 1)}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Version 4 (#286): a line's discount made tax-exclusive. An exclusive line's is unchanged; an inclusive line's
|
|
45
|
+
* gross discount D on its amount A becomes `net(A) − net(A − D)`, exact in millionths (net(A − D) from its stored
|
|
46
|
+
* taxLines, the tax on A − D; net(A) from `taxMicros`, as the builder taxes), rounded half away from zero.
|
|
47
|
+
*/
|
|
48
|
+
function netDiscountMinor(line: PosOrderLine, pricesIncludeTax: boolean): number {
|
|
49
|
+
if (!(line.taxInclusive ?? pricesIncludeTax) || line.discountMinor === 0) return line.discountMinor;
|
|
50
|
+
const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
51
|
+
const taxAfter = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
|
|
52
|
+
const taxBefore = taxMicros(line.unitPriceMinor * line.quantity, ratePpm, true);
|
|
53
|
+
return roundMicrosToMinor(BigInt(line.discountMinor) * MICROS_PER_MINOR - taxBefore + taxAfter);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The order.create version an order's stored content makes: 3 with ADR-065's figures, else 2 when its lines are
|
|
58
|
+
* discounted (ADR-062), else 1. The builder's, and pos_orders v5's migration's for a row with no `sentVersion`.
|
|
59
|
+
*/
|
|
60
|
+
export function contentVersion(order: Pick<PosOrder, 'display' | 'taxByRate' | 'lines'>): 1 | 2 | 3 {
|
|
61
|
+
return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
|
|
62
|
+
}
|
|
3
63
|
|
|
4
64
|
/**
|
|
5
|
-
* Builds the ADR-038 order.create envelope for a PosOrder.
|
|
6
|
-
*
|
|
65
|
+
* Builds the ADR-038 order.create envelope for a PosOrder. It sends the stored order unchanged, so every resend of
|
|
66
|
+
* an order is byte-identical, and an order stored by an older till goes out exactly as that till sent it:
|
|
67
|
+
* `finalizeOrder` freezes the sent form (names and labels cut, an unsendable customer email or id left out).
|
|
68
|
+
* ADR-065's figures make version 3; otherwise a discounted order is version 2 (ADR-062), else version 1, byte-identical.
|
|
69
|
+
* A version-3 order goes as version 4 (#286) only when `maxVersion` is at least 4 and its `sentVersion` doesn't cap it.
|
|
7
70
|
*/
|
|
8
|
-
export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt = 1
|
|
9
|
-
|
|
10
|
-
const
|
|
71
|
+
export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt = 1,
|
|
72
|
+
options?: { maxVersion?: number }): OrderCreateEnvelope {
|
|
73
|
+
const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
74
|
+
const content = contentVersion(order);
|
|
75
|
+
const cap = options?.maxVersion === undefined ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
|
|
76
|
+
if (grossMinor > 0 && cap !== undefined && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
|
|
77
|
+
// Version 4 only when the cap allows it (the server advertises 4, and no sentVersion holds the order lower).
|
|
78
|
+
const version = (content === 3 && cap !== undefined && cap >= 4 ? 4
|
|
79
|
+
: Math.min(content, cap ?? content)) as OrderCreateEnvelope['version'];
|
|
80
|
+
const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
|
|
81
|
+
// The order's discount is the sum of its lines', on one basis, so the payload's two always agree.
|
|
82
|
+
const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
|
|
83
|
+
const email = order.customer?.email;
|
|
84
|
+
const id = order.customer?.id;
|
|
85
|
+
// These are the pre-#222 customerId and sessionId checks, kept byte-exact so stored orders resend older tills' bytes; don't replace with sendable.
|
|
86
|
+
const customerId = typeof id === 'string' && id.length > 0 && id.length <= 64 ? id : undefined;
|
|
87
|
+
const sessionId = order.sessionId ?? order.lateSessionId;
|
|
11
88
|
return {
|
|
12
|
-
id: order.commandId, type: 'order.create', version
|
|
89
|
+
id: order.commandId, type: 'order.create', version, createdAt: order.createdAt, deviceId, attempt,
|
|
13
90
|
payload: {
|
|
14
91
|
clientOrderId: order.id, createdAt: order.createdAt, currency: order.currency, pricesIncludeTax: order.pricesIncludeTax,
|
|
15
|
-
lines: order.lines.map((line) => ({
|
|
92
|
+
lines: order.lines.map((line, i) => ({
|
|
16
93
|
clientLineId: line.id, variantId: line.variantId ?? line.productId, title: line.name,
|
|
17
94
|
quantity: line.quantity, unitPriceMinor: line.unitPriceMinor,
|
|
18
95
|
...(line.taxInclusive !== undefined ? { taxInclusive: line.taxInclusive } : {}),
|
|
19
|
-
...(
|
|
96
|
+
...(lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}),
|
|
20
97
|
})),
|
|
21
98
|
payments: order.payments.map((payment) => ({
|
|
22
99
|
clientPaymentId: payment.id, method: payment.method, amountMinor: payment.amountMinor,
|
|
@@ -27,9 +104,20 @@ export function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt
|
|
|
27
104
|
subtotalMinor: order.subtotalMinor,
|
|
28
105
|
...(discountMinor > 0 ? { discountMinor } : {}),
|
|
29
106
|
taxMinor: order.taxMinor, totalMinor: order.totalMinor,
|
|
30
|
-
|
|
107
|
+
...(version >= 3 ? {
|
|
108
|
+
display: { ...order.display!, lines: order.display!.lines.map(({ lineId, amountMinor, discounts }) => ({
|
|
109
|
+
clientLineId: lineId, amountMinor, discounts,
|
|
110
|
+
})) },
|
|
111
|
+
taxByRate: order.taxByRate!.map(({ ratePpm, code, netMinor, amountMinor, grossMinor }) => ({
|
|
112
|
+
ratePpm, ...(code !== undefined ? { code } : {}), netMinor, taxMinor: amountMinor, grossMinor,
|
|
113
|
+
})),
|
|
114
|
+
} : {}),
|
|
115
|
+
customer: version >= 3
|
|
116
|
+
? (email || customerId ? { ...(email ? { email } : {}), ...(customerId ? { customerId } : {}) } : null)
|
|
117
|
+
: (email ? { email } : null),
|
|
31
118
|
...(order.registerId !== undefined ? { registerId: order.registerId } : {}),
|
|
32
119
|
...(order.cashierRef !== undefined ? { cashierRef: order.cashierRef } : {}),
|
|
120
|
+
...(version >= 3 && typeof sessionId === 'string' && sessionId.length > 0 && sessionId.length <= 36 ? { sessionId } : {}),
|
|
33
121
|
},
|
|
34
122
|
};
|
|
35
123
|
}
|