@tallyui/pos 0.1.0 → 2.0.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
@@ -8,7 +8,7 @@ export interface ParkedOrderSummary {
8
8
  id: string;
9
9
  customerName?: string;
10
10
  itemCount: number;
11
- total: number;
11
+ totalMinor: number;
12
12
  parkedAt: string;
13
13
  source: 'local' | 'server';
14
14
  }
@@ -29,7 +29,8 @@ export interface OrderManager {
29
29
  }
30
30
 
31
31
  export function createOrderManager(options: OrderManagerOptions): OrderManager {
32
- const { currency, taxContext, draftsCollection } = options;
32
+ const { taxContext, draftsCollection } = options;
33
+ const currency = options.currency.toUpperCase();
33
34
 
34
35
  let activeBuilder = createOrderBuilder({ currency, taxContext });
35
36
  const activeSubject = new BehaviorSubject<OrderBuilder>(activeBuilder);
@@ -42,7 +43,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
42
43
  id: json.id,
43
44
  customerName: json.customerName || undefined,
44
45
  itemCount: json.itemCount ?? 0,
45
- total: json.total ?? 0,
46
+ totalMinor: json.total ?? 0,
46
47
  parkedAt: json.parkedAt ?? '',
47
48
  source: 'local' as const,
48
49
  };
@@ -67,7 +68,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
67
68
  data: JSON.stringify(snapshot),
68
69
  customerName: snapshot.customer?.name ?? '',
69
70
  itemCount: snapshot.lineItems.length,
70
- total: snapshot.total,
71
+ total: snapshot.totalMinor,
71
72
  parkedAt: new Date().toISOString(),
72
73
  });
73
74
 
@@ -97,47 +98,25 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
97
98
  builder.setNote(savedOrder.note);
98
99
  }
99
100
 
100
- // Restore line items using synthetic traits
101
+ // Restore line items and their discounts
101
102
  for (const line of savedOrder.lineItems) {
102
- const syntheticTraits = {
103
- getId: (d: any) => d.productId,
104
- getName: (d: any) => d.name,
105
- getSku: (d: any) => d.sku,
106
- getPrice: (d: any) => String(d.price),
107
- getRegularPrice: (d: any) => String(d.price),
108
- getSalePrice: () => undefined,
109
- isOnSale: () => false,
110
- getImageUrl: (d: any) => d.imageUrl,
111
- getImageUrls: (d: any) => (d.imageUrl ? [d.imageUrl] : []),
112
- getDescription: () => undefined,
113
- getStockStatus: () => 'instock' as const,
114
- getStockQuantity: () => null,
115
- hasVariants: () => false,
116
- getType: () => 'simple',
117
- getBarcode: () => undefined,
118
- getCategoryNames: () => [],
119
- };
120
- builder.addProduct(line, syntheticTraits, {
103
+ const lineId = builder.addLine({
104
+ productId: line.productId,
121
105
  variantId: line.variantId,
106
+ name: line.name,
107
+ sku: line.sku,
108
+ imageUrl: line.imageUrl,
109
+ unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
122
110
  quantity: line.quantity,
111
+ taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm })),
123
112
  });
124
- }
125
-
126
- // Restore line-level discounts
127
- const resumedOrder = builder.getSnapshot();
128
- for (const savedLine of savedOrder.lineItems) {
129
- const newLine = resumedOrder.lineItems.find(
130
- (li) => li.productId === savedLine.productId && li.variantId === savedLine.variantId,
131
- );
132
- if (newLine) {
133
- for (const discount of savedLine.discounts) {
134
- builder.applyLineDiscount(newLine.id, {
135
- type: discount.type,
136
- value: discount.value,
137
- label: discount.label,
138
- couponCode: discount.couponCode,
139
- });
140
- }
113
+ for (const discount of line.discounts) {
114
+ builder.applyLineDiscount(lineId, {
115
+ type: discount.type,
116
+ value: discount.value,
117
+ label: discount.label,
118
+ couponCode: discount.couponCode,
119
+ });
141
120
  }
142
121
  }
143
122
 
@@ -155,7 +134,9 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
155
134
  for (const payment of savedOrder.payments) {
156
135
  builder.addPayment({
157
136
  method: payment.method,
158
- amount: payment.amount,
137
+ amountMinor: payment.amountMinor,
138
+ tenderedMinor: payment.tenderedMinor,
139
+ changeMinor: payment.changeMinor,
159
140
  reference: payment.reference,
160
141
  });
161
142
  }
@@ -1,3 +1,5 @@
1
+ import type { Money } from '@tallyui/core';
2
+
1
3
  export interface Order {
2
4
  id: string;
3
5
  status: 'draft' | 'parked' | 'saved' | 'completed';
@@ -6,17 +8,42 @@ export interface Order {
6
8
  payments: Payment[];
7
9
  customer: CustomerSummary | null;
8
10
  note: string;
9
- subtotal: number;
10
- discountTotal: number;
11
- taxTotal: number;
12
- total: number;
13
- balanceDue: number;
14
- changeDue: number;
15
11
  currency: string;
12
+ pricesIncludeTax: boolean;
13
+ // Settlement figures, sent in `order.create` (ADR-038, ADR-062). Show `display` instead (ADR-063).
14
+ subtotalMinor: number; // settlement: excl. tax, after line and order discounts (both pre-tax, ADR-062)
15
+ discountMinor: number; // settlement: Σ lineItems[].discountMinor, each in its line's own mode, so it mixes modes; not for display
16
+ taxMinor: number; // settlement: rounded once per order
17
+ totalMinor: number; // settlement: what the customer pays
18
+ display: DisplayTotals;
19
+ paidMinor: number;
20
+ balanceDueMinor: number; // max(0, total − paid)
21
+ changeDueMinor: number; // max(0, paid − total)
16
22
  createdAt: string;
17
23
  updatedAt: string;
18
24
  }
19
25
 
26
+ /**
27
+ * Figures for showing the cart or a receipt in the store's display mode (ADR-063); never sent to the server.
28
+ * A parked order's saved draft may carry them; they are recomputed from the lines on resume, so never authoritative.
29
+ */
30
+ export interface DisplayTotals {
31
+ taxInclusive: boolean; // = order.pricesIncludeTax
32
+ subtotalMinor: number; // before discounts: total − tax + discount when exclusive, total + discount when inclusive
33
+ discountMinor: number; // Σ lines' discount rows + orderDiscountMinor, in the display mode; >= 0
34
+ taxMinor: number; // = order.taxMinor (added when exclusive, included when inclusive)
35
+ totalMinor: number; // = order.totalMinor
36
+ lines: DisplayLine[]; // in lineItems order; Σ amountMinor = subtotalMinor
37
+ orderDiscountMinor: number; // the order discounts as one row, not allocated: Σ each line's share, converted on its own
38
+ }
39
+
40
+ /** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
41
+ export interface DisplayLine {
42
+ lineId: string;
43
+ amountMinor: number; // quantity × unit price in the display mode; the last converted line also carries the rounding residue
44
+ discounts: { discountId: string; label?: string; amountMinor: number }[]; // this line's own discounts, each converted on its own
45
+ }
46
+
20
47
  export interface LineItem {
21
48
  id: string;
22
49
  productId: string;
@@ -24,31 +51,45 @@ export interface LineItem {
24
51
  name: string;
25
52
  sku: string;
26
53
  imageUrl?: string;
27
- price: number;
28
- quantity: number;
29
- taxRate: number;
30
- taxAmount: number;
54
+ unitPriceMinor: number; // as sold, integer
55
+ quantity: number; // integer >= 1
56
+ taxLines: LineTaxLine[]; // stacked rates on the same net base (ADR-040)
31
57
  discounts: AppliedDiscount[];
32
- discountAmount: number;
33
- lineTotal: number;
58
+ discountMinor: number; // line discounts + orderDiscountMinor, in the line's own mode
59
+ orderDiscountMinor: number; // this line's allocated share of the order discounts (ADR-062)
60
+ netMinor: number; // unitPriceMinor × quantity − discountMinor; the taxed base
61
+ taxMicros: string; // Σ taxLines[].taxMicros, decimal string of a bigint
62
+ taxInclusive: boolean; // the price's own tax mode; the order's when the price has none
63
+ /** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
64
+ priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
65
+ }
66
+
67
+ export interface LineTaxLine {
68
+ code?: string;
69
+ ratePpm: number;
70
+ taxMicros: string; // exact, decimal string of a bigint
34
71
  }
35
72
 
36
73
  export interface Discount {
37
74
  type: 'percentage' | 'fixed';
38
- value: number;
75
+ value: number; // percentage: percent (e.g. 10); fixed: integer minor units
39
76
  label?: string;
40
77
  couponCode?: string;
41
78
  }
42
79
 
43
80
  export interface AppliedDiscount extends Discount {
44
81
  id: string;
45
- amount: number;
82
+ amountMinor: number; // an order discount: its own-mode amount, allocated across the lines
46
83
  }
47
84
 
48
85
  export interface Payment {
49
86
  id: string;
50
87
  method: string;
51
- amount: number;
88
+ // For cash, record the full amount tendered here, not the capped amount: `finalizeOrder`
89
+ // caps it at the balance due and writes out `tenderedMinor`/`changeMinor` itself.
90
+ amountMinor: number;
91
+ tenderedMinor?: number;
92
+ changeMinor?: number;
52
93
  reference?: string;
53
94
  }
54
95
 
@@ -64,3 +105,14 @@ export interface PaymentMethod {
64
105
  icon?: string;
65
106
  requiresReference?: boolean;
66
107
  }
108
+
109
+ export interface AddLineInput {
110
+ productId: string;
111
+ variantId?: string;
112
+ name: string;
113
+ sku?: string;
114
+ imageUrl?: string;
115
+ unitPrice: Money & { taxInclusive?: boolean }; // currency must equal the order currency; taxInclusive wins over the order's pricesIncludeTax
116
+ quantity?: number; // default 1
117
+ taxRates?: Array<{ code?: string; ratePpm: number }>; // default: [{ ratePpm: from taxContext }]
118
+ }
@@ -0,0 +1,66 @@
1
+ import { COMMANDS_PATH, PROTOCOL_HEADER, PROTOCOL_VERSION, isCommandBatchResponse } from '@tallyui/core';
2
+ import type { CommandTransport } from './types';
3
+
4
+ // These HTTP statuses permanently refuse the entire command batch.
5
+ const PERMANENT_STATUSES = new Set([400, 403, 413, 415, 422]);
6
+
7
+ export interface HttpTransportOptions {
8
+ baseUrl: string;
9
+ getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
10
+ fetch?: typeof fetch;
11
+ timeoutMs?: number;
12
+ }
13
+
14
+ export function createHttpCommandTransport(options: HttpTransportOptions): CommandTransport {
15
+ const fetch = options.fetch ?? globalThis.fetch;
16
+ let end = options.baseUrl.length;
17
+ while (end > 0 && options.baseUrl[end - 1] === '/') {
18
+ end--;
19
+ }
20
+ const baseUrl = options.baseUrl.slice(0, end);
21
+ return {
22
+ async send(batch) {
23
+ const controller = new AbortController();
24
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 30000);
25
+ try {
26
+ const response = await fetch(`${baseUrl}${COMMANDS_PATH}`, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': 'application/json',
30
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
31
+ ...await options.getHeaders(),
32
+ },
33
+ body: JSON.stringify({ commands: batch }),
34
+ signal: controller.signal,
35
+ });
36
+ const retryAfter = response.headers.get('Retry-After');
37
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
38
+ const retry = (reason: string) => ({ kind: 'retry' as const, reason,
39
+ ...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}),
40
+ });
41
+ if (response.status === 401) return { kind: 'unauthorized' };
42
+ if (PERMANENT_STATUSES.has(response.status)) {
43
+ let reason = `status_${response.status}`;
44
+ try {
45
+ const body = await response.json();
46
+ if (typeof body?.message === 'string') reason = body.message;
47
+ else if (typeof body?.code === 'string') reason = body.code;
48
+ } catch {}
49
+ return { kind: 'refused', status: response.status, reason };
50
+ }
51
+ if (response.status !== 200) return retry(`status_${response.status}`);
52
+ let body: unknown;
53
+ try {
54
+ body = await response.json();
55
+ } catch {
56
+ return retry(controller.signal.aborted ? 'network' : 'bad_body');
57
+ }
58
+ return isCommandBatchResponse(body) ? { kind: 'results', results: body.results } : retry('bad_body');
59
+ } catch {
60
+ return { kind: 'retry', reason: 'network' };
61
+ } finally {
62
+ clearTimeout(timeout);
63
+ }
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,7 @@
1
+ export { createHttpCommandTransport } from './http-transport';
2
+ export type { HttpTransportOptions } from './http-transport';
3
+ export { createOrderOutbox } from './order-outbox';
4
+ export type { OrderOutboxOptions, OrderOutbox } from './order-outbox';
5
+ export type { TransportOutcome, CommandTransport, OutboxState } from './types';
6
+ export { useOrderOutbox, outboxLogger } from './use-order-outbox';
7
+ export type { UseOrderOutboxOptions, UseOrderOutboxResult } from './use-order-outbox';
@@ -0,0 +1,198 @@
1
+ import type { RxCollection } from 'rxdb';
2
+ import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
3
+ import { toOrderCreateEnvelope, uuidv7, type PosOrder } from '../pos-order';
4
+ import { countFresh, readFresh } from '../rxdb';
5
+ import type { CommandTransport, OutboxState } from './types';
6
+
7
+ // Pause after three 401s since the server last accepted credentials.
8
+ const AUTH_FAILURES_BEFORE_PROMPT = 3;
9
+
10
+ // Rejections that may hide an order the server already created: resending under a new
11
+ // command id could duplicate it, so requeue() leaves these for manual reconciliation.
12
+ const NOT_REQUEUEABLE = new Set(['idempotency_mismatch']);
13
+
14
+ export interface OrderOutboxOptions {
15
+ collection: RxCollection<PosOrder>;
16
+ transport: CommandTransport;
17
+ deviceId: string;
18
+ batchSize?: number;
19
+ initialBackoffMs?: number;
20
+ maxBackoffMs?: number;
21
+ random?: () => number;
22
+ now?: () => number;
23
+ }
24
+
25
+ export interface OrderOutbox {
26
+ /** Sends pending orders until empty or retrying. Concurrent calls share one run. */
27
+ flush(): Promise<void>;
28
+ /** Moves rejected orders (all, or those whose id is listed) back to pending with a new commandId, then flushes.
29
+ * Orders rejected with idempotency_mismatch are left for reconciliation. Resolves to the number requeued. */
30
+ requeue(orderIds?: string[]): Promise<number>;
31
+ /** Watches for pending orders and sends them. */
32
+ start(): void;
33
+ /** Stops watching and retrying. */
34
+ stop(): void;
35
+ state$: Observable<OutboxState>;
36
+ }
37
+
38
+ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
39
+ const { collection, transport, deviceId } = options;
40
+ const batchSize = Math.min(options.batchSize ?? 10, 10);
41
+ const initialBackoff = options.initialBackoffMs ?? 1000;
42
+ const maxBackoff = options.maxBackoffMs ?? 60000;
43
+ const random = options.random ?? Math.random;
44
+ const now = options.now ?? Date.now;
45
+ const state$ = new BehaviorSubject<OutboxState>({ pending: 0, sending: false });
46
+ const attempts = new Map<string, number>();
47
+ let backoff = initialBackoff;
48
+ let unauthorizedSinceAccepted = 0;
49
+ let running: Promise<void> | undefined;
50
+ let timer: ReturnType<typeof setTimeout> | undefined;
51
+ let subscription: Subscription | undefined;
52
+ let stopped = false;
53
+ let insertedDuringRun = false;
54
+ let stoppedDuringRun = false;
55
+
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.
58
+ async function updateState(patch: Partial<OutboxState> = {}) {
59
+ const pending = await countFresh(collection, { syncStatus: 'pending' });
60
+ state$.next({ ...state$.value, ...patch, pending });
61
+ }
62
+
63
+ function scheduleRetry(reason: string, retryAfterMs = 0) {
64
+ state$.next({ ...state$.value, lastRetryReason: reason });
65
+ 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);
69
+ state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
70
+ }
71
+
72
+ async function run() {
73
+ stoppedDuringRun = false;
74
+ while (!stopped) try {
75
+ await updateState({ sending: true, nextAttemptAt: undefined });
76
+ insertedDuringRun = false;
77
+ const orders = await readFresh(collection, {
78
+ selector: { syncStatus: 'pending' }, sort: [{ createdAt: 'asc' }], limit: batchSize,
79
+ });
80
+ if (!orders.length || stopped) { stoppedDuringRun = stopped; return; }
81
+ const batch = orders.map((order) => {
82
+ const attempt = (attempts.get(order.commandId) ?? 0) + 1;
83
+ attempts.set(order.commandId, attempt);
84
+ return toOrderCreateEnvelope(order, deviceId, attempt);
85
+ });
86
+ const outcome = await transport.send(batch);
87
+ if (outcome.kind === 'retry') {
88
+ return scheduleRetry(outcome.reason, outcome.retryAfterMs);
89
+ }
90
+ if (outcome.kind === 'unauthorized') {
91
+ unauthorizedSinceAccepted++;
92
+ if (unauthorizedSinceAccepted < AUTH_FAILURES_BEFORE_PROMPT) return scheduleRetry('unauthorized');
93
+ state$.next({ ...state$.value, authRequired: true, lastRetryReason: 'unauthorized',
94
+ sending: false, nextAttemptAt: undefined });
95
+ return;
96
+ }
97
+ if (outcome.kind === 'refused') {
98
+ unauthorizedSinceAccepted = 0;
99
+ state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
100
+ authRequired: false, lastRetryReason: 'refused', sending: false, nextAttemptAt: undefined });
101
+ return;
102
+ }
103
+ unauthorizedSinceAccepted = 0;
104
+ state$.next({ ...state$.value, authRequired: false, refused: undefined });
105
+ let progressed = false;
106
+ for (const order of orders) {
107
+ const result = outcome.results.find((entry) => entry.id === order.commandId);
108
+ if (!result) continue;
109
+ // A primary-key lookup straight on the storage instance: skips the query cache (like
110
+ // readFresh, but without its full index scan here) and excludes a deleted document as not
111
+ // found. The commandId check catches an order requeued while this one was in flight.
112
+ const [stored] = await collection.storageInstance.findDocumentsById([order.id], false);
113
+ if (stored?.syncStatus !== 'pending' || stored.commandId !== order.commandId) continue;
114
+ const current = await collection.findOne(order.id).exec();
115
+ if (!current) continue;
116
+ 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 });
121
+ attempts.delete(order.commandId);
122
+ progressed = true;
123
+ await updateState();
124
+ }
125
+ if (!progressed) return scheduleRetry('no_progress');
126
+ backoff = initialBackoff;
127
+ state$.next({ ...state$.value, lastRetryReason: undefined });
128
+ } catch (error) {
129
+ scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
130
+ return;
131
+ }
132
+ stoppedDuringRun = true;
133
+ }
134
+
135
+ function flush(): Promise<void> {
136
+ if (running) return running;
137
+ stopped = false;
138
+ clearTimeout(timer);
139
+ timer = undefined;
140
+ running = Promise.resolve().then(run).finally(async () => {
141
+ try { await updateState({ sending: false }); } catch {}
142
+ running = undefined;
143
+ if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === undefined) flush().catch(() => {});
144
+ });
145
+ return running;
146
+ }
147
+
148
+ updateState().catch(() => {});
149
+ return {
150
+ state$,
151
+ flush,
152
+ async requeue(orderIds) {
153
+ const orders = (await readFresh(collection, { selector: { syncStatus: 'rejected',
154
+ ...(orderIds ? { id: { $in: orderIds } } : {}) } }))
155
+ .filter((order) => !NOT_REQUEUEABLE.has(order.error?.code ?? ''));
156
+ let count = 0;
157
+ for (const order of orders) {
158
+ const oldCommandId = order.commandId;
159
+ let changed = false;
160
+ // The modifier sees the stored state, even when findOne's cached result is stale.
161
+ await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
162
+ changed = data.syncStatus === 'rejected' && !NOT_REQUEUEABLE.has(data.error?.code ?? '');
163
+ if (!changed) return data;
164
+ data.syncStatus = 'pending';
165
+ delete data.error;
166
+ // The server ledger stored the rejection under the old id; replaying it returns that rejection.
167
+ // For the codes requeue() accepts, the server created no order, so a fresh id cannot duplicate one.
168
+ data.commandId = uuidv7();
169
+ data.updatedAt = new Date(now()).toISOString();
170
+ return data;
171
+ });
172
+ if (changed) { count++; attempts.delete(oldCommandId); }
173
+ }
174
+ await updateState();
175
+ if (count && !stopped) flush().catch(() => {});
176
+ return count;
177
+ },
178
+ start() {
179
+ stopped = false;
180
+ if (!subscription) subscription = collection.$.subscribe((event) => {
181
+ if (event.documentData?.syncStatus === 'pending' &&
182
+ (event.operation === 'INSERT' || event.operation === 'UPDATE')) {
183
+ insertedDuringRun = true;
184
+ flush().catch(() => {});
185
+ }
186
+ });
187
+ if (!stopped) flush().catch(() => {});
188
+ },
189
+ stop() {
190
+ stopped = true;
191
+ clearTimeout(timer);
192
+ timer = undefined;
193
+ subscription?.unsubscribe();
194
+ subscription = undefined;
195
+ state$.next({ ...state$.value, nextAttemptAt: undefined });
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,22 @@
1
+ import type { CommandEnvelope, CommandResult, OrderCreatePayload } from '@tallyui/core';
2
+
3
+ export type TransportOutcome =
4
+ | { kind: 'results'; results: CommandResult[] }
5
+ | { kind: 'unauthorized' }
6
+ | { kind: 'refused'; status: number; reason: string }
7
+ | { kind: 'retry'; reason: string; retryAfterMs?: number };
8
+
9
+ export interface CommandTransport {
10
+ send(batch: CommandEnvelope<OrderCreatePayload>[]): Promise<TransportOutcome>;
11
+ }
12
+
13
+ export interface OutboxState {
14
+ pending: number;
15
+ sending: boolean;
16
+ lastRetryReason?: string;
17
+ nextAttemptAt?: number;
18
+ /** Set after 3 consecutive 401s; the app should ask the cashier to sign in, then call flush(). */
19
+ authRequired?: boolean;
20
+ /** 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
+ refused?: { status: number; reason: string };
22
+ }