@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
package/dist/index.js
CHANGED
|
@@ -111,6 +111,7 @@ function useCurrencyCode() {
|
|
|
111
111
|
|
|
112
112
|
// src/tax/exact.ts
|
|
113
113
|
var MICROS_PER_MINOR = 1000000n;
|
|
114
|
+
var DEFAULT_TAX_ROUNDING = { granularity: "per_order", mode: "half_away_from_zero" };
|
|
114
115
|
function ratePpmFromPercent(percent) {
|
|
115
116
|
const value = String(percent);
|
|
116
117
|
const match = /^(\d+)(?:\.(\d{1,4}))?$/.exec(value);
|
|
@@ -134,9 +135,13 @@ function taxMicros(amountMinor, ratePpm, pricesIncludeTax) {
|
|
|
134
135
|
const sign = numerator < 0n ? -1n : 1n;
|
|
135
136
|
return sign * ((sign * numerator + denominator / 2n) / denominator);
|
|
136
137
|
}
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
const
|
|
138
|
+
function roundRatio(n, d, mode = "half_away_from_zero") {
|
|
139
|
+
if (mode === "half_away_from_zero" && n < 0n) return -roundRatio(-n, d, mode);
|
|
140
|
+
const a = 2n * n + d, b = 2n * d;
|
|
141
|
+
return a / b - (a < 0n && a % b !== 0n ? 1n : 0n);
|
|
142
|
+
}
|
|
143
|
+
function roundMicrosToMinor(micros, mode) {
|
|
144
|
+
const minor2 = roundRatio(micros, MICROS_PER_MINOR, mode);
|
|
140
145
|
if (minor2 < -BigInt(Number.MAX_SAFE_INTEGER) || minor2 > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
141
146
|
throw new RangeError("Rounded amount must be a safe integer");
|
|
142
147
|
}
|
|
@@ -177,7 +182,38 @@ function computeOrderTax(lines, pricesIncludeTax) {
|
|
|
177
182
|
lineTaxMicros
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
|
-
function
|
|
185
|
+
function roundedTaxByRate(lines, rounding) {
|
|
186
|
+
if (rounding?.granularity !== "per_line_items" && rounding?.granularity !== "per_rate_group_items") return void 0;
|
|
187
|
+
if (rounding.granularity === "per_rate_group_items" && lines.some((line) => line.taxInclusive)) return void 0;
|
|
188
|
+
const { granularity, mode } = rounding;
|
|
189
|
+
const rates = /* @__PURE__ */ new Map();
|
|
190
|
+
let baseMinor = 0n;
|
|
191
|
+
const items = lines.flatMap((line) => (line.discountMinor ?? 0) > 0 ? [{ ...line, netMinor: line.netMinor + line.discountMinor }, { ...line, netMinor: -line.discountMinor }] : [line]);
|
|
192
|
+
for (const line of items) {
|
|
193
|
+
const net = BigInt(line.netMinor);
|
|
194
|
+
const ratePpm = BigInt(line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0));
|
|
195
|
+
const base = line.taxInclusive ? roundRatio(net * MICROS_PER_MINOR, MICROS_PER_MINOR + ratePpm, mode) : net;
|
|
196
|
+
const lineTax = line.taxInclusive ? net - base : roundRatio(net * ratePpm, MICROS_PER_MINOR, mode);
|
|
197
|
+
let left = lineTax;
|
|
198
|
+
baseMinor += base;
|
|
199
|
+
line.taxLines.forEach((tax, index) => {
|
|
200
|
+
const key = JSON.stringify([tax.code ?? "", tax.ratePpm]);
|
|
201
|
+
const row = rates.get(key) ?? { code: tax.code, ratePpm: tax.ratePpm, netMinor: 0n, amountMinor: 0n };
|
|
202
|
+
const share = index === line.taxLines.length - 1 ? left : ratePpm === 0n ? 0n : roundRatio(lineTax * BigInt(tax.ratePpm), ratePpm, mode);
|
|
203
|
+
left -= share;
|
|
204
|
+
rates.set(key, { ...row, netMinor: row.netMinor + base, amountMinor: row.amountMinor + share });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const rows = [...rates.values()].map((row) => ({ ...row, netMinor: Number(row.netMinor), amountMinor: Number(
|
|
208
|
+
// per_rate_group_items: round(Σ net × r) once per group, and the order's tax is their sum (:35-49, :56).
|
|
209
|
+
granularity === "per_rate_group_items" ? roundRatio(row.netMinor * BigInt(row.ratePpm), MICROS_PER_MINOR, mode) : row.amountMinor
|
|
210
|
+
) }));
|
|
211
|
+
return { baseMinor: Number(baseMinor), taxMinor: rows.reduce((sum, row) => sum + row.amountMinor, 0), rates: rows };
|
|
212
|
+
}
|
|
213
|
+
function taxLinesByRate(lines, orderTaxMinor, taxLabels, rounding) {
|
|
214
|
+
const label = (ratePpm) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 1e4}%`;
|
|
215
|
+
const rounded = roundedTaxByRate(lines, rounding);
|
|
216
|
+
if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
|
|
181
217
|
const byRate = /* @__PURE__ */ new Map();
|
|
182
218
|
for (const line of lines) {
|
|
183
219
|
const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
|
|
@@ -196,7 +232,7 @@ function taxLinesByRate(lines, orderTaxMinor, taxLabels) {
|
|
|
196
232
|
const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
|
|
197
233
|
const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
|
|
198
234
|
return {
|
|
199
|
-
line: { label:
|
|
235
|
+
line: { label: label(ratePpm), code, ratePpm, netMinor, amountMinor: Number(floor) },
|
|
200
236
|
remainder: micros - floor * MICROS_PER_MINOR
|
|
201
237
|
};
|
|
202
238
|
});
|
|
@@ -209,19 +245,34 @@ function taxLinesByRate(lines, orderTaxMinor, taxLabels) {
|
|
|
209
245
|
// src/tax/tax-provider.tsx
|
|
210
246
|
import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
|
|
211
247
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
248
|
+
var taxLogger = createLogger("tax");
|
|
212
249
|
var TaxCtx = createContext2(null);
|
|
213
|
-
function TaxProvider({ ratesPpm, pricesIncludeTax, children }) {
|
|
250
|
+
function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }) {
|
|
214
251
|
const value = useMemo2(
|
|
215
252
|
() => {
|
|
216
253
|
if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
|
|
217
254
|
throw new RangeError("TaxProvider: rates must be integer ppm");
|
|
218
255
|
}
|
|
256
|
+
const unknown = /* @__PURE__ */ new Set();
|
|
219
257
|
return {
|
|
220
|
-
getTaxRatePpm: (taxClass) =>
|
|
221
|
-
|
|
258
|
+
getTaxRatePpm: (taxClass) => {
|
|
259
|
+
if (taxClass !== void 0 && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
|
|
260
|
+
if (taxClass !== void 0 && !unknown.has(taxClass)) {
|
|
261
|
+
unknown.add(taxClass);
|
|
262
|
+
taxLogger.warn("No tax rate for this tax class; using the default rate", { taxClass });
|
|
263
|
+
}
|
|
264
|
+
return ratesPpm.default ?? 0;
|
|
265
|
+
},
|
|
266
|
+
// The name of the rate getTaxRatePpm picks: the class's own, else the default's.
|
|
267
|
+
getTaxRateCode: (taxClass) => {
|
|
268
|
+
const key = taxClass !== void 0 && Object.hasOwn(ratesPpm, taxClass) ? taxClass : "default";
|
|
269
|
+
return rateCodes && Object.hasOwn(rateCodes, key) ? rateCodes[key] : void 0;
|
|
270
|
+
},
|
|
271
|
+
pricesIncludeTax,
|
|
272
|
+
...rounding ? { rounding } : {}
|
|
222
273
|
};
|
|
223
274
|
},
|
|
224
|
-
[ratesPpm, pricesIncludeTax]
|
|
275
|
+
[ratesPpm, pricesIncludeTax, rounding, rateCodes]
|
|
225
276
|
);
|
|
226
277
|
return /* @__PURE__ */ jsx2(TaxCtx.Provider, { value, children });
|
|
227
278
|
}
|
|
@@ -267,29 +318,38 @@ function withPricingContext(context, settings) {
|
|
|
267
318
|
return settings.pricingContext ? { ...rest, pricingContext: settings.pricingContext } : rest;
|
|
268
319
|
}
|
|
269
320
|
function taxProviderProps(settings) {
|
|
270
|
-
|
|
321
|
+
const rounding = settings.taxRounding?.granularity === "custom" ? void 0 : settings.taxRounding;
|
|
322
|
+
return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...rounding && { rounding } };
|
|
271
323
|
}
|
|
272
324
|
|
|
273
325
|
// src/store-settings/use-store-settings.ts
|
|
274
326
|
import { useCallback as useCallback2, useEffect, useRef, useState } from "react";
|
|
275
327
|
var LOADING = { state: "loading" };
|
|
328
|
+
function readTaxRounding({ connector, context }) {
|
|
329
|
+
if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
|
|
330
|
+
if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(void 0);
|
|
331
|
+
const read = connector.capabilities;
|
|
332
|
+
return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => void 0);
|
|
333
|
+
}
|
|
276
334
|
function useStoreSettings(options) {
|
|
277
335
|
const { connector, context } = options;
|
|
278
336
|
const optionsRef = useRef(options);
|
|
279
337
|
optionsRef.current = options;
|
|
280
338
|
const requestRef = useRef(0);
|
|
281
339
|
const [state, setState] = useState(LOADING);
|
|
282
|
-
const resolve = useCallback2((choice, from) => {
|
|
340
|
+
const resolve = useCallback2((choice, from, known) => {
|
|
283
341
|
if (from !== void 0 && from !== requestRef.current) return;
|
|
284
342
|
const request = ++requestRef.current;
|
|
285
343
|
const current = () => request === requestRef.current;
|
|
286
344
|
setState(LOADING);
|
|
287
|
-
|
|
288
|
-
|
|
345
|
+
const rounding = known ?? readTaxRounding(optionsRef.current);
|
|
346
|
+
Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
|
|
347
|
+
([result, taxRounding]) => {
|
|
289
348
|
if (!current()) return;
|
|
290
|
-
if (result.status === "ready")
|
|
349
|
+
if (result.status === "ready")
|
|
350
|
+
setState({ state: "ready", settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
|
|
291
351
|
else if (result.status === "choose")
|
|
292
|
-
setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
|
|
352
|
+
setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request, rounding) });
|
|
293
353
|
else setState({ state: "unsupported" });
|
|
294
354
|
},
|
|
295
355
|
// A retry keeps the choice that failed: a new pick is saved only once it resolves.
|
|
@@ -397,6 +457,7 @@ function allocateOrderDiscount(lineAmountsMinor, totalMinor) {
|
|
|
397
457
|
}
|
|
398
458
|
|
|
399
459
|
// src/order/order-builder.ts
|
|
460
|
+
var perRateGroupWarned = /* @__PURE__ */ new WeakSet();
|
|
400
461
|
var nextId = 0;
|
|
401
462
|
function uid() {
|
|
402
463
|
return `${Date.now()}-${++nextId}`;
|
|
@@ -404,12 +465,15 @@ function uid() {
|
|
|
404
465
|
function roundHalfAway(n) {
|
|
405
466
|
return Math.sign(n) * Math.round(Math.abs(n));
|
|
406
467
|
}
|
|
407
|
-
function sumLines(lines) {
|
|
468
|
+
function sumLines(lines, rounding) {
|
|
469
|
+
const rounded = roundedTaxByRate(lines, rounding);
|
|
470
|
+
if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
|
|
471
|
+
const mode = rounding?.granularity === "per_order" ? rounding.mode : void 0;
|
|
408
472
|
const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
|
|
409
473
|
const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
|
|
410
474
|
const exclusiveTaxMicros = lines.reduce((sum, li) => li.taxInclusive ? sum : sum + BigInt(li.taxMicros), 0n);
|
|
411
|
-
const taxMinor = roundMicrosToMinor(lineTaxMicros);
|
|
412
|
-
const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros);
|
|
475
|
+
const taxMinor = roundMicrosToMinor(lineTaxMicros, mode);
|
|
476
|
+
const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros, mode);
|
|
413
477
|
return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
|
|
414
478
|
}
|
|
415
479
|
function toDisplayMode(line, amountMinor, displayInclusive) {
|
|
@@ -480,7 +544,11 @@ function createOrderBuilder(options) {
|
|
|
480
544
|
});
|
|
481
545
|
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
482
546
|
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
483
|
-
const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines);
|
|
547
|
+
const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
|
|
548
|
+
if (taxContext.rounding?.granularity === "per_rate_group_items" && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
|
|
549
|
+
perRateGroupWarned.add(taxContext);
|
|
550
|
+
taxLogger.warn("per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)");
|
|
551
|
+
}
|
|
484
552
|
const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
|
|
485
553
|
const taxInclusive = taxContext.pricesIncludeTax;
|
|
486
554
|
const figures = lines.map((li) => {
|
|
@@ -558,6 +626,7 @@ function createOrderBuilder(options) {
|
|
|
558
626
|
changeDueMinor,
|
|
559
627
|
currency,
|
|
560
628
|
pricesIncludeTax: taxContext.pricesIncludeTax,
|
|
629
|
+
...taxContext.rounding ? { taxRounding: taxContext.rounding } : {},
|
|
561
630
|
createdAt: now,
|
|
562
631
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
563
632
|
};
|
|
@@ -571,7 +640,8 @@ function createOrderBuilder(options) {
|
|
|
571
640
|
if (unitPrice.currency !== currency) throw new RangeError("Line currency must match " + currency);
|
|
572
641
|
if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
|
|
573
642
|
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
|
|
574
|
-
const
|
|
643
|
+
const code = input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
|
|
644
|
+
const taxRates = input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
575
645
|
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
576
646
|
const existing = lineItems.find(
|
|
577
647
|
(li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
|
|
@@ -629,7 +699,8 @@ function createOrderBuilder(options) {
|
|
|
629
699
|
sku: variant?.sku ?? traits.getSku(doc),
|
|
630
700
|
imageUrl: traits.getImageUrl(doc),
|
|
631
701
|
unitPrice,
|
|
632
|
-
quantity: opts?.quantity
|
|
702
|
+
quantity: opts?.quantity,
|
|
703
|
+
taxClass: traits.getTaxClass?.(doc, opts?.variantId)
|
|
633
704
|
});
|
|
634
705
|
},
|
|
635
706
|
updateQuantity(lineId, quantity) {
|
|
@@ -711,8 +782,127 @@ function createOrderBuilder(options) {
|
|
|
711
782
|
};
|
|
712
783
|
}
|
|
713
784
|
|
|
785
|
+
// src/order/tax-figures.ts
|
|
786
|
+
function taxFiguresForBasket(currency, pricesIncludeTax, lines, rounding) {
|
|
787
|
+
const code = currency.toUpperCase();
|
|
788
|
+
const builder = createOrderBuilder({ currency: code, taxContext: { getTaxRatePpm: () => 0, pricesIncludeTax, ...rounding ? { rounding } : {} } });
|
|
789
|
+
lines.forEach((line, index) => {
|
|
790
|
+
const lineId = builder.addLine({
|
|
791
|
+
productId: `line-${index}`,
|
|
792
|
+
name: `Line ${index}`,
|
|
793
|
+
quantity: line.quantity,
|
|
794
|
+
unitPrice: { amount: line.unitPriceMinor, currency: code, taxInclusive: line.taxInclusive },
|
|
795
|
+
taxRates: line.taxLines
|
|
796
|
+
});
|
|
797
|
+
if (line.discountMinor) builder.applyLineDiscount(lineId, { type: "fixed", value: line.discountMinor });
|
|
798
|
+
});
|
|
799
|
+
const { subtotalMinor, taxMinor, totalMinor, lineItems, taxRounding } = builder.getSnapshot();
|
|
800
|
+
return { subtotalMinor, taxMinor, totalMinor, taxByRate: taxLinesByRate(lineItems, taxMinor, void 0, taxRounding) };
|
|
801
|
+
}
|
|
802
|
+
|
|
714
803
|
// src/order/order-manager.ts
|
|
715
804
|
import { BehaviorSubject as BehaviorSubject2, map as map2 } from "rxjs";
|
|
805
|
+
|
|
806
|
+
// src/pos-order/command.ts
|
|
807
|
+
var UnsupportedOrderVersionError = class extends Error {
|
|
808
|
+
constructor(needed, supported) {
|
|
809
|
+
super(`This sale needs order.create version ${needed}; the server supports up to ${supported}.`);
|
|
810
|
+
this.needed = needed;
|
|
811
|
+
this.supported = supported;
|
|
812
|
+
this.name = "UnsupportedOrderVersionError";
|
|
813
|
+
}
|
|
814
|
+
code = "UNSUPPORTED_ORDER_VERSION";
|
|
815
|
+
};
|
|
816
|
+
var PAYLOAD_STRING_MAX = 255;
|
|
817
|
+
var sendable = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !value.includes("\0");
|
|
818
|
+
function customerRefusal(customer) {
|
|
819
|
+
return customer.email !== void 0 && customer.email !== "" && !sendable(customer.email, 254) ? CUSTOMER_REFUSALS[0] : customer.id !== void 0 && customer.id !== "" && !sendable(customer.id, 64) ? CUSTOMER_REFUSALS[1] : null;
|
|
820
|
+
}
|
|
821
|
+
var CUSTOMER_REFUSALS = ["email", "id"].map((field) => `This customer's ${field} can't be sent to the store, so they weren't added to the sale.`);
|
|
822
|
+
function cutText(text2, max) {
|
|
823
|
+
const clean = text2.replaceAll("\0", "");
|
|
824
|
+
if (clean.length <= max) return clean;
|
|
825
|
+
const high = clean.charCodeAt(max - 2);
|
|
826
|
+
return `${clean.slice(0, high >= 55296 && high <= 56319 ? max - 2 : max - 1)}\u2026`;
|
|
827
|
+
}
|
|
828
|
+
function netDiscountMinor(line, pricesIncludeTax) {
|
|
829
|
+
if (!(line.taxInclusive ?? pricesIncludeTax) || line.discountMinor === 0) return line.discountMinor;
|
|
830
|
+
const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
831
|
+
const taxAfter = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
|
|
832
|
+
const taxBefore = taxMicros(line.unitPriceMinor * line.quantity, ratePpm, true);
|
|
833
|
+
return roundMicrosToMinor(BigInt(line.discountMinor) * MICROS_PER_MINOR - taxBefore + taxAfter);
|
|
834
|
+
}
|
|
835
|
+
function contentVersion(order) {
|
|
836
|
+
return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
|
|
837
|
+
}
|
|
838
|
+
function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
|
|
839
|
+
const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
840
|
+
const content = contentVersion(order);
|
|
841
|
+
const cap = options?.maxVersion === void 0 ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
|
|
842
|
+
if (grossMinor > 0 && cap !== void 0 && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
|
|
843
|
+
const version = content === 3 && cap !== void 0 && cap >= 4 ? 4 : Math.min(content, cap ?? content);
|
|
844
|
+
const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
|
|
845
|
+
const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
|
|
846
|
+
const email = order.customer?.email;
|
|
847
|
+
const id2 = order.customer?.id;
|
|
848
|
+
const customerId = typeof id2 === "string" && id2.length > 0 && id2.length <= 64 ? id2 : void 0;
|
|
849
|
+
const sessionId = order.sessionId ?? order.lateSessionId;
|
|
850
|
+
return {
|
|
851
|
+
id: order.commandId,
|
|
852
|
+
type: "order.create",
|
|
853
|
+
version,
|
|
854
|
+
createdAt: order.createdAt,
|
|
855
|
+
deviceId,
|
|
856
|
+
attempt: attempt2,
|
|
857
|
+
payload: {
|
|
858
|
+
clientOrderId: order.id,
|
|
859
|
+
createdAt: order.createdAt,
|
|
860
|
+
currency: order.currency,
|
|
861
|
+
pricesIncludeTax: order.pricesIncludeTax,
|
|
862
|
+
lines: order.lines.map((line, i) => ({
|
|
863
|
+
clientLineId: line.id,
|
|
864
|
+
variantId: line.variantId ?? line.productId,
|
|
865
|
+
title: line.name,
|
|
866
|
+
quantity: line.quantity,
|
|
867
|
+
unitPriceMinor: line.unitPriceMinor,
|
|
868
|
+
...line.taxInclusive !== void 0 ? { taxInclusive: line.taxInclusive } : {},
|
|
869
|
+
...lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}
|
|
870
|
+
})),
|
|
871
|
+
payments: order.payments.map((payment) => ({
|
|
872
|
+
clientPaymentId: payment.id,
|
|
873
|
+
method: payment.method,
|
|
874
|
+
amountMinor: payment.amountMinor,
|
|
875
|
+
...payment.tenderedMinor !== void 0 ? { tenderedMinor: payment.tenderedMinor } : {},
|
|
876
|
+
...payment.changeMinor !== void 0 ? { changeMinor: payment.changeMinor } : {},
|
|
877
|
+
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
878
|
+
})),
|
|
879
|
+
subtotalMinor: order.subtotalMinor,
|
|
880
|
+
...discountMinor > 0 ? { discountMinor } : {},
|
|
881
|
+
taxMinor: order.taxMinor,
|
|
882
|
+
totalMinor: order.totalMinor,
|
|
883
|
+
...version >= 3 ? {
|
|
884
|
+
display: { ...order.display, lines: order.display.lines.map(({ lineId, amountMinor, discounts }) => ({
|
|
885
|
+
clientLineId: lineId,
|
|
886
|
+
amountMinor,
|
|
887
|
+
discounts
|
|
888
|
+
})) },
|
|
889
|
+
taxByRate: order.taxByRate.map(({ ratePpm, code, netMinor, amountMinor, grossMinor: grossMinor2 }) => ({
|
|
890
|
+
ratePpm,
|
|
891
|
+
...code !== void 0 ? { code } : {},
|
|
892
|
+
netMinor,
|
|
893
|
+
taxMinor: amountMinor,
|
|
894
|
+
grossMinor: grossMinor2
|
|
895
|
+
}))
|
|
896
|
+
} : {},
|
|
897
|
+
customer: version >= 3 ? email || customerId ? { ...email ? { email } : {}, ...customerId ? { customerId } : {} } : null : email ? { email } : null,
|
|
898
|
+
...order.registerId !== void 0 ? { registerId: order.registerId } : {},
|
|
899
|
+
...order.cashierRef !== void 0 ? { cashierRef: order.cashierRef } : {},
|
|
900
|
+
...version >= 3 && typeof sessionId === "string" && sessionId.length > 0 && sessionId.length <= 36 ? { sessionId } : {}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/order/order-manager.ts
|
|
716
906
|
function createOrderManager(options) {
|
|
717
907
|
const { taxContext, draftsCollection } = options;
|
|
718
908
|
const currency = options.currency.toUpperCase();
|
|
@@ -765,7 +955,7 @@ function createOrderManager(options) {
|
|
|
765
955
|
taxContext,
|
|
766
956
|
id: savedOrder.id
|
|
767
957
|
});
|
|
768
|
-
if (savedOrder.customer) {
|
|
958
|
+
if (savedOrder.customer && !customerRefusal(savedOrder.customer)) {
|
|
769
959
|
builder.setCustomer(savedOrder.customer);
|
|
770
960
|
}
|
|
771
961
|
if (savedOrder.note) {
|
|
@@ -822,7 +1012,7 @@ function createOrderManager(options) {
|
|
|
822
1012
|
|
|
823
1013
|
// src/receipt/build-receipt-data.ts
|
|
824
1014
|
function buildReceiptData(order, config) {
|
|
825
|
-
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
|
|
1015
|
+
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
|
|
826
1016
|
({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
|
|
827
1017
|
);
|
|
828
1018
|
let taxSoFar = order.lineItems.reduce(
|
|
@@ -836,6 +1026,7 @@ function buildReceiptData(order, config) {
|
|
|
836
1026
|
return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
|
|
837
1027
|
});
|
|
838
1028
|
const label = (d) => d.label ?? d.couponCode ?? `${d.type} discount`;
|
|
1029
|
+
const customer = order.customer?.name || order.customer?.email;
|
|
839
1030
|
return {
|
|
840
1031
|
header: {
|
|
841
1032
|
storeName: config.storeName,
|
|
@@ -843,6 +1034,7 @@ function buildReceiptData(order, config) {
|
|
|
843
1034
|
orderNumber: order.id,
|
|
844
1035
|
date: order.createdAt,
|
|
845
1036
|
cashier: config.cashier,
|
|
1037
|
+
...customer ? { customer } : {},
|
|
846
1038
|
register: config.register
|
|
847
1039
|
},
|
|
848
1040
|
lineItems: order.lineItems.map((li, index) => ({
|
|
@@ -887,7 +1079,11 @@ function buildReceiptData(order, config) {
|
|
|
887
1079
|
function searchProducts(docs, term, traits) {
|
|
888
1080
|
const query = term.trim().toLowerCase();
|
|
889
1081
|
if (!query) return docs;
|
|
890
|
-
const codes = (doc) => [
|
|
1082
|
+
const codes = (doc) => [...new Set([
|
|
1083
|
+
traits.getSku(doc),
|
|
1084
|
+
traits.getBarcode(doc),
|
|
1085
|
+
...(traits.getVariants?.(doc) ?? []).flatMap((variant) => [variant.sku, variant.barcode])
|
|
1086
|
+
].filter((code) => Boolean(code)).map((code) => code.toLowerCase()))];
|
|
891
1087
|
const exact = docs.filter((doc) => codes(doc).includes(query));
|
|
892
1088
|
if (exact.length) return exact;
|
|
893
1089
|
const words = query.split(/\s+/);
|
|
@@ -913,7 +1109,7 @@ function stockOverlayAsOf$(collection) {
|
|
|
913
1109
|
}
|
|
914
1110
|
|
|
915
1111
|
// src/sale/use-sale.ts
|
|
916
|
-
import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
1112
|
+
import { useEffect as useEffect3, useLayoutEffect, useRef as useRef3, useState as useState3 } from "react";
|
|
917
1113
|
|
|
918
1114
|
// src/pos-order/uuidv7.ts
|
|
919
1115
|
function format(ms, bytes) {
|
|
@@ -965,6 +1161,72 @@ function uuidv7(now, randomBytes) {
|
|
|
965
1161
|
}
|
|
966
1162
|
|
|
967
1163
|
// src/pos-order/finalize.ts
|
|
1164
|
+
import { minorUnitDigits } from "@tallyui/core";
|
|
1165
|
+
|
|
1166
|
+
// src/outbox/logger.ts
|
|
1167
|
+
var outboxLogger = createLogger("outbox");
|
|
1168
|
+
|
|
1169
|
+
// src/pos-order/finalize.ts
|
|
1170
|
+
var MESSAGE_NAME_MAX = 61;
|
|
1171
|
+
function withSentForm(order, posOrder) {
|
|
1172
|
+
const customer = order.customer && { ...order.customer };
|
|
1173
|
+
if (customer && posOrder.customer?.email === void 0) delete customer.email;
|
|
1174
|
+
if (customer && posOrder.customer?.id === void 0) delete customer.id;
|
|
1175
|
+
return {
|
|
1176
|
+
...order,
|
|
1177
|
+
customer,
|
|
1178
|
+
lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
|
|
1179
|
+
display: posOrder.display ? {
|
|
1180
|
+
...order.display,
|
|
1181
|
+
lines: order.display.lines.map((line, i) => ({
|
|
1182
|
+
...line,
|
|
1183
|
+
discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display.lines[i].discounts[j].label }))
|
|
1184
|
+
}))
|
|
1185
|
+
} : order.display
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
function freezeSentForm(order) {
|
|
1189
|
+
let changed = false;
|
|
1190
|
+
const lines = order.lines.map((line) => {
|
|
1191
|
+
const name = cutText(line.name, PAYLOAD_STRING_MAX);
|
|
1192
|
+
changed ||= name !== line.name;
|
|
1193
|
+
return { ...line, name };
|
|
1194
|
+
});
|
|
1195
|
+
const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({
|
|
1196
|
+
...line,
|
|
1197
|
+
discounts: line.discounts.map((discount) => {
|
|
1198
|
+
if (discount.label === void 0) return discount;
|
|
1199
|
+
const label = cutText(discount.label, PAYLOAD_STRING_MAX);
|
|
1200
|
+
changed ||= label !== discount.label;
|
|
1201
|
+
return { ...discount, label };
|
|
1202
|
+
})
|
|
1203
|
+
})) };
|
|
1204
|
+
const payments = order.payments.map((payment) => {
|
|
1205
|
+
if (payment.reference === void 0) return payment;
|
|
1206
|
+
const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
|
|
1207
|
+
changed ||= reference !== payment.reference;
|
|
1208
|
+
return { ...payment, reference };
|
|
1209
|
+
});
|
|
1210
|
+
const customer = order.customer && { ...order.customer };
|
|
1211
|
+
const localWarnings = [...order.localWarnings ?? []];
|
|
1212
|
+
for (const [field, max] of [["email", 254], ["id", 64]]) {
|
|
1213
|
+
if (customer && field in customer && !sendable(customer[field], max)) {
|
|
1214
|
+
delete customer[field];
|
|
1215
|
+
changed = true;
|
|
1216
|
+
if (!localWarnings.some((warning) => warning.code === "customer_omitted" && warning.field === field)) localWarnings.push({ code: "customer_omitted", field });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return changed ? { ...order, lines, payments, customer, ...display ? { display } : {}, ...localWarnings.length ? { localWarnings } : {} } : order;
|
|
1220
|
+
}
|
|
1221
|
+
function referenceError(label, value) {
|
|
1222
|
+
const reason = referenceReason(value);
|
|
1223
|
+
return reason === "long" ? `${label} is too long (max ${PAYLOAD_STRING_MAX} characters)` : reason === "nul" ? `${label} contains a NUL character` : null;
|
|
1224
|
+
}
|
|
1225
|
+
function referenceReason(value) {
|
|
1226
|
+
if (value === void 0) return null;
|
|
1227
|
+
if (value.length > PAYLOAD_STRING_MAX) return "long";
|
|
1228
|
+
return value.includes("\0") ? "nul" : null;
|
|
1229
|
+
}
|
|
968
1230
|
function finalizeOrder(order, options = {}) {
|
|
969
1231
|
if (!order.lineItems.length) throw new Error("finalize: no lines");
|
|
970
1232
|
if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
|
|
@@ -983,6 +1245,24 @@ function finalizeOrder(order, options = {}) {
|
|
|
983
1245
|
let change = order.paidMinor - order.totalMinor;
|
|
984
1246
|
const cash = order.payments.reduce((sum, p) => sum + (p.method === "cash" ? p.amountMinor : 0), 0);
|
|
985
1247
|
if (change > cash) throw new Error("finalize: change exceeds cash");
|
|
1248
|
+
const v3 = (options.capabilities?.orderCreate ?? 1) >= 3;
|
|
1249
|
+
const references = [
|
|
1250
|
+
["registerId", options.registerId],
|
|
1251
|
+
["cashierRef", options.cashierRef],
|
|
1252
|
+
...order.lineItems.flatMap((line, i) => {
|
|
1253
|
+
const name = `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
|
|
1254
|
+
return [
|
|
1255
|
+
line.variantId !== void 0 ? [`${name}: the variant id`, line.variantId] : [`${name}: the product id`, line.productId],
|
|
1256
|
+
...(v3 ? order.display.lines[i]?.discounts ?? [] : []).map((d) => [`${name}: the discount id`, d.discountId]),
|
|
1257
|
+
...(v3 ? line.taxLines : []).map((tax) => [`${name}: the tax code`, tax.code])
|
|
1258
|
+
];
|
|
1259
|
+
}),
|
|
1260
|
+
...order.payments.map((payment) => [`the ${payment.method} payment's reference`, payment.reference])
|
|
1261
|
+
];
|
|
1262
|
+
for (const [field, value] of references) {
|
|
1263
|
+
const message = referenceError(field, value);
|
|
1264
|
+
if (message) throw new Error(`finalize: ${message}`);
|
|
1265
|
+
}
|
|
986
1266
|
const newId = options.newId ?? uuidv7;
|
|
987
1267
|
const id2 = newId();
|
|
988
1268
|
const lines = order.lineItems.map((line) => ({
|
|
@@ -1004,6 +1284,18 @@ function finalizeOrder(order, options = {}) {
|
|
|
1004
1284
|
amountMinor: payment.amountMinor,
|
|
1005
1285
|
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
1006
1286
|
}));
|
|
1287
|
+
const localWarnings = options.localWarnings?.flatMap((warning) => {
|
|
1288
|
+
if (warning.code !== "payment_reference_dropped") return [{ ...warning }];
|
|
1289
|
+
const index = order.payments.findIndex((payment) => payment.id === warning.paymentId);
|
|
1290
|
+
if (index === -1) {
|
|
1291
|
+
try {
|
|
1292
|
+
outboxLogger.warn("finalize: dropped a localWarnings entry naming an unknown payment", { orderId: id2, paymentId: warning.paymentId });
|
|
1293
|
+
} catch {
|
|
1294
|
+
}
|
|
1295
|
+
return [];
|
|
1296
|
+
}
|
|
1297
|
+
return [{ ...warning, paymentId: payments[index].id }];
|
|
1298
|
+
});
|
|
1007
1299
|
for (let i = payments.length - 1; i >= 0; i--) {
|
|
1008
1300
|
const payment = payments[i];
|
|
1009
1301
|
if (payment.method !== "cash") continue;
|
|
@@ -1015,8 +1307,44 @@ function finalizeOrder(order, options = {}) {
|
|
|
1015
1307
|
if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
|
|
1016
1308
|
throw new Error("finalize: payments do not reconcile");
|
|
1017
1309
|
}
|
|
1310
|
+
let display;
|
|
1311
|
+
let taxByRate;
|
|
1312
|
+
if ((options.capabilities?.orderCreate ?? 1) >= 3) {
|
|
1313
|
+
if (order.display.lines.length !== order.lineItems.length || order.display.lines.some((line, i) => line.lineId !== order.lineItems[i].id)) {
|
|
1314
|
+
throw new Error("finalize: display lines do not match the order lines");
|
|
1315
|
+
}
|
|
1316
|
+
display = {
|
|
1317
|
+
currency: order.currency,
|
|
1318
|
+
exponent: minorUnitDigits(order.currency),
|
|
1319
|
+
...order.display,
|
|
1320
|
+
lines: order.display.lines.map((line, i) => ({
|
|
1321
|
+
lineId: lines[i].id,
|
|
1322
|
+
amountMinor: line.amountMinor,
|
|
1323
|
+
discounts: line.discounts.map(({ discountId, label, amountMinor }) => ({
|
|
1324
|
+
discountId,
|
|
1325
|
+
...label !== void 0 ? { label } : {},
|
|
1326
|
+
amountMinor
|
|
1327
|
+
}))
|
|
1328
|
+
}))
|
|
1329
|
+
};
|
|
1330
|
+
taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, void 0, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
|
|
1331
|
+
ratePpm,
|
|
1332
|
+
...code !== void 0 ? { code } : {},
|
|
1333
|
+
netMinor,
|
|
1334
|
+
amountMinor,
|
|
1335
|
+
grossMinor: netMinor + amountMinor
|
|
1336
|
+
}));
|
|
1337
|
+
if (display.totalMinor !== order.totalMinor || display.taxMinor !== order.taxMinor || display.taxInclusive !== order.pricesIncludeTax) {
|
|
1338
|
+
throw new Error("finalize: display does not match the order");
|
|
1339
|
+
}
|
|
1340
|
+
if (taxByRate.reduce((sum, rate) => sum + rate.amountMinor, 0) !== order.taxMinor) {
|
|
1341
|
+
throw new Error("finalize: tax by rate does not sum to the order tax");
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
|
|
1345
|
+
const taxRounding = rounding.granularity === "custom" ? { granularity: "custom" } : { granularity: rounding.granularity, mode: rounding.mode };
|
|
1018
1346
|
const now = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1019
|
-
return {
|
|
1347
|
+
return freezeSentForm({
|
|
1020
1348
|
id: id2,
|
|
1021
1349
|
createdAt: now,
|
|
1022
1350
|
updatedAt: now,
|
|
@@ -1030,6 +1358,8 @@ function finalizeOrder(order, options = {}) {
|
|
|
1030
1358
|
discountMinor: order.discountMinor,
|
|
1031
1359
|
taxMinor: order.taxMinor,
|
|
1032
1360
|
totalMinor: order.totalMinor,
|
|
1361
|
+
taxRounding,
|
|
1362
|
+
...display && taxByRate ? { display, taxByRate } : {},
|
|
1033
1363
|
customer: order.customer ? {
|
|
1034
1364
|
id: order.customer.id,
|
|
1035
1365
|
name: order.customer.name,
|
|
@@ -1037,58 +1367,16 @@ function finalizeOrder(order, options = {}) {
|
|
|
1037
1367
|
} : null,
|
|
1038
1368
|
...order.note ? { note: order.note } : {},
|
|
1039
1369
|
...options.registerId !== void 0 ? { registerId: options.registerId } : {},
|
|
1040
|
-
...options.cashierRef !== void 0 ? { cashierRef: options.cashierRef } : {}
|
|
1041
|
-
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// src/pos-order/command.ts
|
|
1045
|
-
function toOrderCreateEnvelope(order, deviceId, attempt2 = 1) {
|
|
1046
|
-
const discountMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
1047
|
-
return {
|
|
1048
|
-
id: order.commandId,
|
|
1049
|
-
type: "order.create",
|
|
1050
|
-
version: discountMinor > 0 ? 2 : 1,
|
|
1051
|
-
createdAt: order.createdAt,
|
|
1052
|
-
deviceId,
|
|
1053
|
-
attempt: attempt2,
|
|
1054
|
-
payload: {
|
|
1055
|
-
clientOrderId: order.id,
|
|
1056
|
-
createdAt: order.createdAt,
|
|
1057
|
-
currency: order.currency,
|
|
1058
|
-
pricesIncludeTax: order.pricesIncludeTax,
|
|
1059
|
-
lines: order.lines.map((line) => ({
|
|
1060
|
-
clientLineId: line.id,
|
|
1061
|
-
variantId: line.variantId ?? line.productId,
|
|
1062
|
-
title: line.name,
|
|
1063
|
-
quantity: line.quantity,
|
|
1064
|
-
unitPriceMinor: line.unitPriceMinor,
|
|
1065
|
-
...line.taxInclusive !== void 0 ? { taxInclusive: line.taxInclusive } : {},
|
|
1066
|
-
...line.discountMinor > 0 ? { discountMinor: line.discountMinor } : {}
|
|
1067
|
-
})),
|
|
1068
|
-
payments: order.payments.map((payment) => ({
|
|
1069
|
-
clientPaymentId: payment.id,
|
|
1070
|
-
method: payment.method,
|
|
1071
|
-
amountMinor: payment.amountMinor,
|
|
1072
|
-
...payment.tenderedMinor !== void 0 ? { tenderedMinor: payment.tenderedMinor } : {},
|
|
1073
|
-
...payment.changeMinor !== void 0 ? { changeMinor: payment.changeMinor } : {},
|
|
1074
|
-
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
1075
|
-
})),
|
|
1076
|
-
subtotalMinor: order.subtotalMinor,
|
|
1077
|
-
...discountMinor > 0 ? { discountMinor } : {},
|
|
1078
|
-
taxMinor: order.taxMinor,
|
|
1079
|
-
totalMinor: order.totalMinor,
|
|
1080
|
-
customer: order.customer?.email ? { email: order.customer.email } : null,
|
|
1081
|
-
...order.registerId !== void 0 ? { registerId: order.registerId } : {},
|
|
1082
|
-
...order.cashierRef !== void 0 ? { cashierRef: order.cashierRef } : {}
|
|
1083
|
-
}
|
|
1084
|
-
};
|
|
1370
|
+
...options.cashierRef !== void 0 ? { cashierRef: options.cashierRef } : {},
|
|
1371
|
+
...localWarnings?.length ? { localWarnings } : {}
|
|
1372
|
+
});
|
|
1085
1373
|
}
|
|
1086
1374
|
|
|
1087
1375
|
// src/pos-order/schema.ts
|
|
1088
1376
|
import { addRxPlugin } from "rxdb";
|
|
1089
1377
|
import { RxDBMigrationSchemaPlugin } from "rxdb/plugins/migration-schema";
|
|
1090
1378
|
var posOrderSchema = {
|
|
1091
|
-
version:
|
|
1379
|
+
version: 6,
|
|
1092
1380
|
primaryKey: "id",
|
|
1093
1381
|
type: "object",
|
|
1094
1382
|
additionalProperties: false,
|
|
@@ -1106,7 +1394,7 @@ var posOrderSchema = {
|
|
|
1106
1394
|
syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
|
|
1107
1395
|
note: { type: "string" },
|
|
1108
1396
|
registerId: { type: "string" },
|
|
1109
|
-
sessionId: { type: "string" },
|
|
1397
|
+
sessionId: { type: "string", maxLength: 36 },
|
|
1110
1398
|
cashierRef: { type: "string" },
|
|
1111
1399
|
lines: { type: "array", items: {
|
|
1112
1400
|
type: "object",
|
|
@@ -1153,7 +1441,23 @@ var posOrderSchema = {
|
|
|
1153
1441
|
additionalProperties: true
|
|
1154
1442
|
} },
|
|
1155
1443
|
error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" } }, required: ["code", "message"] },
|
|
1444
|
+
localWarnings: { type: "array", items: { type: "object", additionalProperties: false, properties: {
|
|
1445
|
+
code: { type: "string", maxLength: 64 },
|
|
1446
|
+
field: { type: "string", maxLength: 16 },
|
|
1447
|
+
paymentId: { type: "string", maxLength: 36 }
|
|
1448
|
+
}, required: ["code"] } },
|
|
1449
|
+
serverFailures: { type: "object", additionalProperties: false, properties: {
|
|
1450
|
+
since: { type: "integer", minimum: 0 },
|
|
1451
|
+
reason: { type: "string", maxLength: 64 },
|
|
1452
|
+
isolated: { type: "boolean" }
|
|
1453
|
+
}, required: ["since", "reason", "isolated"] },
|
|
1156
1454
|
lateSessionId: { type: "string" },
|
|
1455
|
+
taxRounding: { type: "object", additionalProperties: false, properties: {
|
|
1456
|
+
granularity: { type: "string", enum: ["per_order", "per_line_items", "per_rate_group_items", "custom"] },
|
|
1457
|
+
mode: { type: "string", enum: ["half_away_from_zero", "half_up"] }
|
|
1458
|
+
}, required: ["granularity"] },
|
|
1459
|
+
sentVersion: { type: "integer", minimum: 1, maximum: 4 },
|
|
1460
|
+
downgradedFrom: { type: "integer", minimum: 1, maximum: 4 },
|
|
1157
1461
|
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
1158
1462
|
display: { type: "object", additionalProperties: false, properties: {
|
|
1159
1463
|
currency: { type: "string" },
|
|
@@ -1208,14 +1512,26 @@ var posOrderSchema = {
|
|
|
1208
1512
|
"customer",
|
|
1209
1513
|
"syncStatus",
|
|
1210
1514
|
"commandId",
|
|
1211
|
-
"updatedAt"
|
|
1515
|
+
"updatedAt",
|
|
1516
|
+
"taxRounding"
|
|
1212
1517
|
],
|
|
1213
|
-
indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"]]
|
|
1518
|
+
indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"], "sessionId"]
|
|
1214
1519
|
};
|
|
1215
1520
|
function posOrderCollection() {
|
|
1216
1521
|
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
1217
1522
|
const identity2 = (doc) => doc;
|
|
1218
|
-
|
|
1523
|
+
const recordSent = (doc) => {
|
|
1524
|
+
doc.sentVersion ??= contentVersion(doc);
|
|
1525
|
+
return doc;
|
|
1526
|
+
};
|
|
1527
|
+
const recordRounding = (doc) => {
|
|
1528
|
+
doc.taxRounding ??= { ...DEFAULT_TAX_ROUNDING };
|
|
1529
|
+
return doc;
|
|
1530
|
+
};
|
|
1531
|
+
return {
|
|
1532
|
+
schema: posOrderSchema,
|
|
1533
|
+
migrationStrategies: { 1: identity2, 2: identity2, 3: identity2, 4: identity2, 5: recordSent, 6: recordRounding }
|
|
1534
|
+
};
|
|
1219
1535
|
}
|
|
1220
1536
|
|
|
1221
1537
|
// src/pos-order/open.ts
|
|
@@ -1253,9 +1569,10 @@ function waitWithTimeout(promise, ms) {
|
|
|
1253
1569
|
});
|
|
1254
1570
|
});
|
|
1255
1571
|
}
|
|
1256
|
-
async function writeOverStaleCopies(collection, from, to) {
|
|
1572
|
+
async function writeOverStaleCopies(collection, from, to, stopIfClosing) {
|
|
1257
1573
|
const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
|
|
1258
1574
|
for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0; page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
|
|
1575
|
+
stopIfClosing();
|
|
1259
1576
|
const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
|
|
1260
1577
|
const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) => ({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
|
|
1261
1578
|
const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
|
|
@@ -1276,7 +1593,8 @@ async function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE
|
|
|
1276
1593
|
return opening;
|
|
1277
1594
|
}
|
|
1278
1595
|
async function openPosOrders(db, previous) {
|
|
1279
|
-
|
|
1596
|
+
let cancelled = false;
|
|
1597
|
+
const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
|
|
1280
1598
|
const stopIfClosing = () => {
|
|
1281
1599
|
if (closing()) throw new PosOrderOpenClosedError(db.name);
|
|
1282
1600
|
};
|
|
@@ -1286,7 +1604,11 @@ async function openPosOrders(db, previous) {
|
|
|
1286
1604
|
stopIfClosing();
|
|
1287
1605
|
const mustMigrate = await state.mustMigrate;
|
|
1288
1606
|
stopIfClosing();
|
|
1289
|
-
if (!mustMigrate)
|
|
1607
|
+
if (!mustMigrate) {
|
|
1608
|
+
await state.startMigration();
|
|
1609
|
+
stopIfClosing();
|
|
1610
|
+
return collection;
|
|
1611
|
+
}
|
|
1290
1612
|
await previous;
|
|
1291
1613
|
stopIfClosing();
|
|
1292
1614
|
await state.updateStatus((status2) => {
|
|
@@ -1310,9 +1632,18 @@ async function openPosOrders(db, previous) {
|
|
|
1310
1632
|
await (closing() ? checkpoint.close() : checkpoint.remove());
|
|
1311
1633
|
stopIfClosing();
|
|
1312
1634
|
const settled = new Subject();
|
|
1635
|
+
let inFlight = 0;
|
|
1636
|
+
let drained;
|
|
1637
|
+
const track = (result) => {
|
|
1638
|
+
if (!(result instanceof Promise)) return result;
|
|
1639
|
+
inFlight++;
|
|
1640
|
+
return result.finally(() => {
|
|
1641
|
+
if (--inFlight === 0) drained?.();
|
|
1642
|
+
});
|
|
1643
|
+
};
|
|
1313
1644
|
const gate = (target, closed) => new Proxy(target, { get: (t, key) => {
|
|
1314
1645
|
const value = Reflect.get(t, key);
|
|
1315
|
-
return typeof value !== "function" ? value : (...args) => closing() && (key === "bulkWrite" || key === "findDocumentsById") ? closed(key, args[0]) : value.apply(t, args);
|
|
1646
|
+
return typeof value !== "function" ? value : (...args) => closing() && (key === "bulkWrite" || key === "findDocumentsById") ? closed(key, args[0]) : track(value.apply(t, args));
|
|
1316
1647
|
} });
|
|
1317
1648
|
const internalStore = gate(db.internalStore, async (key, first) => {
|
|
1318
1649
|
const ids = key === "bulkWrite" ? first.map((row) => row.document.id) : first;
|
|
@@ -1325,14 +1656,33 @@ async function openPosOrders(db, previous) {
|
|
|
1325
1656
|
state.database = new Proxy(db, { get: (target, key) => key === "internalStore" ? internalStore : Reflect.get(target, key) });
|
|
1326
1657
|
const migrateStorage = state.migrateStorage.bind(state);
|
|
1327
1658
|
state.migrateStorage = async (from, current, batchSize) => {
|
|
1659
|
+
stopIfClosing();
|
|
1328
1660
|
const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
|
|
1329
|
-
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
|
|
1661
|
+
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
|
|
1662
|
+
stopIfClosing();
|
|
1330
1663
|
return migrateStorage(new Proxy(from, { get: (target, key) => {
|
|
1331
1664
|
const value = key === "changeStream" ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
|
|
1332
1665
|
return typeof value === "function" ? value.bind(target) : value;
|
|
1333
1666
|
} }), to, batchSize);
|
|
1334
1667
|
};
|
|
1335
|
-
|
|
1668
|
+
let running = true;
|
|
1669
|
+
let onCancel;
|
|
1670
|
+
const cancelledRun = new Promise((resolve) => {
|
|
1671
|
+
onCancel = resolve;
|
|
1672
|
+
});
|
|
1673
|
+
const cancel = state.cancel.bind(state);
|
|
1674
|
+
state.cancel = () => {
|
|
1675
|
+
if (running) {
|
|
1676
|
+
cancelled = true;
|
|
1677
|
+
if (inFlight === 0) onCancel();
|
|
1678
|
+
else drained = onCancel;
|
|
1679
|
+
}
|
|
1680
|
+
return cancel();
|
|
1681
|
+
};
|
|
1682
|
+
await Promise.race([state.startMigration(), cancelledRun]).finally(() => {
|
|
1683
|
+
running = false;
|
|
1684
|
+
settled.next();
|
|
1685
|
+
});
|
|
1336
1686
|
stopIfClosing();
|
|
1337
1687
|
const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data;
|
|
1338
1688
|
stopIfClosing();
|
|
@@ -1363,8 +1713,9 @@ function getDeviceId(storage, key) {
|
|
|
1363
1713
|
}
|
|
1364
1714
|
|
|
1365
1715
|
// src/pos-order/needs-attention.ts
|
|
1366
|
-
|
|
1367
|
-
|
|
1716
|
+
import { knownWarnings } from "@tallyui/core";
|
|
1717
|
+
function needsAttention(orders, options = {}) {
|
|
1718
|
+
return orders.filter((order) => order.syncStatus === "rejected" || (order.localWarnings?.length ?? 0) > 0 || order.syncStatus === "applied" && knownWarnings(order.warnings).length > 0 || order.lateSessionId !== void 0 || order.syncStatus === "pending" && options.stuckCommandIds?.includes(order.commandId) === true).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1368
1719
|
}
|
|
1369
1720
|
|
|
1370
1721
|
// src/pos-order/same-sale.ts
|
|
@@ -1586,6 +1937,7 @@ var closureSchema = {
|
|
|
1586
1937
|
// src/register/register-document.ts
|
|
1587
1938
|
import { map as map4 } from "rxjs";
|
|
1588
1939
|
var REGISTER = "register";
|
|
1940
|
+
var MAX_REGISTER_ID_LENGTH = 36;
|
|
1589
1941
|
function mintUuid() {
|
|
1590
1942
|
const id2 = globalThis.crypto?.randomUUID?.();
|
|
1591
1943
|
if (id2) return id2;
|
|
@@ -1637,7 +1989,16 @@ async function readBoundRegister(host, storeKey) {
|
|
|
1637
1989
|
const id2 = getBoundRegisterId(register, storeKey);
|
|
1638
1990
|
return id2 ? { id: id2, name: register?.stores[storeKey]?.register_name ?? "" } : null;
|
|
1639
1991
|
}
|
|
1640
|
-
|
|
1992
|
+
var RegisterIdInvalidError = class extends Error {
|
|
1993
|
+
constructor() {
|
|
1994
|
+
super("register_id_invalid");
|
|
1995
|
+
this.name = "RegisterIdInvalidError";
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
async function bindRegister(host, storeKey, register) {
|
|
1999
|
+
if (typeof register.id === "string" && (register.id.length === 0 || register.id.length > MAX_REGISTER_ID_LENGTH)) {
|
|
2000
|
+
throw new RegisterIdInvalidError();
|
|
2001
|
+
}
|
|
1641
2002
|
return modifyStore(host, storeKey, (store) => ({
|
|
1642
2003
|
...store ?? { sale_counter: 0 },
|
|
1643
2004
|
register_id: register.id,
|
|
@@ -1659,6 +2020,16 @@ function withRegister(store, registerId, bucket) {
|
|
|
1659
2020
|
const base = store ?? { sale_counter: 0 };
|
|
1660
2021
|
return { ...base, registers: { ...base.registers, [registerId]: bucket } };
|
|
1661
2022
|
}
|
|
2023
|
+
async function markCommandsSince(host, storeKey, registerId, now) {
|
|
2024
|
+
let since = now;
|
|
2025
|
+
await modifyStore(host, storeKey, (store) => {
|
|
2026
|
+
const register = store?.registers?.[registerId] ?? {};
|
|
2027
|
+
since = register.commands_since ?? now;
|
|
2028
|
+
if (register.commands_since !== void 0) return void 0;
|
|
2029
|
+
return withRegister(store, registerId, { ...register, commands_since: since });
|
|
2030
|
+
});
|
|
2031
|
+
return since;
|
|
2032
|
+
}
|
|
1662
2033
|
async function mintClosureNumber(host, storeKey, registerId, closure) {
|
|
1663
2034
|
let number = 0;
|
|
1664
2035
|
await modifyStore(host, storeKey, (store) => {
|
|
@@ -1802,6 +2173,18 @@ var RegisterSessionClosedError = class extends Error {
|
|
|
1802
2173
|
this.name = "RegisterSessionClosedError";
|
|
1803
2174
|
}
|
|
1804
2175
|
};
|
|
2176
|
+
var RegisterMovementAmountError = class extends Error {
|
|
2177
|
+
constructor(type, amountMinor) {
|
|
2178
|
+
super(`movement_amount_invalid:${type}:${amountMinor}`);
|
|
2179
|
+
this.name = "RegisterMovementAmountError";
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
var RegisterMovementReasonError = class extends Error {
|
|
2183
|
+
constructor() {
|
|
2184
|
+
super("movement_reason_invalid");
|
|
2185
|
+
this.name = "RegisterMovementReasonError";
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
1805
2188
|
var RegisterMovementStrandedError = class extends Error {
|
|
1806
2189
|
id;
|
|
1807
2190
|
session_id;
|
|
@@ -1933,13 +2316,16 @@ async function closeSession(sessions, id2, input) {
|
|
|
1933
2316
|
});
|
|
1934
2317
|
}
|
|
1935
2318
|
async function recordMovement(sessions, movements, closures, input) {
|
|
2319
|
+
if (!Number.isSafeInteger(input.amountMinor) || (input.type === "paid_in" || input.type === "paid_out") && input.amountMinor <= 0 || input.type === "no_sale" && input.amountMinor !== 0) throw new RegisterMovementAmountError(input.type, input.amountMinor);
|
|
2320
|
+
const reasonLength = input.reason.trim().length;
|
|
2321
|
+
if (reasonLength === 0 || reasonLength > MAX_REASON_LENGTH) throw new RegisterMovementReasonError();
|
|
1936
2322
|
await requireLiveSession(sessions, input.sessionId);
|
|
1937
2323
|
const row = await movements.insert({
|
|
1938
2324
|
id: mintUuid(),
|
|
1939
2325
|
session_id: input.sessionId,
|
|
1940
2326
|
type: input.type,
|
|
1941
2327
|
amountMinor: input.amountMinor,
|
|
1942
|
-
reason: input.reason,
|
|
2328
|
+
reason: input.reason.trim(),
|
|
1943
2329
|
created_by: input.actor,
|
|
1944
2330
|
created_at_gmt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1945
2331
|
});
|
|
@@ -2041,9 +2427,13 @@ async function writeClosure({
|
|
|
2041
2427
|
payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row2.amountMinor, refunds_minor: 0 };
|
|
2042
2428
|
}
|
|
2043
2429
|
const taxRates = /* @__PURE__ */ new Map();
|
|
2430
|
+
const roundings = /* @__PURE__ */ new Set();
|
|
2044
2431
|
for (const order of bound) {
|
|
2432
|
+
const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
|
|
2433
|
+
const { granularity, mode } = recorded.granularity === "custom" ? DEFAULT_TAX_ROUNDING : recorded;
|
|
2434
|
+
roundings.add(`${granularity} ${mode}`);
|
|
2045
2435
|
const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
|
|
2046
|
-
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor)) {
|
|
2436
|
+
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, void 0, order.taxRounding)) {
|
|
2047
2437
|
const existing2 = taxRates.get(String(ratePpm));
|
|
2048
2438
|
taxRates.set(String(ratePpm), {
|
|
2049
2439
|
ratePpm,
|
|
@@ -2089,6 +2479,8 @@ async function writeClosure({
|
|
|
2089
2479
|
{ name: `Tax ${ratePpm / 1e4}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor }
|
|
2090
2480
|
])
|
|
2091
2481
|
),
|
|
2482
|
+
// The session's sales used more than one tax rounding (#287): the Z report says so.
|
|
2483
|
+
...roundings.size > 1 ? { tax_rounding_mixed: true } : {},
|
|
2092
2484
|
opening_float: {
|
|
2093
2485
|
expected_minor: session.expected_float_minor ?? null,
|
|
2094
2486
|
counted_minor: session.counted_float_minor,
|
|
@@ -2311,6 +2703,7 @@ function selectClosureRows(rows, scope, timezone) {
|
|
|
2311
2703
|
}
|
|
2312
2704
|
|
|
2313
2705
|
// src/register/closure-document.ts
|
|
2706
|
+
var TAX_ROUNDING_MIXED_NOTE = "This register's sales used more than one tax rounding method. Each sale's tax is as its receipt showed.";
|
|
2314
2707
|
var decimalMap = (minor2, exponent) => Object.fromEntries(Object.entries(minor2).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
|
|
2315
2708
|
function decimalizeBreakdowns(breakdowns, exponent) {
|
|
2316
2709
|
const dec = (m) => typeof m === "number" ? minorToDecimal(m, exponent) : m;
|
|
@@ -2412,6 +2805,7 @@ function envelope(row, context, xreport = false) {
|
|
|
2412
2805
|
...Object.fromEntries(
|
|
2413
2806
|
["payment_methods", "tax_rates", "movements"].map((key) => [`has_${key}`, Object.keys(breakdowns[key] ?? {}).length > 0])
|
|
2414
2807
|
),
|
|
2808
|
+
...breakdowns.tax_rounding_mixed === true ? { tax_rounding_note: TAX_ROUNDING_MIXED_NOTE } : {},
|
|
2415
2809
|
...money(row, ["period_sales_total", "period_refunds_total", "perpetual_sales_total", "perpetual_refunds_total", "unsynced_total"]),
|
|
2416
2810
|
opened_at_gmt: row.opened_at,
|
|
2417
2811
|
opened_by: breakdowns.opened_by ?? null,
|
|
@@ -2530,6 +2924,155 @@ function buildXReportDocument(session, context) {
|
|
|
2530
2924
|
// src/register/use-register-session.ts
|
|
2531
2925
|
import { useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
|
|
2532
2926
|
import { combineLatest, map as map5, of, switchMap } from "rxjs";
|
|
2927
|
+
|
|
2928
|
+
// src/register/register-commands.ts
|
|
2929
|
+
var registerCommandsLogger = createLogger("register-commands");
|
|
2930
|
+
var registerCommandSchema = {
|
|
2931
|
+
title: "Register commands",
|
|
2932
|
+
version: 0,
|
|
2933
|
+
primaryKey: "key",
|
|
2934
|
+
type: "object",
|
|
2935
|
+
additionalProperties: false,
|
|
2936
|
+
properties: {
|
|
2937
|
+
key: { type: "string", maxLength: 100 },
|
|
2938
|
+
registerId: { type: "string", maxLength: 36 },
|
|
2939
|
+
seq: { type: "integer", minimum: 0, maximum: 1e15, multipleOf: 1 },
|
|
2940
|
+
commandId: { type: "string" },
|
|
2941
|
+
type: { type: "string" },
|
|
2942
|
+
version: { type: "integer" },
|
|
2943
|
+
payload: { type: "object", additionalProperties: true },
|
|
2944
|
+
createdAt: { type: "string" },
|
|
2945
|
+
updatedAt: { type: "string" },
|
|
2946
|
+
syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
|
|
2947
|
+
error: { type: "object", properties: {
|
|
2948
|
+
code: { type: "string" },
|
|
2949
|
+
message: { type: "string" },
|
|
2950
|
+
data: { type: "object", additionalProperties: true }
|
|
2951
|
+
}, required: ["code", "message"], additionalProperties: false },
|
|
2952
|
+
result: { type: "object", additionalProperties: true }
|
|
2953
|
+
},
|
|
2954
|
+
required: ["key", "registerId", "seq", "commandId", "type", "version", "payload", "createdAt", "syncStatus", "updatedAt"],
|
|
2955
|
+
indexes: [["registerId", "seq"], ["syncStatus", "registerId", "seq"]]
|
|
2956
|
+
};
|
|
2957
|
+
var registerCommandCollection = () => ({ schema: registerCommandSchema });
|
|
2958
|
+
function sessionOpenCommand(s) {
|
|
2959
|
+
return { key: `session.open:${s.id}`, type: "register.session.open", version: 1, payload: {
|
|
2960
|
+
sessionId: s.id,
|
|
2961
|
+
registerId: s.register_id,
|
|
2962
|
+
openedAt: s.opened_at_gmt,
|
|
2963
|
+
countedFloatMinor: s.counted_float_minor,
|
|
2964
|
+
...s.store_key == null ? {} : { storeKey: s.store_key },
|
|
2965
|
+
...s.business_day == null ? {} : { businessDay: s.business_day },
|
|
2966
|
+
...s.opened_by == null ? {} : { openedBy: s.opened_by },
|
|
2967
|
+
...s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor },
|
|
2968
|
+
...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }
|
|
2969
|
+
} };
|
|
2970
|
+
}
|
|
2971
|
+
function sessionTransitionCommand(s) {
|
|
2972
|
+
return { key: `session.transition:${s.id}:${s.status}:${s.status_at}`, type: "register.session.transition", version: 1, payload: {
|
|
2973
|
+
sessionId: s.id,
|
|
2974
|
+
status: s.status,
|
|
2975
|
+
at: s.status_at,
|
|
2976
|
+
...s.status !== "closed" || s.counted == null ? {} : { counted: s.counted },
|
|
2977
|
+
...s.status !== "closed" || s.closed_by == null ? {} : { closedBy: s.closed_by },
|
|
2978
|
+
...s.status !== "closed" || s.approved_by == null ? {} : { approvedBy: s.approved_by }
|
|
2979
|
+
} };
|
|
2980
|
+
}
|
|
2981
|
+
function movementCommand(m) {
|
|
2982
|
+
const common = {
|
|
2983
|
+
movementId: m.id,
|
|
2984
|
+
sessionId: m.session_id,
|
|
2985
|
+
createdAt: m.created_at_gmt,
|
|
2986
|
+
...m.created_by == null ? {} : { createdBy: m.created_by }
|
|
2987
|
+
};
|
|
2988
|
+
return m.type === "void" ? {
|
|
2989
|
+
key: `movement.void:${m.id}`,
|
|
2990
|
+
type: "register.movement.void",
|
|
2991
|
+
version: 1,
|
|
2992
|
+
payload: { ...common, voids: m.voids }
|
|
2993
|
+
} : {
|
|
2994
|
+
key: `movement.record:${m.id}`,
|
|
2995
|
+
type: "register.movement.record",
|
|
2996
|
+
version: 1,
|
|
2997
|
+
payload: { ...common, type: m.type, amountMinor: m.amountMinor, reason: m.reason }
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
function closureCommand(c) {
|
|
3001
|
+
return { key: `closure.submit:${c.id}`, type: "register.closure.submit", version: 1, payload: {
|
|
3002
|
+
closureId: c.id,
|
|
3003
|
+
sessionId: c.session_id,
|
|
3004
|
+
registerId: c.register_id,
|
|
3005
|
+
number: c.number,
|
|
3006
|
+
openedAt: c.opened_at,
|
|
3007
|
+
closedAt: c.closed_at,
|
|
3008
|
+
...c.business_day == null ? {} : { businessDay: c.business_day },
|
|
3009
|
+
...c.closed_by == null ? {} : { closedBy: c.closed_by },
|
|
3010
|
+
...typeof c.breakdowns.approved_by === "string" && c.breakdowns.approved_by ? { approvedBy: c.breakdowns.approved_by } : {},
|
|
3011
|
+
tillExpected: c.till_expected,
|
|
3012
|
+
counted: c.counted,
|
|
3013
|
+
periodSalesTotalMinor: c.period_sales_total_minor,
|
|
3014
|
+
periodRefundsTotalMinor: c.period_refunds_total_minor,
|
|
3015
|
+
perpetualSalesTotalMinor: c.perpetual_sales_total_minor,
|
|
3016
|
+
perpetualRefundsTotalMinor: c.perpetual_refunds_total_minor,
|
|
3017
|
+
unsyncedCount: c.unsynced_count,
|
|
3018
|
+
unsyncedTotalMinor: c.unsynced_total_minor,
|
|
3019
|
+
softwareVersion: c.software_version,
|
|
3020
|
+
orderIds: c.order_ids,
|
|
3021
|
+
movementIds: c.movement_ids
|
|
3022
|
+
} };
|
|
3023
|
+
}
|
|
3024
|
+
var chains = /* @__PURE__ */ new WeakMap();
|
|
3025
|
+
function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }) {
|
|
3026
|
+
let registers = chains.get(commands);
|
|
3027
|
+
if (!registers) chains.set(commands, registers = /* @__PURE__ */ new Map());
|
|
3028
|
+
const run = (registers.get(registerId) ?? Promise.resolve()).then(async () => {
|
|
3029
|
+
const since = await markCommandsSince(host, storeKey, registerId, now ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
3030
|
+
const [highest] = await readFresh(commands, { selector: { registerId }, sort: [{ seq: "desc" }], limit: 1 });
|
|
3031
|
+
let seq = highest?.seq ?? 0;
|
|
3032
|
+
const transitioned = new Set((await readFresh(commands, { selector: { registerId, type: "register.session.transition" } })).map(({ payload }) => payload.sessionId));
|
|
3033
|
+
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
3034
|
+
const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) => [`session.open:${session.id}`, ...session.status === "closed" && session.closure_id ? [`closure.submit:${session.closure_id}`] : []]), false)).map(({ key }) => key));
|
|
3035
|
+
const facts = await Promise.all(rows.filter((session) => session.status !== "closed" || (session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`)) && !complete.has(`closure.submit:${session.closure_id}`)).map(async (session) => {
|
|
3036
|
+
const [entries, closureRows] = await Promise.all([
|
|
3037
|
+
readFresh(movements, { selector: { session_id: session.id } }),
|
|
3038
|
+
session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([])
|
|
3039
|
+
]);
|
|
3040
|
+
const events = (observed ?? []).filter((row) => row.id === session.id && !transitioned.has(session.id) && row.status !== session.status && row.status !== "closed").flatMap((row) => row.status_at == null ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
|
|
3041
|
+
events.push(
|
|
3042
|
+
...entries.filter((entry) => entry.type !== "void").map(movementCommand),
|
|
3043
|
+
...entries.filter((entry) => entry.type === "void").map(movementCommand)
|
|
3044
|
+
);
|
|
3045
|
+
if (session.status_at != null) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
3046
|
+
return { session, number: closureRows[0]?.number, built: [
|
|
3047
|
+
sessionOpenCommand(session),
|
|
3048
|
+
...events,
|
|
3049
|
+
...closureRows.map((closure) => closureCommand(closure))
|
|
3050
|
+
] };
|
|
3051
|
+
}));
|
|
3052
|
+
facts.sort((a, b) => Number(a.session.status !== "closed") - Number(b.session.status !== "closed") || (a.number ?? Infinity) - (b.number ?? Infinity) || a.session.id.localeCompare(b.session.id));
|
|
3053
|
+
const existing = new Set((await commands.storageInstance.findDocumentsById(facts.flatMap(({ built }) => built.map(({ key }) => key)), false)).map(({ key }) => key));
|
|
3054
|
+
const appended = [];
|
|
3055
|
+
for (const { built } of facts) {
|
|
3056
|
+
for (const command of built) {
|
|
3057
|
+
if (existing.has(command.key)) continue;
|
|
3058
|
+
seq++;
|
|
3059
|
+
const at2 = now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3060
|
+
try {
|
|
3061
|
+
await commands.insert({ ...command, registerId, seq, commandId: uuidv7(), createdAt: at2, updatedAt: at2, syncStatus: "pending" });
|
|
3062
|
+
appended.push(command.key);
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
if (error?.code !== "CONFLICT") throw error;
|
|
3065
|
+
}
|
|
3066
|
+
existing.add(command.key);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return appended;
|
|
3070
|
+
});
|
|
3071
|
+
registers.set(registerId, run.then(() => void 0, () => void 0));
|
|
3072
|
+
return run;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// src/register/use-register-session.ts
|
|
2533
3076
|
var RegisterTenderInProgressError = class extends Error {
|
|
2534
3077
|
constructor() {
|
|
2535
3078
|
super("Finish or cancel the sale in progress first.");
|
|
@@ -2581,6 +3124,25 @@ function ledgerRows(orders) {
|
|
|
2581
3124
|
}
|
|
2582
3125
|
function useRegisterSession(options) {
|
|
2583
3126
|
const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
|
|
3127
|
+
const { commands } = options;
|
|
3128
|
+
const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
|
|
3129
|
+
const commandTarget = useMemo3(
|
|
3130
|
+
() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
|
|
3131
|
+
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]
|
|
3132
|
+
);
|
|
3133
|
+
const commandTargetRef = useRef2(commandTarget);
|
|
3134
|
+
commandTargetRef.current = commandTarget;
|
|
3135
|
+
const reconcile = (row) => {
|
|
3136
|
+
const target = commandTargetRef.current;
|
|
3137
|
+
if (!target) return;
|
|
3138
|
+
void reconcileRegisterCommands({ ...target, observed: row ? [row] : void 0 }).catch((error) => {
|
|
3139
|
+
try {
|
|
3140
|
+
registerCommandsLogger.error("Register command reconcile failed", { context: { registerId: target.registerId, error: String(error) } });
|
|
3141
|
+
} catch {
|
|
3142
|
+
}
|
|
3143
|
+
});
|
|
3144
|
+
};
|
|
3145
|
+
useEffect2(reconcile, [commandTarget]);
|
|
2584
3146
|
const source = useMemo3(() => {
|
|
2585
3147
|
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
2586
3148
|
return combineLatest([
|
|
@@ -2733,18 +3295,29 @@ function useRegisterSession(options) {
|
|
|
2733
3295
|
return await run;
|
|
2734
3296
|
} finally {
|
|
2735
3297
|
openingByRegister.delete(key);
|
|
3298
|
+
reconcile();
|
|
2736
3299
|
}
|
|
2737
3300
|
},
|
|
2738
3301
|
startCounting: async () => {
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3302
|
+
let row;
|
|
3303
|
+
try {
|
|
3304
|
+
refuseDuringTender();
|
|
3305
|
+
row = await startCounting(live().sessions, current().id);
|
|
3306
|
+
recordRegisterFact({ kind: "counting-started", actor, sessionId: row.id, registerId: row.register_id });
|
|
3307
|
+
return row;
|
|
3308
|
+
} finally {
|
|
3309
|
+
reconcile(row?.toJSON());
|
|
3310
|
+
}
|
|
2743
3311
|
},
|
|
2744
3312
|
backToSelling: async () => {
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
3313
|
+
let row;
|
|
3314
|
+
try {
|
|
3315
|
+
row = await backToSelling(live().sessions, current().id);
|
|
3316
|
+
recordRegisterFact({ kind: "counting-abandoned", actor, sessionId: row.id, registerId: row.register_id });
|
|
3317
|
+
return row;
|
|
3318
|
+
} finally {
|
|
3319
|
+
reconcile(row?.toJSON());
|
|
3320
|
+
}
|
|
2748
3321
|
},
|
|
2749
3322
|
/**
|
|
2750
3323
|
* `counted` maps each tender to its counted minor units. An interrupted close resumes with
|
|
@@ -2821,38 +3394,47 @@ function useRegisterSession(options) {
|
|
|
2821
3394
|
} finally {
|
|
2822
3395
|
closingByRegister.delete(key);
|
|
2823
3396
|
notifyClosing();
|
|
3397
|
+
reconcile();
|
|
2824
3398
|
}
|
|
2825
3399
|
},
|
|
2826
3400
|
recordMovement: async (input) => {
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3401
|
+
try {
|
|
3402
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
3403
|
+
const sessionId = await requireOpen();
|
|
3404
|
+
const row = await recordMovement(sessions2, movements2, closures2, { ...input, sessionId, actor: actor.id });
|
|
3405
|
+
recordRegisterFact({
|
|
3406
|
+
kind: "movement-recorded",
|
|
3407
|
+
actor,
|
|
3408
|
+
sessionId: row.session_id,
|
|
3409
|
+
registerId: registerId2,
|
|
3410
|
+
movementId: row.id,
|
|
3411
|
+
movementType: row.type,
|
|
3412
|
+
amount: row.amountMinor
|
|
3413
|
+
});
|
|
3414
|
+
return row;
|
|
3415
|
+
} finally {
|
|
3416
|
+
reconcile();
|
|
3417
|
+
}
|
|
2840
3418
|
},
|
|
2841
3419
|
voidMovement: async (id2) => {
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
3420
|
+
try {
|
|
3421
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
3422
|
+
await requireOpen();
|
|
3423
|
+
const row = await voidMovement(sessions2, movements2, id2, actor.id, closures2);
|
|
3424
|
+
recordRegisterFact({
|
|
3425
|
+
kind: "movement-voided",
|
|
3426
|
+
actor,
|
|
3427
|
+
sessionId: row.session_id,
|
|
3428
|
+
registerId: registerId2,
|
|
3429
|
+
movementId: row.id,
|
|
3430
|
+
movementType: row.type,
|
|
3431
|
+
amount: row.amountMinor,
|
|
3432
|
+
voids: id2
|
|
3433
|
+
});
|
|
3434
|
+
return row;
|
|
3435
|
+
} finally {
|
|
3436
|
+
reconcile();
|
|
3437
|
+
}
|
|
2856
3438
|
}
|
|
2857
3439
|
}
|
|
2858
3440
|
};
|
|
@@ -2878,7 +3460,8 @@ function addEntryToCart(builder, entry, traits, currency) {
|
|
|
2878
3460
|
name,
|
|
2879
3461
|
sku: variant.sku ?? "",
|
|
2880
3462
|
imageUrl: traits.getImageUrl?.(product),
|
|
2881
|
-
unitPrice
|
|
3463
|
+
unitPrice,
|
|
3464
|
+
taxClass: traits.getTaxClass?.(product, variant.id)
|
|
2882
3465
|
});
|
|
2883
3466
|
}
|
|
2884
3467
|
|
|
@@ -2890,13 +3473,17 @@ var HUNG_SAVE_CHECK_MS = 5e3;
|
|
|
2890
3473
|
function useSale(settings, opts) {
|
|
2891
3474
|
const taxContext = useTax();
|
|
2892
3475
|
const madeWith = useRef3({ taxContext, currency: settings.currency });
|
|
2893
|
-
const [
|
|
2894
|
-
const
|
|
3476
|
+
const [rendered, setBuilder] = useState3(() => createOrderBuilder({ currency: settings.currency, taxContext }));
|
|
3477
|
+
const builderNow = useRef3(rendered);
|
|
3478
|
+
const [order, setOrder] = useState3(() => rendered.getSnapshot());
|
|
2895
3479
|
const [stage, setStage] = useState3({ kind: "cart" });
|
|
2896
|
-
const [
|
|
3480
|
+
const [saleError, setError] = useState3(null);
|
|
3481
|
+
const configError = referenceError("cashierRef", opts.cashierRef) ?? referenceError("registerId", opts.registerId);
|
|
2897
3482
|
const pending = useRef3(null);
|
|
2898
3483
|
const [saving, setSaving] = useState3(false);
|
|
3484
|
+
const error = saving && saleError ? saleError : configError ?? saleError;
|
|
2899
3485
|
const tenderSession = useRef3(null);
|
|
3486
|
+
const droppedReference = useRef3(null);
|
|
2900
3487
|
const confirmed = useRef3(null);
|
|
2901
3488
|
const [canContinue, setCanContinue] = useState3(false);
|
|
2902
3489
|
const attempts = useRef3(0);
|
|
@@ -2921,13 +3508,36 @@ function useSale(settings, opts) {
|
|
|
2921
3508
|
if (active) setError(SALE_SAVING);
|
|
2922
3509
|
return active;
|
|
2923
3510
|
}
|
|
3511
|
+
function resetSale(customer) {
|
|
3512
|
+
if (inFlight.current && !pending.current) return setError(SALE_SAVING);
|
|
3513
|
+
if (pending.current && confirmed.current !== pending.current) {
|
|
3514
|
+
if (inFlight.current) checkStored(pending.current);
|
|
3515
|
+
return setError(SALE_SAVING);
|
|
3516
|
+
}
|
|
3517
|
+
confirm(null);
|
|
3518
|
+
pending.current = null;
|
|
3519
|
+
inFlight.current = null;
|
|
3520
|
+
tenderSession.current = null;
|
|
3521
|
+
droppedReference.current = null;
|
|
3522
|
+
setSaving(false);
|
|
3523
|
+
madeWith.current = { taxContext, currency: settings.currency };
|
|
3524
|
+
const next = createOrderBuilder({ currency: settings.currency, taxContext });
|
|
3525
|
+
if (customer !== null) next.setCustomer(customer);
|
|
3526
|
+
builderNow.current = next;
|
|
3527
|
+
setBuilder(next);
|
|
3528
|
+
setOrder(next.getSnapshot());
|
|
3529
|
+
setStage({ kind: "cart" });
|
|
3530
|
+
setError(null);
|
|
3531
|
+
}
|
|
2924
3532
|
useEffect3(() => {
|
|
2925
|
-
const subscription =
|
|
3533
|
+
const subscription = rendered.order$.subscribe(setOrder);
|
|
2926
3534
|
return () => subscription.unsubscribe();
|
|
2927
|
-
}, [
|
|
3535
|
+
}, [rendered]);
|
|
2928
3536
|
const idle2 = stage.kind === "cart" && !order.lineItems.length;
|
|
2929
|
-
|
|
2930
|
-
|
|
3537
|
+
useLayoutEffect(() => {
|
|
3538
|
+
const live = builderNow.current.getSnapshot();
|
|
3539
|
+
const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
|
|
3540
|
+
if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
|
|
2931
3541
|
});
|
|
2932
3542
|
useEffect3(() => () => {
|
|
2933
3543
|
clearHungSaveTimer();
|
|
@@ -2940,10 +3550,19 @@ function useSale(settings, opts) {
|
|
|
2940
3550
|
}, []);
|
|
2941
3551
|
function setTender(tender) {
|
|
2942
3552
|
if (locked()) return;
|
|
3553
|
+
const reason = referenceReason(tender?.reference);
|
|
3554
|
+
const dropped = reason && (reason === "nul" ? "it contains a NUL character" : `it is over ${PAYLOAD_STRING_MAX} characters`);
|
|
3555
|
+
const kept = tender && dropped ? { method: tender.method, amountMinor: tender.amountMinor } : tender;
|
|
3556
|
+
const builder = builderNow.current;
|
|
2943
3557
|
const previous = builder.getSnapshot().payments[0];
|
|
2944
3558
|
if (previous) builder.removePayment(previous.id);
|
|
2945
|
-
|
|
2946
|
-
|
|
3559
|
+
const paymentId = kept ? builder.addPayment(kept) : null;
|
|
3560
|
+
droppedReference.current = dropped ? paymentId : null;
|
|
3561
|
+
setError(dropped && `The terminal's payment reference couldn't be kept (${dropped}); the payment is recorded without it.`);
|
|
3562
|
+
try {
|
|
3563
|
+
if (dropped) saleLogger.warn("the terminal's payment reference was dropped", { reason: dropped, method: tender.method });
|
|
3564
|
+
} catch {
|
|
3565
|
+
}
|
|
2947
3566
|
}
|
|
2948
3567
|
function checkStored(completion) {
|
|
2949
3568
|
const attempt2 = attempts.current;
|
|
@@ -2981,10 +3600,11 @@ function useSale(settings, opts) {
|
|
|
2981
3600
|
if (hungSaveTimer.current === timer) clearHungSaveTimer();
|
|
2982
3601
|
if (pending.current !== completion) return;
|
|
2983
3602
|
pending.current = null;
|
|
3603
|
+
droppedReference.current = null;
|
|
2984
3604
|
confirm(null);
|
|
2985
3605
|
setSaving(false);
|
|
2986
3606
|
setError(null);
|
|
2987
|
-
stageNow.current = { kind: "receipt", order: completion.order, posOrder: completion.posOrder };
|
|
3607
|
+
stageNow.current = { kind: "receipt", order: withSentForm(completion.order, completion.posOrder), posOrder: completion.posOrder };
|
|
2988
3608
|
setStage(stageNow.current);
|
|
2989
3609
|
}
|
|
2990
3610
|
function once(attempt2) {
|
|
@@ -3007,8 +3627,21 @@ function useSale(settings, opts) {
|
|
|
3007
3627
|
canContinue,
|
|
3008
3628
|
add(entry, traits) {
|
|
3009
3629
|
if (locked()) return;
|
|
3630
|
+
const builder = builderNow.current;
|
|
3010
3631
|
try {
|
|
3011
|
-
|
|
3632
|
+
const known = new Set(builder.getSnapshot().lineItems.map((line2) => line2.id));
|
|
3633
|
+
const lineId = addEntryToCart(builder, entry, traits, madeWith.current.currency);
|
|
3634
|
+
const line = builder.getSnapshot().lineItems.find((item) => item.id === lineId && !known.has(item.id));
|
|
3635
|
+
const name = line && `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
|
|
3636
|
+
const taxLines = line && (opts.capabilities?.orderCreate ?? 1) >= 3 ? line.taxLines : [];
|
|
3637
|
+
const refused = line && [
|
|
3638
|
+
line.variantId !== void 0 ? referenceError(`${name}: the variant id`, line.variantId) : referenceError(`${name}: the product id`, line.productId),
|
|
3639
|
+
...taxLines.map((tax) => referenceError(`${name}: the tax code`, tax.code))
|
|
3640
|
+
].find((message) => message !== null);
|
|
3641
|
+
if (refused) {
|
|
3642
|
+
builder.removeItem(lineId);
|
|
3643
|
+
return setError(refused);
|
|
3644
|
+
}
|
|
3012
3645
|
setError(null);
|
|
3013
3646
|
} catch (error2) {
|
|
3014
3647
|
if (!(error2 instanceof CartError)) throw error2;
|
|
@@ -3016,15 +3649,16 @@ function useSale(settings, opts) {
|
|
|
3016
3649
|
}
|
|
3017
3650
|
},
|
|
3018
3651
|
setQuantity(lineId, quantity) {
|
|
3019
|
-
if (!locked())
|
|
3652
|
+
if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
|
|
3020
3653
|
},
|
|
3021
3654
|
remove(lineId) {
|
|
3022
|
-
if (!locked())
|
|
3655
|
+
if (!locked()) builderNow.current.removeItem(lineId);
|
|
3023
3656
|
},
|
|
3024
3657
|
/** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
|
|
3025
3658
|
applyDiscount(lineId, discount) {
|
|
3026
3659
|
if (locked()) return SALE_SAVING;
|
|
3027
3660
|
if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
|
|
3661
|
+
const builder = builderNow.current;
|
|
3028
3662
|
const applied = (snapshot) => lineId === null ? snapshot.discounts : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
|
|
3029
3663
|
const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
|
|
3030
3664
|
if (lineId === null) builder.applyOrderDiscount(discount);
|
|
@@ -3037,7 +3671,15 @@ function useSale(settings, opts) {
|
|
|
3037
3671
|
return null;
|
|
3038
3672
|
},
|
|
3039
3673
|
removeDiscount(id2) {
|
|
3040
|
-
if (!locked())
|
|
3674
|
+
if (!locked()) builderNow.current.removeDiscount(id2);
|
|
3675
|
+
},
|
|
3676
|
+
/** the picked customer reaches the server as order.create v3's customer.customerId */
|
|
3677
|
+
setCustomer(customer) {
|
|
3678
|
+
if (locked()) return;
|
|
3679
|
+
const refused = customer && customerRefusal(customer);
|
|
3680
|
+
if (refused) return setError(refused);
|
|
3681
|
+
builderNow.current.setCustomer(customer);
|
|
3682
|
+
setError((current) => current !== null && CUSTOMER_REFUSALS.includes(current) ? null : current);
|
|
3041
3683
|
},
|
|
3042
3684
|
/**
|
|
3043
3685
|
* Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
|
|
@@ -3046,7 +3688,7 @@ function useSale(settings, opts) {
|
|
|
3046
3688
|
*/
|
|
3047
3689
|
startTender(method, options) {
|
|
3048
3690
|
if (locked()) return;
|
|
3049
|
-
const current =
|
|
3691
|
+
const current = builderNow.current.getSnapshot();
|
|
3050
3692
|
if (!current.lineItems.length) return;
|
|
3051
3693
|
tenderSession.current ??= { session: options?.session ?? opts.session };
|
|
3052
3694
|
setTender(method === "external" ? { method, amountMinor: current.totalMinor } : null);
|
|
@@ -3070,17 +3712,24 @@ function useSale(settings, opts) {
|
|
|
3070
3712
|
* and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
|
|
3071
3713
|
* again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
|
|
3072
3714
|
* pending completion is cleared once `onSaleCompleted` resolves, or by `newSale()`. A call while
|
|
3073
|
-
* another is in flight returns that call's promise, and a call on the receipt does nothing.
|
|
3715
|
+
* another is in flight returns that call's promise, and a call on the receipt does nothing. While
|
|
3716
|
+
* `cashierRef` or `registerId` is out of bounds (shown as `error`), a call does nothing either,
|
|
3717
|
+
* unless it's the Retry of a pending completion, which was built with the options as they were.
|
|
3074
3718
|
*/
|
|
3075
|
-
complete: () => once(async () => {
|
|
3719
|
+
complete: () => configError && !pending.current && !inFlight.current ? Promise.resolve() : once(async () => {
|
|
3076
3720
|
attempts.current++;
|
|
3077
3721
|
confirm(null);
|
|
3078
3722
|
if (pending.current) return deliver(pending.current);
|
|
3079
3723
|
setSaving(true);
|
|
3080
|
-
const current =
|
|
3724
|
+
const current = builderNow.current.getSnapshot();
|
|
3081
3725
|
let posOrder;
|
|
3082
3726
|
try {
|
|
3083
|
-
posOrder = finalizeOrder(current, {
|
|
3727
|
+
posOrder = finalizeOrder(current, {
|
|
3728
|
+
registerId: opts.registerId,
|
|
3729
|
+
cashierRef: opts.cashierRef,
|
|
3730
|
+
capabilities: opts.capabilities,
|
|
3731
|
+
...droppedReference.current ? { localWarnings: [{ code: "payment_reference_dropped", paymentId: droppedReference.current }] } : {}
|
|
3732
|
+
});
|
|
3084
3733
|
} catch (error2) {
|
|
3085
3734
|
setSaving(false);
|
|
3086
3735
|
setError(error2.message);
|
|
@@ -3131,22 +3780,7 @@ function useSale(settings, opts) {
|
|
|
3131
3780
|
* updates or requeues `pos_orders` itself.
|
|
3132
3781
|
*/
|
|
3133
3782
|
newSale() {
|
|
3134
|
-
|
|
3135
|
-
if (pending.current && confirmed.current !== pending.current) {
|
|
3136
|
-
if (inFlight.current) checkStored(pending.current);
|
|
3137
|
-
return setError(SALE_SAVING);
|
|
3138
|
-
}
|
|
3139
|
-
confirm(null);
|
|
3140
|
-
pending.current = null;
|
|
3141
|
-
inFlight.current = null;
|
|
3142
|
-
tenderSession.current = null;
|
|
3143
|
-
setSaving(false);
|
|
3144
|
-
madeWith.current = { taxContext, currency: settings.currency };
|
|
3145
|
-
const next = createOrderBuilder({ currency: settings.currency, taxContext });
|
|
3146
|
-
setBuilder(next);
|
|
3147
|
-
setOrder(next.getSnapshot());
|
|
3148
|
-
setStage({ kind: "cart" });
|
|
3149
|
-
setError(null);
|
|
3783
|
+
resetSale(null);
|
|
3150
3784
|
},
|
|
3151
3785
|
/**
|
|
3152
3786
|
* Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
|
|
@@ -3222,11 +3856,11 @@ function createHttpCommandTransport(options) {
|
|
|
3222
3856
|
try {
|
|
3223
3857
|
body = await response.json();
|
|
3224
3858
|
} catch {
|
|
3225
|
-
return retry(controller.signal.aborted ? "
|
|
3859
|
+
return retry(controller.signal.aborted ? "timeout" : "bad_body");
|
|
3226
3860
|
}
|
|
3227
3861
|
return isCommandBatchResponse(body) ? { kind: "results", results: body.results } : retry("bad_body");
|
|
3228
3862
|
} catch {
|
|
3229
|
-
return { kind: "retry", reason: "network" };
|
|
3863
|
+
return { kind: "retry", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
3230
3864
|
} finally {
|
|
3231
3865
|
clearTimeout(timeout);
|
|
3232
3866
|
}
|
|
@@ -3235,9 +3869,36 @@ function createHttpCommandTransport(options) {
|
|
|
3235
3869
|
}
|
|
3236
3870
|
|
|
3237
3871
|
// src/outbox/order-outbox.ts
|
|
3872
|
+
import { deepEqual as deepEqual2 } from "rxdb";
|
|
3873
|
+
import { BehaviorSubject as BehaviorSubject4 } from "rxjs";
|
|
3874
|
+
|
|
3875
|
+
// src/outbox/backend-not-found.ts
|
|
3238
3876
|
import { BehaviorSubject as BehaviorSubject3 } from "rxjs";
|
|
3877
|
+
var NOT_FOUND_BEFORE_NOTICE = 3;
|
|
3878
|
+
function createBackendNotFound() {
|
|
3879
|
+
let count = 0;
|
|
3880
|
+
let since = 0;
|
|
3881
|
+
const backendMissing$ = new BehaviorSubject3(void 0);
|
|
3882
|
+
return {
|
|
3883
|
+
backendMissing$,
|
|
3884
|
+
record(outcome, at2) {
|
|
3885
|
+
if (outcome.kind === "retry" && outcome.reason === "network") return;
|
|
3886
|
+
if (outcome.kind === "retry" && outcome.reason === "status_404") {
|
|
3887
|
+
if (count++ === 0) since = at2;
|
|
3888
|
+
} else count = 0;
|
|
3889
|
+
const missing = count >= NOT_FOUND_BEFORE_NOTICE;
|
|
3890
|
+
if (missing !== !!backendMissing$.value) backendMissing$.next(missing ? { since } : void 0);
|
|
3891
|
+
}
|
|
3892
|
+
};
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
// src/outbox/order-outbox.ts
|
|
3239
3896
|
var AUTH_FAILURES_BEFORE_PROMPT = 3;
|
|
3240
3897
|
var NOT_REQUEUEABLE = /* @__PURE__ */ new Set(["idempotency_mismatch"]);
|
|
3898
|
+
var REFUSAL_LOG_MESSAGE_MAX = 200;
|
|
3899
|
+
var ISOLATE_AFTER_ATTEMPTS = 5;
|
|
3900
|
+
var STUCK_AFTER_MS = 15 * 6e4;
|
|
3901
|
+
var validMax = (max) => typeof max === "number" && Number.isSafeInteger(max) && max > 0 ? max : void 0;
|
|
3241
3902
|
function createOrderOutbox(options) {
|
|
3242
3903
|
const { collection, transport, deviceId } = options;
|
|
3243
3904
|
const batchSize = Math.min(options.batchSize ?? 10, 10);
|
|
@@ -3245,7 +3906,13 @@ function createOrderOutbox(options) {
|
|
|
3245
3906
|
const maxBackoff = options.maxBackoffMs ?? 6e4;
|
|
3246
3907
|
const random = options.random ?? Math.random;
|
|
3247
3908
|
const now = options.now ?? Date.now;
|
|
3248
|
-
const state$ = new
|
|
3909
|
+
const state$ = new BehaviorSubject4({ pending: 0, sending: false });
|
|
3910
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
3911
|
+
let missingSubscription;
|
|
3912
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
3913
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
3914
|
+
});
|
|
3915
|
+
watchBackendMissing();
|
|
3249
3916
|
const attempts = /* @__PURE__ */ new Map();
|
|
3250
3917
|
let backoff = initialBackoff;
|
|
3251
3918
|
let unauthorizedSinceAccepted = 0;
|
|
@@ -3255,20 +3922,102 @@ function createOrderOutbox(options) {
|
|
|
3255
3922
|
let stopped = false;
|
|
3256
3923
|
let insertedDuringRun = false;
|
|
3257
3924
|
let stoppedDuringRun = false;
|
|
3925
|
+
const isolateAfter = options.isolateAfterAttempts ?? ISOLATE_AFTER_ATTEMPTS;
|
|
3926
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
3927
|
+
let head;
|
|
3928
|
+
let walk;
|
|
3929
|
+
const isolated = /* @__PURE__ */ new Map();
|
|
3930
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
3931
|
+
let failureSeq = 0;
|
|
3932
|
+
let turn = "batch";
|
|
3933
|
+
let isolatedWait = false;
|
|
3934
|
+
let runProgressed = false;
|
|
3935
|
+
let timerFiredDuringRun = false;
|
|
3936
|
+
let restored = false;
|
|
3937
|
+
function stuckState() {
|
|
3938
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
3939
|
+
if (!stuck.length) return void 0;
|
|
3940
|
+
return {
|
|
3941
|
+
commandIds: stuck.map(([id2]) => id2),
|
|
3942
|
+
since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
3943
|
+
reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
|
|
3944
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
|
|
3945
|
+
};
|
|
3946
|
+
}
|
|
3947
|
+
function isDue(id2) {
|
|
3948
|
+
const entry = isolated.get(id2);
|
|
3949
|
+
return !!entry && (entry.nextAt <= now() || entry.nextAt - now() > maxBackoff);
|
|
3950
|
+
}
|
|
3951
|
+
function failuresOf(commandId) {
|
|
3952
|
+
const clock = clocks.get(commandId);
|
|
3953
|
+
return clock && {
|
|
3954
|
+
since: clock.since,
|
|
3955
|
+
reason: clock.reason.slice(0, 64),
|
|
3956
|
+
isolated: isolated.has(commandId) || !!walk?.failed.has(commandId)
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
async function mirror() {
|
|
3960
|
+
if (!clocks.size) return;
|
|
3961
|
+
for (const order of await readFresh(collection, { selector: { syncStatus: "pending", commandId: { $in: [...clocks.keys()] } } })) {
|
|
3962
|
+
const value = failuresOf(order.commandId);
|
|
3963
|
+
if (!value || deepEqual2(value, order.serverFailures)) continue;
|
|
3964
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
|
|
3965
|
+
if (data.syncStatus === "pending" && data.commandId === order.commandId) data.serverFailures = value;
|
|
3966
|
+
return data;
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
async function restore() {
|
|
3971
|
+
for (const { commandId, serverFailures } of await readFresh(collection, { selector: {
|
|
3972
|
+
syncStatus: "pending",
|
|
3973
|
+
serverFailures: { $exists: true }
|
|
3974
|
+
}, sort: [{ createdAt: "asc" }] })) if (serverFailures) {
|
|
3975
|
+
const { since, reason } = serverFailures;
|
|
3976
|
+
clocks.set(commandId, { since, pausedAt: now(), seq: ++failureSeq, reason });
|
|
3977
|
+
if (serverFailures.isolated) isolated.set(commandId, { backoff: initialBackoff, nextAt: now(), reason });
|
|
3978
|
+
}
|
|
3979
|
+
restored = true;
|
|
3980
|
+
}
|
|
3981
|
+
const bare = ({ serverFailures, _rev, _meta, ...data }) => data;
|
|
3982
|
+
const failuresOnly = (before, after) => !deepEqual2(before.serverFailures, after.serverFailures) && deepEqual2(bare(before), bare(after));
|
|
3258
3983
|
async function updateState(patch = {}) {
|
|
3259
3984
|
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
3260
|
-
|
|
3985
|
+
const rejected = await countFresh(collection, { syncStatus: "rejected" });
|
|
3986
|
+
state$.next({ ...state$.value, ...patch, pending, rejected, stuck: stuckState() });
|
|
3261
3987
|
}
|
|
3262
|
-
function
|
|
3988
|
+
function isolate(id2, reason, retryAfterMs = 0) {
|
|
3989
|
+
const entry = isolated.get(id2) ?? { backoff: initialBackoff };
|
|
3990
|
+
const delay = Math.min(Math.max(retryAfterMs, entry.backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
3991
|
+
isolated.set(id2, { reason, backoff: Math.min(entry.backoff * 2, maxBackoff), nextAt: now() + delay });
|
|
3992
|
+
}
|
|
3993
|
+
function serverFailed(orders, alone, reason, retryAfterMs) {
|
|
3994
|
+
const at2 = now();
|
|
3995
|
+
for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
|
|
3996
|
+
const id2 = orders[0].commandId;
|
|
3997
|
+
if (alone === "isolated") {
|
|
3998
|
+
isolate(id2, reason, retryAfterMs);
|
|
3999
|
+
if (runProgressed && retryAfterMs === void 0 && reason !== "status_429") return void 0;
|
|
4000
|
+
return Math.max(retryAfterMs ?? 0, isolated.get(id2).nextAt - now());
|
|
4001
|
+
}
|
|
4002
|
+
if (alone === "probe") walk?.failed.set(id2, reason);
|
|
4003
|
+
else {
|
|
4004
|
+
head = { id: id2, count: head?.id === id2 ? head.count + 1 : 1 };
|
|
4005
|
+
if (head.count >= isolateAfter) walk ??= { failed: /* @__PURE__ */ new Map() };
|
|
4006
|
+
}
|
|
4007
|
+
return retryAfterMs ?? 0;
|
|
4008
|
+
}
|
|
4009
|
+
function scheduleRetry(reason, retryAfterMs = 0, wait) {
|
|
3263
4010
|
state$.next({ ...state$.value, lastRetryReason: reason });
|
|
3264
4011
|
if (stopped) {
|
|
3265
4012
|
stoppedDuringRun = true;
|
|
3266
4013
|
return;
|
|
3267
4014
|
}
|
|
3268
|
-
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
3269
|
-
backoff = Math.min(backoff * 2, maxBackoff);
|
|
4015
|
+
const delay = wait ?? Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
4016
|
+
if (wait === void 0) backoff = Math.min(backoff * 2, maxBackoff);
|
|
4017
|
+
isolatedWait = wait !== void 0;
|
|
3270
4018
|
timer = setTimeout(() => {
|
|
3271
4019
|
timer = void 0;
|
|
4020
|
+
if (running) timerFiredDuringRun = true;
|
|
3272
4021
|
flush().catch(() => {
|
|
3273
4022
|
});
|
|
3274
4023
|
}, delay);
|
|
@@ -3276,26 +4025,89 @@ function createOrderOutbox(options) {
|
|
|
3276
4025
|
}
|
|
3277
4026
|
async function run() {
|
|
3278
4027
|
stoppedDuringRun = false;
|
|
4028
|
+
runProgressed = false;
|
|
4029
|
+
const sentAlone = /* @__PURE__ */ new Set();
|
|
3279
4030
|
while (!stopped) try {
|
|
4031
|
+
if (!restored) await restore();
|
|
3280
4032
|
await updateState({ sending: true, nextAttemptAt: void 0 });
|
|
3281
4033
|
insertedDuringRun = false;
|
|
3282
|
-
const
|
|
3283
|
-
|
|
4034
|
+
const tracked = [.../* @__PURE__ */ new Set([...isolated.keys(), ...clocks.keys()])];
|
|
4035
|
+
const held = tracked.length ? await readFresh(collection, {
|
|
4036
|
+
selector: { syncStatus: "pending", commandId: { $in: tracked } },
|
|
4037
|
+
sort: [{ createdAt: "asc" }]
|
|
4038
|
+
}) : [];
|
|
4039
|
+
for (const id2 of tracked) if (!held.some((order) => order.commandId === id2)) {
|
|
4040
|
+
isolated.delete(id2);
|
|
4041
|
+
clocks.delete(id2);
|
|
4042
|
+
}
|
|
4043
|
+
let probe;
|
|
4044
|
+
if (walk) {
|
|
4045
|
+
[probe] = await readFresh(collection, { selector: {
|
|
4046
|
+
syncStatus: "pending",
|
|
4047
|
+
commandId: { $nin: [...isolated.keys(), ...walk.failed.keys()] }
|
|
4048
|
+
}, sort: [{ createdAt: "asc" }], limit: 1 });
|
|
4049
|
+
if (!probe) {
|
|
4050
|
+
walk = void 0;
|
|
4051
|
+
head = void 0;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
const [due] = held.filter((order) => isDue(order.commandId) && !sentAlone.has(order.commandId)).sort((a, b) => isolated.get(a.commandId).nextAt - isolated.get(b.commandId).nextAt);
|
|
4055
|
+
let orders = turn === "isolated" && due ? [due] : probe ? [probe] : await readFresh(collection, {
|
|
4056
|
+
selector: { syncStatus: "pending", ...isolated.size ? { commandId: { $nin: [...isolated.keys()] } } : {} },
|
|
3284
4057
|
sort: [{ createdAt: "asc" }],
|
|
3285
4058
|
limit: batchSize
|
|
3286
4059
|
});
|
|
4060
|
+
if (!orders.length && due) orders = [due];
|
|
3287
4061
|
if (!orders.length || stopped) {
|
|
3288
|
-
|
|
3289
|
-
|
|
4062
|
+
if (stopped || !isolated.size) {
|
|
4063
|
+
stoppedDuringRun = stopped;
|
|
4064
|
+
return;
|
|
4065
|
+
}
|
|
4066
|
+
if (insertedDuringRun) continue;
|
|
4067
|
+
const next = [...isolated.values()].sort((a, b) => a.nextAt - b.nextAt)[0];
|
|
4068
|
+
return scheduleRetry(next.reason, 0, Math.min(maxBackoff, Math.max(initialBackoff, next.nextAt - now())));
|
|
3290
4069
|
}
|
|
3291
|
-
const
|
|
4070
|
+
const alone = due && orders[0] === due ? "isolated" : probe ? "probe" : void 0;
|
|
4071
|
+
if (alone === "isolated") sentAlone.add(orders[0].commandId);
|
|
4072
|
+
turn = alone === "isolated" ? "batch" : "isolated";
|
|
4073
|
+
const firstSend = orders.some((order) => order.sentVersion === void 0);
|
|
4074
|
+
const serverMax = firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.()).catch((cause) => {
|
|
4075
|
+
outboxLogger.warn("Failed to read capabilities", { cause });
|
|
4076
|
+
})) || 3;
|
|
4077
|
+
const batch = await Promise.all(orders.map(async (order) => {
|
|
4078
|
+
const frozen = freezeSentForm(order);
|
|
3292
4079
|
const attempt2 = (attempts.get(order.commandId) ?? 0) + 1;
|
|
3293
4080
|
attempts.set(order.commandId, attempt2);
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
4081
|
+
let envelope2;
|
|
4082
|
+
try {
|
|
4083
|
+
envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: order.sentVersion ?? serverMax });
|
|
4084
|
+
} catch (cause) {
|
|
4085
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
4086
|
+
envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: 3 });
|
|
4087
|
+
}
|
|
4088
|
+
if (frozen !== order || order.sentVersion === void 0) {
|
|
4089
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => ({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope2.version }));
|
|
4090
|
+
}
|
|
4091
|
+
return envelope2;
|
|
4092
|
+
}));
|
|
4093
|
+
let outcome = await transport.send(batch);
|
|
4094
|
+
backendNotFound.record(outcome, now());
|
|
4095
|
+
if (outcome.kind !== "retry" || outcome.reason !== "network") {
|
|
4096
|
+
const at2 = now();
|
|
4097
|
+
for (const clock of clocks.values()) if (clock.pausedAt !== void 0) {
|
|
4098
|
+
clock.since += at2 - clock.pausedAt;
|
|
4099
|
+
clock.pausedAt = void 0;
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
let batchMax;
|
|
4103
|
+
let capabilitiesRefreshed = false;
|
|
3297
4104
|
if (outcome.kind === "retry") {
|
|
3298
|
-
|
|
4105
|
+
let floor = outcome.retryAfterMs;
|
|
4106
|
+
if (outcome.reason === "network") {
|
|
4107
|
+
for (const clock of clocks.values()) clock.pausedAt ??= now();
|
|
4108
|
+
walk = void 0;
|
|
4109
|
+
} else if ((floor = serverFailed(orders, alone, outcome.reason, outcome.retryAfterMs)) === void 0) continue;
|
|
4110
|
+
return scheduleRetry(outcome.reason, floor);
|
|
3299
4111
|
}
|
|
3300
4112
|
if (outcome.kind === "unauthorized") {
|
|
3301
4113
|
unauthorizedSinceAccepted++;
|
|
@@ -3309,6 +4121,28 @@ function createOrderOutbox(options) {
|
|
|
3309
4121
|
});
|
|
3310
4122
|
return;
|
|
3311
4123
|
}
|
|
4124
|
+
if (outcome.kind === "refused" && outcome.status === 400 && options.getMaxOrderCreateVersion) {
|
|
4125
|
+
const match = /^Invalid commands\[(\d+)\]\.version$/.exec(outcome.reason);
|
|
4126
|
+
if (match) {
|
|
4127
|
+
try {
|
|
4128
|
+
await options.refreshCapabilities?.();
|
|
4129
|
+
} catch (cause) {
|
|
4130
|
+
outboxLogger.warn("Failed to refresh capabilities", { cause });
|
|
4131
|
+
}
|
|
4132
|
+
const advertised = await options.getMaxOrderCreateVersion();
|
|
4133
|
+
const max = typeof advertised === "number" && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : void 0;
|
|
4134
|
+
if (max !== void 0 && max < batch[Number(match[1])]?.version) {
|
|
4135
|
+
batchMax = max;
|
|
4136
|
+
capabilitiesRefreshed = true;
|
|
4137
|
+
const message = outcome.reason;
|
|
4138
|
+
outcome = { kind: "results", results: batch.filter((command) => command.version > max).map((command) => ({
|
|
4139
|
+
id: command.id,
|
|
4140
|
+
status: "rejected",
|
|
4141
|
+
error: { code: "unsupported_version", message, data: { orderCreate: max } }
|
|
4142
|
+
})) };
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
3312
4146
|
if (outcome.kind === "refused") {
|
|
3313
4147
|
unauthorizedSinceAccepted = 0;
|
|
3314
4148
|
state$.next({
|
|
@@ -3324,6 +4158,7 @@ function createOrderOutbox(options) {
|
|
|
3324
4158
|
unauthorizedSinceAccepted = 0;
|
|
3325
4159
|
state$.next({ ...state$.value, authRequired: false, refused: void 0 });
|
|
3326
4160
|
let progressed = false;
|
|
4161
|
+
let downgraded = false;
|
|
3327
4162
|
for (const order of orders) {
|
|
3328
4163
|
const result = outcome.results.find((entry) => entry.id === order.commandId);
|
|
3329
4164
|
if (!result) continue;
|
|
@@ -3331,20 +4166,79 @@ function createOrderOutbox(options) {
|
|
|
3331
4166
|
if (stored?.syncStatus !== "pending" || stored.commandId !== order.commandId) continue;
|
|
3332
4167
|
const current = await collection.findOne(order.id).exec();
|
|
3333
4168
|
if (!current) continue;
|
|
4169
|
+
let error = result.error;
|
|
4170
|
+
if (result.status === "rejected" && error?.code === "unsupported_version" && options.getMaxOrderCreateVersion) {
|
|
4171
|
+
if (!capabilitiesRefreshed) {
|
|
4172
|
+
try {
|
|
4173
|
+
await options.refreshCapabilities?.();
|
|
4174
|
+
} catch (cause) {
|
|
4175
|
+
outboxLogger.warn("Failed to refresh capabilities", { cause });
|
|
4176
|
+
}
|
|
4177
|
+
const advertised = error.data?.orderCreate;
|
|
4178
|
+
const max2 = typeof advertised === "number" && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : await options.getMaxOrderCreateVersion();
|
|
4179
|
+
batchMax = typeof max2 === "number" && Number.isSafeInteger(max2) && max2 > 0 ? max2 : void 0;
|
|
4180
|
+
capabilitiesRefreshed = true;
|
|
4181
|
+
}
|
|
4182
|
+
const max = batchMax;
|
|
4183
|
+
const from = batch.find((command) => command.id === order.commandId).version;
|
|
4184
|
+
if (max !== void 0 && max < (stored.sentVersion ?? from)) {
|
|
4185
|
+
try {
|
|
4186
|
+
const to = toOrderCreateEnvelope(stored, deviceId, 1, { maxVersion: max }).version;
|
|
4187
|
+
let changed = false;
|
|
4188
|
+
await current.incrementalModify((data) => {
|
|
4189
|
+
changed = data.syncStatus === "pending" && data.commandId === order.commandId && max < (data.sentVersion ?? from);
|
|
4190
|
+
if (changed) {
|
|
4191
|
+
data.sentVersion = to;
|
|
4192
|
+
data.downgradedFrom ??= from;
|
|
4193
|
+
delete data.serverFailures;
|
|
4194
|
+
}
|
|
4195
|
+
return data;
|
|
4196
|
+
});
|
|
4197
|
+
if (changed) {
|
|
4198
|
+
outboxLogger.warn("Sent an order at a lower order.create version", { orderId: order.id, from, to });
|
|
4199
|
+
progressed = downgraded = true;
|
|
4200
|
+
clocks.delete(order.commandId);
|
|
4201
|
+
}
|
|
4202
|
+
continue;
|
|
4203
|
+
} catch (cause) {
|
|
4204
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
4205
|
+
error = { code: "unsupported_version", message: cause.message };
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
if (result.status === "rejected") outboxLogger[error?.code === "unsupported_version" ? "error" : "warn"](
|
|
4210
|
+
"Order refused by the store",
|
|
4211
|
+
{ orderId: order.id, code: error?.code, message: error?.message?.slice(0, REFUSAL_LOG_MESSAGE_MAX) }
|
|
4212
|
+
);
|
|
3334
4213
|
const updatedAt = new Date(now()).toISOString();
|
|
3335
|
-
await current.
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
4214
|
+
await current.incrementalModify((data) => {
|
|
4215
|
+
delete data.serverFailures;
|
|
4216
|
+
return Object.assign(data, result.status === "rejected" ? { syncStatus: "rejected", error, updatedAt } : {
|
|
4217
|
+
syncStatus: "applied",
|
|
4218
|
+
serverRefs: result.serverRefs,
|
|
4219
|
+
...result.warnings ? { warnings: result.warnings } : {},
|
|
4220
|
+
updatedAt
|
|
4221
|
+
});
|
|
3340
4222
|
});
|
|
3341
4223
|
attempts.delete(order.commandId);
|
|
4224
|
+
isolated.delete(order.commandId);
|
|
4225
|
+
clocks.delete(order.commandId);
|
|
3342
4226
|
progressed = true;
|
|
3343
4227
|
await updateState();
|
|
3344
4228
|
}
|
|
3345
|
-
if (!progressed)
|
|
4229
|
+
if (!progressed) {
|
|
4230
|
+
const floor = serverFailed(orders, alone, "no_progress");
|
|
4231
|
+
if (floor === void 0) continue;
|
|
4232
|
+
return scheduleRetry("no_progress", floor);
|
|
4233
|
+
}
|
|
4234
|
+
runProgressed = true;
|
|
4235
|
+
if (alone !== "isolated") head = void 0;
|
|
4236
|
+
if (walk && alone === "probe") {
|
|
4237
|
+
for (const [id2, reason] of walk.failed) isolate(id2, reason);
|
|
4238
|
+
walk = void 0;
|
|
4239
|
+
}
|
|
3346
4240
|
backoff = initialBackoff;
|
|
3347
|
-
state$.next({ ...state$.value, lastRetryReason: void 0 });
|
|
4241
|
+
state$.next({ ...state$.value, lastRetryReason: downgraded ? "downgraded" : void 0 });
|
|
3348
4242
|
} catch (error) {
|
|
3349
4243
|
scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
|
|
3350
4244
|
return;
|
|
@@ -3354,16 +4248,26 @@ function createOrderOutbox(options) {
|
|
|
3354
4248
|
function flush() {
|
|
3355
4249
|
if (running) return running;
|
|
3356
4250
|
stopped = false;
|
|
4251
|
+
watchBackendMissing();
|
|
3357
4252
|
clearTimeout(timer);
|
|
3358
4253
|
timer = void 0;
|
|
3359
4254
|
running = Promise.resolve().then(run).finally(async () => {
|
|
4255
|
+
try {
|
|
4256
|
+
await mirror();
|
|
4257
|
+
} catch (cause) {
|
|
4258
|
+
outboxLogger.warn("Failed to store server failures", { cause });
|
|
4259
|
+
}
|
|
3360
4260
|
try {
|
|
3361
4261
|
await updateState({ sending: false });
|
|
3362
4262
|
} catch {
|
|
3363
4263
|
}
|
|
3364
4264
|
running = void 0;
|
|
3365
|
-
|
|
3366
|
-
|
|
4265
|
+
const missed = timerFiredDuringRun;
|
|
4266
|
+
timerFiredDuringRun = false;
|
|
4267
|
+
if (!stopped && (missed || (insertedDuringRun || stoppedDuringRun) && (timer === void 0 || insertedDuringRun && isolatedWait))) {
|
|
4268
|
+
flush().catch(() => {
|
|
4269
|
+
});
|
|
4270
|
+
}
|
|
3367
4271
|
});
|
|
3368
4272
|
return running;
|
|
3369
4273
|
}
|
|
@@ -3386,6 +4290,9 @@ function createOrderOutbox(options) {
|
|
|
3386
4290
|
if (!changed) return data;
|
|
3387
4291
|
data.syncStatus = "pending";
|
|
3388
4292
|
delete data.error;
|
|
4293
|
+
delete data.serverFailures;
|
|
4294
|
+
delete data.sentVersion;
|
|
4295
|
+
delete data.downgradedFrom;
|
|
3389
4296
|
data.commandId = uuidv7();
|
|
3390
4297
|
data.updatedAt = new Date(now()).toISOString();
|
|
3391
4298
|
return data;
|
|
@@ -3402,6 +4309,275 @@ function createOrderOutbox(options) {
|
|
|
3402
4309
|
},
|
|
3403
4310
|
start() {
|
|
3404
4311
|
stopped = false;
|
|
4312
|
+
watchBackendMissing();
|
|
4313
|
+
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
4314
|
+
if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE" && !(event.previousDocumentData !== void 0 && failuresOnly(event.previousDocumentData, event.documentData)))) {
|
|
4315
|
+
insertedDuringRun = true;
|
|
4316
|
+
flush().catch(() => {
|
|
4317
|
+
});
|
|
4318
|
+
}
|
|
4319
|
+
});
|
|
4320
|
+
if (!stopped) flush().catch(() => {
|
|
4321
|
+
});
|
|
4322
|
+
},
|
|
4323
|
+
stop() {
|
|
4324
|
+
stopped = true;
|
|
4325
|
+
clearTimeout(timer);
|
|
4326
|
+
timer = void 0;
|
|
4327
|
+
subscription?.unsubscribe();
|
|
4328
|
+
subscription = void 0;
|
|
4329
|
+
missingSubscription?.unsubscribe();
|
|
4330
|
+
missingSubscription = void 0;
|
|
4331
|
+
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
4332
|
+
}
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
// src/outbox/register-outbox.ts
|
|
4337
|
+
import { BehaviorSubject as BehaviorSubject5 } from "rxjs";
|
|
4338
|
+
function plainCommand({
|
|
4339
|
+
key,
|
|
4340
|
+
registerId,
|
|
4341
|
+
seq,
|
|
4342
|
+
commandId,
|
|
4343
|
+
type,
|
|
4344
|
+
version,
|
|
4345
|
+
payload,
|
|
4346
|
+
createdAt,
|
|
4347
|
+
syncStatus,
|
|
4348
|
+
error,
|
|
4349
|
+
result,
|
|
4350
|
+
updatedAt
|
|
4351
|
+
}) {
|
|
4352
|
+
return structuredClone({
|
|
4353
|
+
key,
|
|
4354
|
+
registerId,
|
|
4355
|
+
seq,
|
|
4356
|
+
commandId,
|
|
4357
|
+
type,
|
|
4358
|
+
version,
|
|
4359
|
+
payload,
|
|
4360
|
+
createdAt,
|
|
4361
|
+
syncStatus,
|
|
4362
|
+
...error ? { error } : {},
|
|
4363
|
+
...result ? { result } : {},
|
|
4364
|
+
updatedAt
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
function createRegisterOutbox(options) {
|
|
4368
|
+
const { collection, transport, deviceId } = options;
|
|
4369
|
+
const size = options.batchSize;
|
|
4370
|
+
const batchSize = Math.max(1, Math.min(typeof size === "number" && Number.isFinite(size) ? Math.floor(size) : 10, 10));
|
|
4371
|
+
const initialBackoff = options.initialBackoffMs ?? 1e3;
|
|
4372
|
+
const maxBackoff = options.maxBackoffMs ?? 6e4;
|
|
4373
|
+
const random = options.random ?? Math.random;
|
|
4374
|
+
const now = options.now ?? Date.now;
|
|
4375
|
+
const state$ = new BehaviorSubject5({ pending: 0, sending: false });
|
|
4376
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
4377
|
+
let missingSubscription;
|
|
4378
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
4379
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
4380
|
+
});
|
|
4381
|
+
watchBackendMissing();
|
|
4382
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
4383
|
+
let backoff = initialBackoff;
|
|
4384
|
+
let unauthorizedSinceAccepted = 0;
|
|
4385
|
+
let running;
|
|
4386
|
+
let timer;
|
|
4387
|
+
let subscription;
|
|
4388
|
+
let stopped = false;
|
|
4389
|
+
let insertedDuringRun = false;
|
|
4390
|
+
let stoppedDuringRun = false;
|
|
4391
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
4392
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
4393
|
+
let failureSeq = 0;
|
|
4394
|
+
function stuckState() {
|
|
4395
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
4396
|
+
if (!stuck.length) return void 0;
|
|
4397
|
+
return {
|
|
4398
|
+
commandIds: stuck.map(([id2]) => id2),
|
|
4399
|
+
since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
4400
|
+
reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
|
|
4401
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
|
|
4402
|
+
};
|
|
4403
|
+
}
|
|
4404
|
+
function serverFailed(commands, reason) {
|
|
4405
|
+
const at2 = now();
|
|
4406
|
+
for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
|
|
4407
|
+
}
|
|
4408
|
+
async function updateState(patch = {}) {
|
|
4409
|
+
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
4410
|
+
const sendable2 = /* @__PURE__ */ new Set();
|
|
4411
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
4412
|
+
if (clocks.size) for (const command of await readFresh(collection, {
|
|
4413
|
+
selector: { syncStatus: { $in: ["pending", "rejected"] } },
|
|
4414
|
+
sort: [{ seq: "asc" }, { key: "asc" }]
|
|
4415
|
+
})) {
|
|
4416
|
+
if (command.syncStatus === "rejected") blocked.add(command.registerId);
|
|
4417
|
+
else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
|
|
4418
|
+
}
|
|
4419
|
+
for (const id2 of clocks.keys()) if (!sendable2.has(id2)) clocks.delete(id2);
|
|
4420
|
+
state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
|
|
4421
|
+
}
|
|
4422
|
+
function scheduleRetry(reason, retryAfterMs = 0) {
|
|
4423
|
+
state$.next({ ...state$.value, lastRetryReason: reason, stuck: stuckState() });
|
|
4424
|
+
if (stopped) {
|
|
4425
|
+
stoppedDuringRun = true;
|
|
4426
|
+
return;
|
|
4427
|
+
}
|
|
4428
|
+
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
4429
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
4430
|
+
timer = setTimeout(() => {
|
|
4431
|
+
timer = void 0;
|
|
4432
|
+
flush().catch(() => {
|
|
4433
|
+
});
|
|
4434
|
+
}, delay);
|
|
4435
|
+
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
4436
|
+
}
|
|
4437
|
+
async function run() {
|
|
4438
|
+
stoppedDuringRun = false;
|
|
4439
|
+
while (!stopped) try {
|
|
4440
|
+
await updateState({ sending: true, nextAttemptAt: void 0 });
|
|
4441
|
+
insertedDuringRun = false;
|
|
4442
|
+
if (options.isEnabled?.() === false) return;
|
|
4443
|
+
const pending = await readFresh(collection, { selector: { syncStatus: "pending" } });
|
|
4444
|
+
const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
|
|
4445
|
+
let sent = false;
|
|
4446
|
+
for (const registerId of registerIds) {
|
|
4447
|
+
const ledger = await readFresh(collection, {
|
|
4448
|
+
selector: { registerId, syncStatus: { $in: ["pending", "rejected"] } },
|
|
4449
|
+
sort: [{ seq: "asc" }, { key: "asc" }]
|
|
4450
|
+
});
|
|
4451
|
+
const commands = [];
|
|
4452
|
+
for (const command of ledger) {
|
|
4453
|
+
if (command.syncStatus === "rejected") break;
|
|
4454
|
+
commands.push(command);
|
|
4455
|
+
if (commands.length >= batchSize) break;
|
|
4456
|
+
}
|
|
4457
|
+
if (stopped) {
|
|
4458
|
+
stoppedDuringRun = true;
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
if (!commands.length) continue;
|
|
4462
|
+
sent = true;
|
|
4463
|
+
const batch = commands.map((doc) => {
|
|
4464
|
+
const attempt2 = (attempts.get(doc.commandId) ?? 0) + 1;
|
|
4465
|
+
attempts.set(doc.commandId, attempt2);
|
|
4466
|
+
return {
|
|
4467
|
+
id: doc.commandId,
|
|
4468
|
+
type: doc.type,
|
|
4469
|
+
version: doc.version,
|
|
4470
|
+
payload: doc.payload,
|
|
4471
|
+
createdAt: doc.createdAt,
|
|
4472
|
+
deviceId,
|
|
4473
|
+
attempt: attempt2
|
|
4474
|
+
};
|
|
4475
|
+
});
|
|
4476
|
+
const outcome = await transport.send(batch);
|
|
4477
|
+
backendNotFound.record(outcome, now());
|
|
4478
|
+
const at2 = now();
|
|
4479
|
+
if (outcome.kind === "retry" && outcome.reason === "network") for (const clock of clocks.values()) clock.pausedAt ??= at2;
|
|
4480
|
+
else for (const clock of clocks.values()) if (clock.pausedAt !== void 0) {
|
|
4481
|
+
clock.since += at2 - clock.pausedAt;
|
|
4482
|
+
clock.pausedAt = void 0;
|
|
4483
|
+
}
|
|
4484
|
+
if (outcome.kind === "retry") {
|
|
4485
|
+
if (outcome.reason !== "network") serverFailed(commands, outcome.reason);
|
|
4486
|
+
return scheduleRetry(outcome.reason, outcome.retryAfterMs);
|
|
4487
|
+
}
|
|
4488
|
+
if (outcome.kind === "unauthorized") {
|
|
4489
|
+
unauthorizedSinceAccepted++;
|
|
4490
|
+
if (unauthorizedSinceAccepted < 3) return scheduleRetry("unauthorized");
|
|
4491
|
+
state$.next({
|
|
4492
|
+
...state$.value,
|
|
4493
|
+
authRequired: true,
|
|
4494
|
+
lastRetryReason: "unauthorized",
|
|
4495
|
+
sending: false,
|
|
4496
|
+
nextAttemptAt: void 0
|
|
4497
|
+
});
|
|
4498
|
+
return;
|
|
4499
|
+
}
|
|
4500
|
+
if (outcome.kind === "refused") {
|
|
4501
|
+
unauthorizedSinceAccepted = 0;
|
|
4502
|
+
state$.next({
|
|
4503
|
+
...state$.value,
|
|
4504
|
+
refused: { status: outcome.status, reason: outcome.reason },
|
|
4505
|
+
authRequired: false,
|
|
4506
|
+
lastRetryReason: "refused",
|
|
4507
|
+
sending: false,
|
|
4508
|
+
nextAttemptAt: void 0
|
|
4509
|
+
});
|
|
4510
|
+
return;
|
|
4511
|
+
}
|
|
4512
|
+
unauthorizedSinceAccepted = 0;
|
|
4513
|
+
state$.next({ ...state$.value, authRequired: false, refused: void 0 });
|
|
4514
|
+
let progressed = false;
|
|
4515
|
+
for (const command of commands) {
|
|
4516
|
+
const result = outcome.results.find((entry) => entry.id === command.commandId);
|
|
4517
|
+
if (!result) continue;
|
|
4518
|
+
const [stored] = await collection.storageInstance.findDocumentsById([command.key], false);
|
|
4519
|
+
if (stored?.syncStatus !== "pending" || stored.commandId !== command.commandId) continue;
|
|
4520
|
+
const current = await collection.findOne(command.key).exec();
|
|
4521
|
+
if (!current) continue;
|
|
4522
|
+
const error = result.error && {
|
|
4523
|
+
code: result.error.code,
|
|
4524
|
+
message: result.error.message,
|
|
4525
|
+
...result.error.data ? { data: result.error.data } : {}
|
|
4526
|
+
};
|
|
4527
|
+
const normalized = result.status === "duplicate" && error ? { id: result.id, status: "rejected", error } : { ...result, ...error ? { error } : {} };
|
|
4528
|
+
try {
|
|
4529
|
+
await options.onResult?.(plainCommand(stored), normalized);
|
|
4530
|
+
} catch (cause) {
|
|
4531
|
+
registerCommandsLogger.warn("Failed to apply register command result", { commandId: command.commandId, cause });
|
|
4532
|
+
}
|
|
4533
|
+
const updatedAt = new Date(now()).toISOString();
|
|
4534
|
+
await current.incrementalModify((row) => {
|
|
4535
|
+
if (row.syncStatus !== "pending" || row.commandId !== command.commandId) return row;
|
|
4536
|
+
return Object.assign(row, normalized.status === "rejected" ? { syncStatus: "rejected", error: normalized.error, updatedAt } : { syncStatus: "applied", ...normalized.register ? { result: normalized.register } : {}, updatedAt });
|
|
4537
|
+
});
|
|
4538
|
+
attempts.delete(command.commandId);
|
|
4539
|
+
progressed = true;
|
|
4540
|
+
await updateState();
|
|
4541
|
+
}
|
|
4542
|
+
if (!progressed) {
|
|
4543
|
+
serverFailed(commands, "no_progress");
|
|
4544
|
+
return scheduleRetry("no_progress");
|
|
4545
|
+
}
|
|
4546
|
+
backoff = initialBackoff;
|
|
4547
|
+
state$.next({ ...state$.value, lastRetryReason: void 0 });
|
|
4548
|
+
}
|
|
4549
|
+
if (!sent) return;
|
|
4550
|
+
} catch (error) {
|
|
4551
|
+
scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
|
|
4552
|
+
return;
|
|
4553
|
+
}
|
|
4554
|
+
stoppedDuringRun = true;
|
|
4555
|
+
}
|
|
4556
|
+
function flush() {
|
|
4557
|
+
if (running) return running;
|
|
4558
|
+
stopped = false;
|
|
4559
|
+
watchBackendMissing();
|
|
4560
|
+
clearTimeout(timer);
|
|
4561
|
+
timer = void 0;
|
|
4562
|
+
running = Promise.resolve().then(run).finally(async () => {
|
|
4563
|
+
try {
|
|
4564
|
+
await updateState({ sending: false });
|
|
4565
|
+
} catch {
|
|
4566
|
+
}
|
|
4567
|
+
running = void 0;
|
|
4568
|
+
if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === void 0) flush().catch(() => {
|
|
4569
|
+
});
|
|
4570
|
+
});
|
|
4571
|
+
return running;
|
|
4572
|
+
}
|
|
4573
|
+
updateState().catch(() => {
|
|
4574
|
+
});
|
|
4575
|
+
return {
|
|
4576
|
+
state$,
|
|
4577
|
+
flush,
|
|
4578
|
+
start() {
|
|
4579
|
+
stopped = false;
|
|
4580
|
+
watchBackendMissing();
|
|
3405
4581
|
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
3406
4582
|
if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE")) {
|
|
3407
4583
|
insertedDuringRun = true;
|
|
@@ -3418,15 +4594,17 @@ function createOrderOutbox(options) {
|
|
|
3418
4594
|
timer = void 0;
|
|
3419
4595
|
subscription?.unsubscribe();
|
|
3420
4596
|
subscription = void 0;
|
|
4597
|
+
missingSubscription?.unsubscribe();
|
|
4598
|
+
missingSubscription = void 0;
|
|
3421
4599
|
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
3422
4600
|
}
|
|
3423
4601
|
};
|
|
3424
4602
|
}
|
|
3425
4603
|
|
|
3426
4604
|
// src/outbox/use-order-outbox.ts
|
|
3427
|
-
import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
|
|
4605
|
+
import { useEffect as useEffect4, useMemo as useMemo4, useRef as useRef4, useState as useState4 } from "react";
|
|
3428
4606
|
var idle = { pending: 0, sending: false };
|
|
3429
|
-
var
|
|
4607
|
+
var noneStuck = [];
|
|
3430
4608
|
function useOrderOutbox(options) {
|
|
3431
4609
|
const { storeKey, deviceId } = options;
|
|
3432
4610
|
const latest = useRef4(options);
|
|
@@ -3452,7 +4630,15 @@ function useOrderOutbox(options) {
|
|
|
3452
4630
|
await store.close();
|
|
3453
4631
|
return;
|
|
3454
4632
|
}
|
|
3455
|
-
const outbox = createOrderOutbox({
|
|
4633
|
+
const outbox = createOrderOutbox({
|
|
4634
|
+
collection: store.orders,
|
|
4635
|
+
deviceId,
|
|
4636
|
+
transport: latest.current.transport(storeKey),
|
|
4637
|
+
getMaxOrderCreateVersion: latest.current.getMaxOrderCreateVersion ? () => latest.current.getMaxOrderCreateVersion?.() : void 0,
|
|
4638
|
+
refreshCapabilities: latest.current.refreshCapabilities ? async () => {
|
|
4639
|
+
await latest.current.refreshCapabilities?.();
|
|
4640
|
+
} : void 0
|
|
4641
|
+
});
|
|
3456
4642
|
current.current = { storeKey, orders: store.orders, outbox };
|
|
3457
4643
|
const status = outbox.state$.subscribe(setState);
|
|
3458
4644
|
const history = watchFresh(store.orders, { sort: [{ createdAt: "desc" }], limit: 50 }).subscribe(setRecent);
|
|
@@ -3479,6 +4665,8 @@ function useOrderOutbox(options) {
|
|
|
3479
4665
|
return () => latest.current.onBusy?.(false);
|
|
3480
4666
|
}, [state.sending]);
|
|
3481
4667
|
const ready = current.current?.storeKey === storeKey && !!storeKey;
|
|
4668
|
+
const stuckKey = ready ? state.stuck?.commandIds.join(",") ?? "" : "";
|
|
4669
|
+
const stuckCommandIds = useMemo4(() => stuckKey ? stuckKey.split(",") : noneStuck, [stuckKey]);
|
|
3482
4670
|
return {
|
|
3483
4671
|
orders: ready ? orders : null,
|
|
3484
4672
|
state: ready ? state : idle,
|
|
@@ -3492,6 +4680,7 @@ function useOrderOutbox(options) {
|
|
|
3492
4680
|
return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
|
|
3493
4681
|
},
|
|
3494
4682
|
savesInFlight: savesInFlightCount,
|
|
4683
|
+
stuckCommandIds,
|
|
3495
4684
|
async record(posOrder) {
|
|
3496
4685
|
savesInFlight.current++;
|
|
3497
4686
|
setSavesInFlightCount(savesInFlight.current);
|
|
@@ -3734,13 +4923,18 @@ export {
|
|
|
3734
4923
|
PosOrderOpenClosedError,
|
|
3735
4924
|
RegisterApprovalRequiredError,
|
|
3736
4925
|
RegisterCloseIncompleteError,
|
|
4926
|
+
RegisterIdInvalidError,
|
|
4927
|
+
RegisterMovementAmountError,
|
|
4928
|
+
RegisterMovementReasonError,
|
|
3737
4929
|
RegisterMovementStrandedError,
|
|
3738
4930
|
RegisterSessionAlreadyOpenError,
|
|
3739
4931
|
RegisterSessionClosedError,
|
|
3740
4932
|
RegisterSessionRequiredError,
|
|
3741
4933
|
RegisterTenderInProgressError,
|
|
3742
4934
|
SALE_SAVING,
|
|
4935
|
+
TAX_ROUNDING_MIXED_NOTE,
|
|
3743
4936
|
TaxProvider,
|
|
4937
|
+
UnsupportedOrderVersionError,
|
|
3744
4938
|
activePlan,
|
|
3745
4939
|
addEntryToCart,
|
|
3746
4940
|
addPosOrderCollection,
|
|
@@ -3759,16 +4953,19 @@ export {
|
|
|
3759
4953
|
clampClosureScope,
|
|
3760
4954
|
closeNeedsApproval,
|
|
3761
4955
|
closeSession,
|
|
4956
|
+
closureCommand,
|
|
3762
4957
|
closureSchema,
|
|
3763
4958
|
computeOrderTax,
|
|
3764
4959
|
consoleSink,
|
|
3765
4960
|
countFresh,
|
|
3766
4961
|
countVariance,
|
|
4962
|
+
createBackendNotFound,
|
|
3767
4963
|
createHttpCommandTransport,
|
|
3768
4964
|
createLogger,
|
|
3769
4965
|
createOrderBuilder,
|
|
3770
4966
|
createOrderManager,
|
|
3771
4967
|
createOrderOutbox,
|
|
4968
|
+
createRegisterOutbox,
|
|
3772
4969
|
createRepository,
|
|
3773
4970
|
denominationTotal,
|
|
3774
4971
|
denominations,
|
|
@@ -3790,6 +4987,7 @@ export {
|
|
|
3790
4987
|
minorToDecimal,
|
|
3791
4988
|
mintClosureNumber,
|
|
3792
4989
|
mintUuid,
|
|
4990
|
+
movementCommand,
|
|
3793
4991
|
movementFieldError,
|
|
3794
4992
|
needsAttention,
|
|
3795
4993
|
nextSaleCounter,
|
|
@@ -3809,8 +5007,12 @@ export {
|
|
|
3809
5007
|
readBoundRegister,
|
|
3810
5008
|
readFresh,
|
|
3811
5009
|
readRegister,
|
|
5010
|
+
reconcileRegisterCommands,
|
|
3812
5011
|
recordMovement,
|
|
3813
5012
|
recordRegisterFact,
|
|
5013
|
+
registerCommandCollection,
|
|
5014
|
+
registerCommandSchema,
|
|
5015
|
+
registerCommandsLogger,
|
|
3814
5016
|
registerFactsLogger,
|
|
3815
5017
|
registerSessionCollection,
|
|
3816
5018
|
registerSessionSchema,
|
|
@@ -3821,11 +5023,15 @@ export {
|
|
|
3821
5023
|
sameSale,
|
|
3822
5024
|
searchProducts,
|
|
3823
5025
|
selectClosureRows,
|
|
5026
|
+
sessionOpenCommand,
|
|
5027
|
+
sessionTransitionCommand,
|
|
3824
5028
|
stampSession,
|
|
3825
5029
|
startCounting,
|
|
3826
5030
|
stockOverlay$,
|
|
3827
5031
|
stockOverlayAsOf$,
|
|
5032
|
+
taxFiguresForBasket,
|
|
3828
5033
|
taxLinesByRate,
|
|
5034
|
+
taxLogger,
|
|
3829
5035
|
taxMicros,
|
|
3830
5036
|
taxProviderProps,
|
|
3831
5037
|
tenderReducer,
|