@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +270 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
101
|
+
// Restore line items and their discounts
|
|
101
102
|
for (const line of savedOrder.lineItems) {
|
|
102
|
-
const
|
|
103
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
+
amountMinor: payment.amountMinor,
|
|
138
|
+
tenderedMinor: payment.tenderedMinor,
|
|
139
|
+
changeMinor: payment.changeMinor,
|
|
159
140
|
reference: payment.reference,
|
|
160
141
|
});
|
|
161
142
|
}
|
package/src/order/types.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
quantity: number;
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|