@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +321 -61
  2. package/dist/index.js +1391 -185
  3. package/package.json +8 -8
  4. package/src/index.ts +15 -7
  5. package/src/order/index.ts +3 -0
  6. package/src/order/order-builder.ts +23 -7
  7. package/src/order/order-manager.ts +3 -1
  8. package/src/order/tax-figures.ts +23 -0
  9. package/src/order/types.ts +9 -3
  10. package/src/outbox/backend-not-found.ts +27 -0
  11. package/src/outbox/http-transport.ts +5 -4
  12. package/src/outbox/index.ts +2 -0
  13. package/src/outbox/logger.ts +3 -0
  14. package/src/outbox/order-outbox.ts +331 -27
  15. package/src/outbox/register-outbox.ts +250 -0
  16. package/src/outbox/types.test-d.ts +22 -0
  17. package/src/outbox/types.ts +17 -3
  18. package/src/outbox/use-order-outbox.ts +20 -6
  19. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  20. package/src/pos-order/command.ts +99 -11
  21. package/src/pos-order/finalize.ts +145 -7
  22. package/src/pos-order/index.ts +2 -2
  23. package/src/pos-order/needs-attention.ts +9 -4
  24. package/src/pos-order/open.ts +100 -37
  25. package/src/pos-order/schema.ts +35 -5
  26. package/src/pos-order/types.ts +31 -5
  27. package/src/product/search-products.ts +5 -2
  28. package/src/receipt/build-receipt-data.ts +5 -3
  29. package/src/receipt/types.ts +1 -0
  30. package/src/register/closure-document.ts +7 -0
  31. package/src/register/index.ts +5 -3
  32. package/src/register/movement-input.ts +1 -1
  33. package/src/register/register-commands.ts +147 -0
  34. package/src/register/register-document.ts +26 -1
  35. package/src/register/session-store.ts +41 -12
  36. package/src/register/use-register-session.ts +46 -2
  37. package/src/sale/cart.ts +1 -0
  38. package/src/sale/use-sale.ts +97 -40
  39. package/src/store-settings/map-store-settings.ts +7 -2
  40. package/src/store-settings/use-store-settings.ts +22 -7
  41. package/src/tax/exact.ts +74 -11
  42. package/src/tax/index.ts +1 -1
  43. package/src/tax/tax-provider.tsx +26 -4
  44. package/src/tax/types.ts +6 -0
@@ -1,7 +1,11 @@
1
- import type { RxCollection } from 'rxdb';
1
+ import type { OrderCreateEnvelope } from '@tallyui/core';
2
+ import { deepEqual, type RxCollection, type RxDocumentData } from 'rxdb';
2
3
  import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
3
- import { toOrderCreateEnvelope, uuidv7, type PosOrder } from '../pos-order';
4
+ import { outboxLogger } from './logger';
5
+ import { toOrderCreateEnvelope, UnsupportedOrderVersionError, uuidv7, type PosOrder, type PosOrderServerFailures } from '../pos-order';
6
+ import { freezeSentForm } from '../pos-order/finalize';
4
7
  import { countFresh, readFresh } from '../rxdb';
8
+ import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
5
9
  import type { CommandTransport, OutboxState } from './types';
6
10
 
7
11
  // Pause after three 401s since the server last accepted credentials.
@@ -11,15 +15,38 @@ const AUTH_FAILURES_BEFORE_PROMPT = 3;
11
15
  // command id could duplicate it, so requeue() leaves these for manual reconciliation.
12
16
  const NOT_REQUEUEABLE = new Set(['idempotency_mismatch']);
13
17
 
18
+ // The store's message in the refusal log is cut to this many characters, the cap connectors use for a foreign 426 message.
19
+ const REFUSAL_LOG_MESSAGE_MAX = 200;
20
+
21
+ // After this many consecutive failures the store answered (not offline) with the same head order and
22
+ // no progress, the queue is probed one order at a time, so an order the store can't take holds up only
23
+ // itself. With the default backoff the first probe goes after about 30 s: sales keep flowing within minutes.
24
+ export const ISOLATE_AFTER_ATTEMPTS = 5;
25
+
26
+ // An order the store has kept failing (server-answered) for this much answered time, offline gaps left out,
27
+ // is flagged in OutboxState.stuck. It stays pending and keeps retrying.
28
+ export const STUCK_AFTER_MS = 15 * 60_000;
29
+
30
+ // An advertised order.create max the outbox can use: a positive safe integer, else unknown.
31
+ const validMax = (max: unknown) => (typeof max === 'number' && Number.isSafeInteger(max) && max > 0 ? max : undefined);
32
+
14
33
  export interface OrderOutboxOptions {
15
34
  collection: RxCollection<PosOrder>;
16
- transport: CommandTransport;
35
+ transport: CommandTransport<OrderCreateEnvelope>;
17
36
  deviceId: string;
37
+ getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
38
+ refreshCapabilities?: () => Promise<void>;
18
39
  batchSize?: number;
19
40
  initialBackoffMs?: number;
20
41
  maxBackoffMs?: number;
21
42
  random?: () => number;
22
43
  now?: () => number;
44
+ /** Tests only: overrides ISOLATE_AFTER_ATTEMPTS. */
45
+ isolateAfterAttempts?: number;
46
+ /** Tests only: overrides STUCK_AFTER_MS. */
47
+ stuckAfterMs?: number;
48
+ /** Counts 404s toward OutboxState.backendMissing; pass the register outbox's too, so either one's 404s show one notice. */
49
+ backendNotFound?: BackendNotFound;
23
50
  }
24
51
 
25
52
  export interface OrderOutbox {
@@ -43,6 +70,12 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
43
70
  const random = options.random ?? Math.random;
44
71
  const now = options.now ?? Date.now;
45
72
  const state$ = new BehaviorSubject<OutboxState>({ pending: 0, sending: false });
73
+ const backendNotFound = options.backendNotFound ?? createBackendNotFound();
74
+ let missingSubscription: Subscription | undefined; // stop() lets go of the (maybe shared) tracker; start() and flush() take it up again
75
+ const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
76
+ if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
77
+ });
78
+ watchBackendMissing();
46
79
  const attempts = new Map<string, number>();
47
80
  let backoff = initialBackoff;
48
81
  let unauthorizedSinceAccepted = 0;
@@ -52,40 +85,225 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
52
85
  let stopped = false;
53
86
  let insertedDuringRun = false;
54
87
  let stoppedDuringRun = false;
88
+ const isolateAfter = options.isolateAfterAttempts ?? ISOLATE_AFTER_ATTEMPTS;
89
+ const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
90
+ // Each order's stuck clock and isolation are mirrored onto its stored `serverFailures` and restored when an outbox
91
+ // starts. In memory only, so a restart starts them again: `head`, its failure count, and an unfinished walk (whose
92
+ // failed probes are stored as isolated). `head` counts server-answered failures of batches
93
+ // led by one order. At `isolateAfter` a walk starts: one probe (an order sent alone) per backoff interval,
94
+ // through the whole pending queue, oldest first, each order once. When the store takes a probe it is up:
95
+ // the orders whose probes failed are isolated and batching resumes. When no order is left to probe, the
96
+ // store is down: nothing is isolated, and batching resumes with its backoff.
97
+ let head: { id: string; count: number } | undefined;
98
+ let walk: { failed: Map<string, string> } | undefined;
99
+ // Isolated orders are left out of batches and retried alone, each on its own backoff, while pending
100
+ // under their commandId.
101
+ const isolated = new Map<string, { backoff: number; nextAt: number; reason: string }>();
102
+ // Each order's stuck clock counts only answered time. It starts at the order's first server-answered failure (in
103
+ // a batch, as a probe or alone; `timeout` counts, like a 503); an offline (`network`) failure pauses every running
104
+ // clock (`pausedAt`), and the next response the store answers, of any kind and for any order, resumes them all,
105
+ // moving `since` on by the offline gap: a pause is about the connection. So `since` is when the clock would have
106
+ // started had there been no offline gaps: now minus its answered time (while paused, as of the pause). Progress,
107
+ // or the order leaving pending, clears it.
108
+ const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
109
+ let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
110
+ // Timer runs alternate between the batch turn (the batch, or the walk's probe) and the isolated turn (one due
111
+ // isolated order); a turn with nothing to send gives way to the other, so neither side can starve the other.
112
+ let turn: 'batch' | 'isolated' = 'batch';
113
+ let isolatedWait = false;
114
+ let runProgressed = false;
115
+ let timerFiredDuringRun = false;
116
+ let restored = false;
117
+
118
+ function stuckState(): OutboxState['stuck'] {
119
+ const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
120
+ if (!stuck.length) return undefined;
121
+ return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
122
+ reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
123
+ orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
124
+ }
125
+
126
+ // An isolated order is due once its backoff has passed, or when the clock was set back further than any backoff.
127
+ function isDue(id: string) {
128
+ const entry = isolated.get(id);
129
+ return !!entry && (entry.nextAt <= now() || entry.nextAt - now() > maxBackoff);
130
+ }
131
+
132
+ // An order's stored serverFailures: its clock, and whether it failed alone (isolated, or a failed probe).
133
+ function failuresOf(commandId: string): PosOrderServerFailures | undefined {
134
+ const clock = clocks.get(commandId);
135
+ return clock && { since: clock.since, reason: clock.reason.slice(0, 64),
136
+ isolated: isolated.has(commandId) || !!walk?.failed.has(commandId) };
137
+ }
55
138
 
56
- // Every read goes past RxDB's query cache: in RxDB 16.21.1 a sale inserted while a cached
57
- // query's read is in flight never reaches that query, and stayed unsent until a restart.
139
+ // Writes each clocked pending order's serverFailures when the stored value would change. The writes that clear a
140
+ // clock (applied, rejected, downgraded, requeued) remove the field, so every stored field has a clock here.
141
+ async function mirror() {
142
+ if (!clocks.size) return;
143
+ for (const order of await readFresh(collection, { selector: { syncStatus: 'pending', commandId: { $in: [...clocks.keys()] } } })) {
144
+ const value = failuresOf(order.commandId);
145
+ if (!value || deepEqual(value, order.serverFailures)) continue;
146
+ await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
147
+ if (data.syncStatus === 'pending' && data.commandId === order.commandId) data.serverFailures = value;
148
+ return data;
149
+ });
150
+ }
151
+ }
152
+
153
+ // Before the first send: each pending order's stored clock, paused until the store next answers (so the time the
154
+ // till was off counts as answered: never a later flag than before), and its isolation, due at once.
155
+ async function restore() {
156
+ for (const { commandId, serverFailures } of await readFresh(collection, { selector: { syncStatus: 'pending',
157
+ serverFailures: { $exists: true } }, sort: [{ createdAt: 'asc' }] })) if (serverFailures) {
158
+ const { since, reason } = serverFailures;
159
+ clocks.set(commandId, { since, pausedAt: now(), seq: ++failureSeq, reason });
160
+ if (serverFailures.isolated) isolated.set(commandId, { backoff: initialBackoff, nextAt: now(), reason });
161
+ }
162
+ restored = true;
163
+ }
164
+
165
+ // An update that changed only serverFailures is the outbox's own mirror write: nothing new to send.
166
+ const bare = ({ serverFailures, _rev, _meta, ...data }: RxDocumentData<PosOrder>) => data;
167
+ const failuresOnly = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
168
+ !deepEqual(before.serverFailures, after.serverFailures) && deepEqual(bare(before), bare(after));
169
+
170
+ // Every read bypasses the query cache. RxDB 17 fixed RxDB 16.21.1's bug 4 (rxdb#7067);
171
+ // readFresh, countFresh and watchFresh remain correct public API.
172
+ // Retiring them is a separate decision.
58
173
  async function updateState(patch: Partial<OutboxState> = {}) {
59
174
  const pending = await countFresh(collection, { syncStatus: 'pending' });
60
- state$.next({ ...state$.value, ...patch, pending });
175
+ const rejected = await countFresh(collection, { syncStatus: 'rejected' });
176
+ state$.next({ ...state$.value, ...patch, pending, rejected, stuck: stuckState() });
177
+ }
178
+
179
+ function isolate(id: string, reason: string, retryAfterMs = 0) {
180
+ const entry = isolated.get(id) ?? { backoff: initialBackoff };
181
+ const delay = Math.min(Math.max(retryAfterMs, entry.backoff * (0.9 + 0.2 * random())), maxBackoff);
182
+ isolated.set(id, { reason, backoff: Math.min(entry.backoff * 2, maxBackoff), nextAt: now() + delay });
183
+ }
184
+
185
+ // A failure the store answered (not offline). Returns undefined when the run goes on at once, or the
186
+ // floor for the retry: the store's Retry-After, or an isolated order's own backoff.
187
+ function serverFailed(orders: PosOrder[], alone: 'probe' | 'isolated' | undefined, reason: string, retryAfterMs?: number) {
188
+ const at = now();
189
+ for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
190
+ const id = orders[0].commandId;
191
+ if (alone === 'isolated') {
192
+ isolate(id, reason, retryAfterMs);
193
+ // Once the store took something this run it is up, and the run goes on, unless it asked to wait
194
+ // (Retry-After, 429). Otherwise it may be down: one request per backoff interval.
195
+ if (runProgressed && retryAfterMs === undefined && reason !== 'status_429') return undefined;
196
+ return Math.max(retryAfterMs ?? 0, isolated.get(id)!.nextAt - now());
197
+ }
198
+ if (alone === 'probe') walk?.failed.set(id, reason);
199
+ else {
200
+ head = { id, count: head?.id === id ? head.count + 1 : 1 };
201
+ if (head.count >= isolateAfter) walk ??= { failed: new Map() };
202
+ }
203
+ return retryAfterMs ?? 0;
61
204
  }
62
205
 
63
- function scheduleRetry(reason: string, retryAfterMs = 0) {
206
+ // `wait` arms the timer for an isolated order's own backoff, leaving the outbox's backoff alone.
207
+ function scheduleRetry(reason: string, retryAfterMs = 0, wait?: number) {
64
208
  state$.next({ ...state$.value, lastRetryReason: reason });
65
209
  if (stopped) { stoppedDuringRun = true; return; }
66
- const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
67
- backoff = Math.min(backoff * 2, maxBackoff);
68
- timer = setTimeout(() => { timer = undefined; flush().catch(() => {}); }, delay);
210
+ const delay = wait ?? Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
211
+ if (wait === undefined) backoff = Math.min(backoff * 2, maxBackoff);
212
+ isolatedWait = wait !== undefined;
213
+ // A timer firing while a run is still finishing finds it busy: the run's end flushes again.
214
+ timer = setTimeout(() => { timer = undefined; if (running) timerFiredDuringRun = true; flush().catch(() => {}); }, delay);
69
215
  state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
70
216
  }
71
217
 
72
218
  async function run() {
73
219
  stoppedDuringRun = false;
220
+ runProgressed = false;
221
+ const sentAlone = new Set<string>();
74
222
  while (!stopped) try {
223
+ if (!restored) await restore();
75
224
  await updateState({ sending: true, nextAttemptAt: undefined });
76
225
  insertedDuringRun = false;
77
- const orders = await readFresh(collection, {
78
- selector: { syncStatus: 'pending' }, sort: [{ createdAt: 'asc' }], limit: batchSize,
226
+ // An order no longer pending under its commandId leaves the isolated set and loses its stuck clock.
227
+ const tracked = [...new Set([...isolated.keys(), ...clocks.keys()])];
228
+ const held = tracked.length ? await readFresh(collection, { selector: { syncStatus: 'pending', commandId: { $in: tracked } },
229
+ sort: [{ createdAt: 'asc' }] }) : [];
230
+ for (const id of tracked) if (!held.some((order) => order.commandId === id)) { isolated.delete(id); clocks.delete(id); }
231
+ // During a walk, the oldest pending order neither probed nor isolated, alone. A sale rung up during
232
+ // a walk triggers its next probe: still one request per sale. None left: the store is down.
233
+ let probe: PosOrder | undefined;
234
+ if (walk) {
235
+ [probe] = await readFresh(collection, { selector: { syncStatus: 'pending',
236
+ commandId: { $nin: [...isolated.keys(), ...walk.failed.keys()] } }, sort: [{ createdAt: 'asc' }], limit: 1 });
237
+ if (!probe) { walk = undefined; head = undefined; }
238
+ }
239
+ // The isolated turn: an isolated order whose backoff is due, alone (once per run), the one due longest, so
240
+ // isolated orders take turns too. The batch turn: the probe, else a batch without the isolated orders.
241
+ // Whichever turn is next goes, unless it has nothing.
242
+ const [due] = held.filter((order) => isDue(order.commandId) && !sentAlone.has(order.commandId))
243
+ .sort((a, b) => isolated.get(a.commandId)!.nextAt - isolated.get(b.commandId)!.nextAt);
244
+ let orders = turn === 'isolated' && due ? [due] : probe ? [probe] : await readFresh(collection, {
245
+ selector: { syncStatus: 'pending', ...(isolated.size ? { commandId: { $nin: [...isolated.keys()] } } : {}) },
246
+ sort: [{ createdAt: 'asc' }], limit: batchSize,
79
247
  });
80
- if (!orders.length || stopped) { stoppedDuringRun = stopped; return; }
81
- const batch = orders.map((order) => {
248
+ if (!orders.length && due) orders = [due];
249
+ if (!orders.length || stopped) {
250
+ if (stopped || !isolated.size) { stoppedDuringRun = stopped; return; }
251
+ if (insertedDuringRun) continue;
252
+ const next = [...isolated.values()].sort((a, b) => a.nextAt - b.nextAt)[0];
253
+ return scheduleRetry(next.reason, 0, Math.min(maxBackoff, Math.max(initialBackoff, next.nextAt - now())));
254
+ }
255
+ const alone = due && orders[0] === due ? 'isolated' : probe ? 'probe' : undefined;
256
+ if (alone === 'isolated') sentAlone.add(orders[0].commandId);
257
+ turn = alone === 'isolated' ? 'batch' : 'isolated';
258
+ // An order goes out, on every attempt, at the version it first went out at: a retry's bytes never change, even
259
+ // after the store upgrades (#286). A first send takes the server's max, read once per batch; unknown is 3.
260
+ const firstSend = orders.some((order) => order.sentVersion === undefined);
261
+ const serverMax = (firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.())
262
+ .catch((cause) => { outboxLogger.warn('Failed to read capabilities', { cause }); }))) || 3;
263
+ const batch = await Promise.all(orders.map(async (order) => {
264
+ const frozen = freezeSentForm(order);
82
265
  const attempt = (attempts.get(order.commandId) ?? 0) + 1;
83
266
  attempts.set(order.commandId, attempt);
84
- return toOrderCreateEnvelope(order, deviceId, attempt);
85
- });
86
- const outcome = await transport.send(batch);
267
+ let envelope: OrderCreateEnvelope;
268
+ try {
269
+ envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: order.sentVersion ?? serverMax });
270
+ } catch (cause) {
271
+ // A discounted order the server can take only at 1 goes as before (at most 3): the refusal rejects it.
272
+ if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
273
+ envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: 3 });
274
+ }
275
+ // Bounds apply when frozen: persist older tills' sent forms before sending (Front desk, 2026-09-29). A first
276
+ // send's version is stored, awaited, before the batch request leaves, so a crash in between keeps it.
277
+ if (frozen !== order || order.sentVersion === undefined) {
278
+ await (await collection.findOne(order.id).exec())?.incrementalModify((data) =>
279
+ ({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope.version }));
280
+ }
281
+ return envelope;
282
+ }));
283
+ let outcome = await transport.send(batch);
284
+ // A 404 answers the whole batch (the route is missing); any other answer resets the count. Offline changes nothing.
285
+ backendNotFound.record(outcome, now());
286
+ if (outcome.kind !== 'retry' || outcome.reason !== 'network') {
287
+ // The store answered: every paused clock resumes, leaving out the offline gap. A device clock set back
288
+ // during it makes the gap negative, which keeps the answered time exact, since `now` moved back too.
289
+ const at = now();
290
+ for (const clock of clocks.values()) if (clock.pausedAt !== undefined) {
291
+ clock.since += at - clock.pausedAt;
292
+ clock.pausedAt = undefined;
293
+ }
294
+ }
295
+ let batchMax: number | undefined;
296
+ let capabilitiesRefreshed = false;
87
297
  if (outcome.kind === 'retry') {
88
- return scheduleRetry(outcome.reason, outcome.retryAfterMs);
298
+ let floor = outcome.retryAfterMs;
299
+ if (outcome.reason === 'network') {
300
+ // Offline never counts: it pauses every stuck clock and ends a walk, isolating nothing. The
301
+ // failure count stands, so the next failure the store answers walks again. A `timeout` (sent, but
302
+ // no answer in time) is not offline: it counts like a 503 and never pauses a clock.
303
+ for (const clock of clocks.values()) clock.pausedAt ??= now();
304
+ walk = undefined;
305
+ } else if ((floor = serverFailed(orders, alone, outcome.reason, outcome.retryAfterMs)) === undefined) continue;
306
+ return scheduleRetry(outcome.reason, floor);
89
307
  }
90
308
  if (outcome.kind === 'unauthorized') {
91
309
  unauthorizedSinceAccepted++;
@@ -94,6 +312,23 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
94
312
  sending: false, nextAttemptAt: undefined });
95
313
  return;
96
314
  }
315
+ if (outcome.kind === 'refused' && outcome.status === 400 && options.getMaxOrderCreateVersion) {
316
+ const match = /^Invalid commands\[(\d+)\]\.version$/.exec(outcome.reason);
317
+ if (match) {
318
+ try { await options.refreshCapabilities?.(); } catch (cause) { outboxLogger.warn('Failed to refresh capabilities', { cause }); }
319
+ const advertised = await options.getMaxOrderCreateVersion();
320
+ const max = typeof advertised === 'number' && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : undefined;
321
+ if (max !== undefined && max < batch[Number(match[1])]?.version) {
322
+ batchMax = max;
323
+ capabilitiesRefreshed = true;
324
+ const message = outcome.reason;
325
+ // Validation refused the whole batch before processing: only orders above the cap need changes.
326
+ outcome = { kind: 'results', results: batch.filter((command) => command.version > max).map((command) => ({
327
+ id: command.id, status: 'rejected', error: { code: 'unsupported_version', message, data: { orderCreate: max } },
328
+ })) };
329
+ }
330
+ }
331
+ }
97
332
  if (outcome.kind === 'refused') {
98
333
  unauthorizedSinceAccepted = 0;
99
334
  state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
@@ -103,6 +338,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
103
338
  unauthorizedSinceAccepted = 0;
104
339
  state$.next({ ...state$.value, authRequired: false, refused: undefined });
105
340
  let progressed = false;
341
+ let downgraded = false;
106
342
  for (const order of orders) {
107
343
  const result = outcome.results.find((entry) => entry.id === order.commandId);
108
344
  if (!result) continue;
@@ -113,18 +349,72 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
113
349
  if (stored?.syncStatus !== 'pending' || stored.commandId !== order.commandId) continue;
114
350
  const current = await collection.findOne(order.id).exec();
115
351
  if (!current) continue;
352
+ let error = result.error;
353
+ if (result.status === 'rejected' && error?.code === 'unsupported_version' && options.getMaxOrderCreateVersion) {
354
+ if (!capabilitiesRefreshed) {
355
+ try { await options.refreshCapabilities?.(); } catch (cause) { outboxLogger.warn('Failed to refresh capabilities', { cause }); }
356
+ const advertised = error.data?.orderCreate;
357
+ const max = typeof advertised === 'number' && Number.isSafeInteger(advertised) && advertised > 0
358
+ ? advertised : await options.getMaxOrderCreateVersion();
359
+ batchMax = typeof max === 'number' && Number.isSafeInteger(max) && max > 0 ? max : undefined;
360
+ capabilitiesRefreshed = true;
361
+ }
362
+ const max = batchMax;
363
+ const from = batch.find((command) => command.id === order.commandId)!.version;
364
+ if (max !== undefined && max < (stored.sentVersion ?? from)) {
365
+ try {
366
+ const to = toOrderCreateEnvelope(stored, deviceId, 1, { maxVersion: max }).version;
367
+ let changed = false;
368
+ await current.incrementalModify((data) => {
369
+ changed = data.syncStatus === 'pending' && data.commandId === order.commandId && max < (data.sentVersion ?? from);
370
+ if (changed) { data.sentVersion = to; data.downgradedFrom ??= from; delete data.serverFailures; }
371
+ return data;
372
+ });
373
+ if (changed) {
374
+ outboxLogger.warn('Sent an order at a lower order.create version', { orderId: order.id, from, to });
375
+ progressed = downgraded = true;
376
+ clocks.delete(order.commandId);
377
+ }
378
+ continue;
379
+ } catch (cause) {
380
+ if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
381
+ error = { code: 'unsupported_version', message: cause.message };
382
+ }
383
+ }
384
+ }
385
+ // Every refusal goes to the sync log once, with the store's code and message (the orders list shows the cashier
386
+ // only the code's sentence, #269); unsupported_version stays an error.
387
+ if (result.status === 'rejected') outboxLogger[error?.code === 'unsupported_version' ? 'error' : 'warn']('Order refused by the store',
388
+ { orderId: order.id, code: error?.code, message: error?.message?.slice(0, REFUSAL_LOG_MESSAGE_MAX) });
116
389
  const updatedAt = new Date(now()).toISOString();
117
- await current.incrementalPatch(result.status === 'rejected'
118
- ? { syncStatus: 'rejected', error: result.error, updatedAt }
119
- : { syncStatus: 'applied', serverRefs: result.serverRefs,
120
- ...(result.warnings ? { warnings: result.warnings } : {}), updatedAt });
390
+ // Progress removes the stored serverFailures in the write that marks the order applied or rejected.
391
+ await current.incrementalModify((data) => {
392
+ delete data.serverFailures;
393
+ return Object.assign(data, result.status === 'rejected'
394
+ ? { syncStatus: 'rejected' as const, error, updatedAt }
395
+ : { syncStatus: 'applied' as const, serverRefs: result.serverRefs,
396
+ ...(result.warnings ? { warnings: result.warnings } : {}), updatedAt });
397
+ });
121
398
  attempts.delete(order.commandId);
399
+ isolated.delete(order.commandId);
400
+ clocks.delete(order.commandId);
122
401
  progressed = true;
123
402
  await updateState();
124
403
  }
125
- if (!progressed) return scheduleRetry('no_progress');
404
+ if (!progressed) {
405
+ const floor = serverFailed(orders, alone, 'no_progress');
406
+ if (floor === undefined) continue;
407
+ return scheduleRetry('no_progress', floor);
408
+ }
409
+ runProgressed = true;
410
+ if (alone !== 'isolated') head = undefined;
411
+ if (walk && alone === 'probe') {
412
+ // The store took a probe, so it is up: the orders whose probes failed are isolated, and batching resumes.
413
+ for (const [id, reason] of walk.failed) isolate(id, reason);
414
+ walk = undefined;
415
+ }
126
416
  backoff = initialBackoff;
127
- state$.next({ ...state$.value, lastRetryReason: undefined });
417
+ state$.next({ ...state$.value, lastRetryReason: downgraded ? 'downgraded' : undefined });
128
418
  } catch (error) {
129
419
  scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
130
420
  return;
@@ -135,12 +425,19 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
135
425
  function flush(): Promise<void> {
136
426
  if (running) return running;
137
427
  stopped = false;
428
+ watchBackendMissing();
138
429
  clearTimeout(timer);
139
430
  timer = undefined;
140
431
  running = Promise.resolve().then(run).finally(async () => {
432
+ try { await mirror(); } catch (cause) { outboxLogger.warn('Failed to store server failures', { cause }); }
141
433
  try { await updateState({ sending: false }); } catch {}
142
434
  running = undefined;
143
- if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === undefined) flush().catch(() => {});
435
+ const missed = timerFiredDuringRun;
436
+ timerFiredDuringRun = false;
437
+ // A timer that fired during the run is not lost; a sale inserted while only isolated orders wait goes now.
438
+ if (!stopped && (missed || (insertedDuringRun || stoppedDuringRun) && (timer === undefined || insertedDuringRun && isolatedWait))) {
439
+ flush().catch(() => {});
440
+ }
144
441
  });
145
442
  return running;
146
443
  }
@@ -163,6 +460,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
163
460
  if (!changed) return data;
164
461
  data.syncStatus = 'pending';
165
462
  delete data.error;
463
+ delete data.serverFailures;
464
+ // A new commandId is a new idempotency key, so the next send chooses its version afresh.
465
+ delete data.sentVersion;
466
+ delete data.downgradedFrom;
166
467
  // The server ledger stored the rejection under the old id; replaying it returns that rejection.
167
468
  // For the codes requeue() accepts, the server created no order, so a fresh id cannot duplicate one.
168
469
  data.commandId = uuidv7();
@@ -177,9 +478,11 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
177
478
  },
178
479
  start() {
179
480
  stopped = false;
481
+ watchBackendMissing();
180
482
  if (!subscription) subscription = collection.$.subscribe((event) => {
181
- if (event.documentData?.syncStatus === 'pending' &&
182
- (event.operation === 'INSERT' || event.operation === 'UPDATE')) {
483
+ // An UPDATE without previousDocumentData counts as a change: a needless send costs a request, a skipped one a sale.
484
+ if (event.documentData?.syncStatus === 'pending' && (event.operation === 'INSERT' || event.operation === 'UPDATE' &&
485
+ !(event.previousDocumentData !== undefined && failuresOnly(event.previousDocumentData, event.documentData)))) {
183
486
  insertedDuringRun = true;
184
487
  flush().catch(() => {});
185
488
  }
@@ -192,6 +495,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
192
495
  timer = undefined;
193
496
  subscription?.unsubscribe();
194
497
  subscription = undefined;
498
+ missingSubscription?.unsubscribe(); missingSubscription = undefined;
195
499
  state$.next({ ...state$.value, nextAttemptAt: undefined });
196
500
  },
197
501
  };