@tallyui/pos 2.0.0 → 3.0.0-next.1
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 +323 -61
- package/dist/index.js +1423 -186
- 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 +336 -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 +55 -9
- 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,40 +318,79 @@ 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 }, ...settings.taxRateCodes && { rateCodes: settings.taxRateCodes } };
|
|
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
|
+
var StoreCapabilitiesUnavailableError = class extends Error {
|
|
329
|
+
constructor(cause) {
|
|
330
|
+
super("Store capabilities unavailable", { cause });
|
|
331
|
+
this.name = "StoreCapabilitiesUnavailableError";
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var INITIAL_RETRY_MS = 5e3;
|
|
335
|
+
var MAX_RETRY_MS = 5 * 6e4;
|
|
336
|
+
function readTaxRounding({ connector, context }) {
|
|
337
|
+
if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
|
|
338
|
+
if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(void 0);
|
|
339
|
+
const read = connector.capabilities;
|
|
340
|
+
return Promise.resolve().then(() => read(context)).then(
|
|
341
|
+
(capabilities) => {
|
|
342
|
+
if (!capabilities) throw new StoreCapabilitiesUnavailableError();
|
|
343
|
+
return capabilities.taxRounding;
|
|
344
|
+
},
|
|
345
|
+
(cause) => {
|
|
346
|
+
throw new StoreCapabilitiesUnavailableError(cause);
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
}
|
|
276
350
|
function useStoreSettings(options) {
|
|
277
351
|
const { connector, context } = options;
|
|
278
352
|
const optionsRef = useRef(options);
|
|
279
353
|
optionsRef.current = options;
|
|
280
354
|
const requestRef = useRef(0);
|
|
355
|
+
const retryTimerRef = useRef(void 0);
|
|
356
|
+
const retryDelayRef = useRef(INITIAL_RETRY_MS);
|
|
281
357
|
const [state, setState] = useState(LOADING);
|
|
282
|
-
const resolve = useCallback2((choice, from) => {
|
|
358
|
+
const resolve = useCallback2((choice, from, known) => {
|
|
283
359
|
if (from !== void 0 && from !== requestRef.current) return;
|
|
360
|
+
clearTimeout(retryTimerRef.current);
|
|
284
361
|
const request = ++requestRef.current;
|
|
285
362
|
const current = () => request === requestRef.current;
|
|
286
363
|
setState(LOADING);
|
|
287
|
-
|
|
288
|
-
|
|
364
|
+
const rounding = known ?? readTaxRounding(optionsRef.current);
|
|
365
|
+
Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
|
|
366
|
+
([result, taxRounding]) => {
|
|
289
367
|
if (!current()) return;
|
|
290
|
-
|
|
368
|
+
retryDelayRef.current = INITIAL_RETRY_MS;
|
|
369
|
+
if (result.status === "ready")
|
|
370
|
+
setState({ state: "ready", settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
|
|
291
371
|
else if (result.status === "choose")
|
|
292
|
-
setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
|
|
372
|
+
setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request, rounding) });
|
|
293
373
|
else setState({ state: "unsupported" });
|
|
294
374
|
},
|
|
295
375
|
// A retry keeps the choice that failed: a new pick is saved only once it resolves.
|
|
296
376
|
(error) => {
|
|
297
|
-
if (current())
|
|
377
|
+
if (!current()) return;
|
|
378
|
+
const retry = () => resolve(choice, request);
|
|
379
|
+
if (error instanceof StoreCapabilitiesUnavailableError) {
|
|
380
|
+
const delay = retryDelayRef.current;
|
|
381
|
+
const nextRetryAt = Date.now() + delay;
|
|
382
|
+
retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
|
|
383
|
+
retryTimerRef.current = setTimeout(retry, delay);
|
|
384
|
+
setState({ state: "error", error, retry, nextRetryAt });
|
|
385
|
+
} else setState({ state: "error", error, retry });
|
|
298
386
|
}
|
|
299
387
|
);
|
|
300
388
|
}, []);
|
|
301
389
|
useEffect(() => {
|
|
302
390
|
resolve();
|
|
303
391
|
return () => {
|
|
392
|
+
clearTimeout(retryTimerRef.current);
|
|
393
|
+
retryDelayRef.current = INITIAL_RETRY_MS;
|
|
304
394
|
requestRef.current += 1;
|
|
305
395
|
};
|
|
306
396
|
}, [connector, context, resolve]);
|
|
@@ -397,6 +487,7 @@ function allocateOrderDiscount(lineAmountsMinor, totalMinor) {
|
|
|
397
487
|
}
|
|
398
488
|
|
|
399
489
|
// src/order/order-builder.ts
|
|
490
|
+
var perRateGroupWarned = /* @__PURE__ */ new WeakSet();
|
|
400
491
|
var nextId = 0;
|
|
401
492
|
function uid() {
|
|
402
493
|
return `${Date.now()}-${++nextId}`;
|
|
@@ -404,12 +495,15 @@ function uid() {
|
|
|
404
495
|
function roundHalfAway(n) {
|
|
405
496
|
return Math.sign(n) * Math.round(Math.abs(n));
|
|
406
497
|
}
|
|
407
|
-
function sumLines(lines) {
|
|
498
|
+
function sumLines(lines, rounding) {
|
|
499
|
+
const rounded = roundedTaxByRate(lines, rounding);
|
|
500
|
+
if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
|
|
501
|
+
const mode = rounding?.granularity === "per_order" ? rounding.mode : void 0;
|
|
408
502
|
const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
|
|
409
503
|
const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
|
|
410
504
|
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);
|
|
505
|
+
const taxMinor = roundMicrosToMinor(lineTaxMicros, mode);
|
|
506
|
+
const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros, mode);
|
|
413
507
|
return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
|
|
414
508
|
}
|
|
415
509
|
function toDisplayMode(line, amountMinor, displayInclusive) {
|
|
@@ -480,7 +574,11 @@ function createOrderBuilder(options) {
|
|
|
480
574
|
});
|
|
481
575
|
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
482
576
|
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
483
|
-
const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines);
|
|
577
|
+
const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
|
|
578
|
+
if (taxContext.rounding?.granularity === "per_rate_group_items" && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
|
|
579
|
+
perRateGroupWarned.add(taxContext);
|
|
580
|
+
taxLogger.warn("per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)");
|
|
581
|
+
}
|
|
484
582
|
const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
|
|
485
583
|
const taxInclusive = taxContext.pricesIncludeTax;
|
|
486
584
|
const figures = lines.map((li) => {
|
|
@@ -558,6 +656,7 @@ function createOrderBuilder(options) {
|
|
|
558
656
|
changeDueMinor,
|
|
559
657
|
currency,
|
|
560
658
|
pricesIncludeTax: taxContext.pricesIncludeTax,
|
|
659
|
+
...taxContext.rounding ? { taxRounding: taxContext.rounding } : {},
|
|
561
660
|
createdAt: now,
|
|
562
661
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
563
662
|
};
|
|
@@ -571,7 +670,8 @@ function createOrderBuilder(options) {
|
|
|
571
670
|
if (unitPrice.currency !== currency) throw new RangeError("Line currency must match " + currency);
|
|
572
671
|
if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
|
|
573
672
|
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
|
|
574
|
-
const
|
|
673
|
+
const code = input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
|
|
674
|
+
const taxRates = input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
575
675
|
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
576
676
|
const existing = lineItems.find(
|
|
577
677
|
(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 +729,8 @@ function createOrderBuilder(options) {
|
|
|
629
729
|
sku: variant?.sku ?? traits.getSku(doc),
|
|
630
730
|
imageUrl: traits.getImageUrl(doc),
|
|
631
731
|
unitPrice,
|
|
632
|
-
quantity: opts?.quantity
|
|
732
|
+
quantity: opts?.quantity,
|
|
733
|
+
taxClass: traits.getTaxClass?.(doc, opts?.variantId)
|
|
633
734
|
});
|
|
634
735
|
},
|
|
635
736
|
updateQuantity(lineId, quantity) {
|
|
@@ -711,8 +812,127 @@ function createOrderBuilder(options) {
|
|
|
711
812
|
};
|
|
712
813
|
}
|
|
713
814
|
|
|
815
|
+
// src/order/tax-figures.ts
|
|
816
|
+
function taxFiguresForBasket(currency, pricesIncludeTax, lines, rounding) {
|
|
817
|
+
const code = currency.toUpperCase();
|
|
818
|
+
const builder = createOrderBuilder({ currency: code, taxContext: { getTaxRatePpm: () => 0, pricesIncludeTax, ...rounding ? { rounding } : {} } });
|
|
819
|
+
lines.forEach((line, index) => {
|
|
820
|
+
const lineId = builder.addLine({
|
|
821
|
+
productId: `line-${index}`,
|
|
822
|
+
name: `Line ${index}`,
|
|
823
|
+
quantity: line.quantity,
|
|
824
|
+
unitPrice: { amount: line.unitPriceMinor, currency: code, taxInclusive: line.taxInclusive },
|
|
825
|
+
taxRates: line.taxLines
|
|
826
|
+
});
|
|
827
|
+
if (line.discountMinor) builder.applyLineDiscount(lineId, { type: "fixed", value: line.discountMinor });
|
|
828
|
+
});
|
|
829
|
+
const { subtotalMinor, taxMinor, totalMinor, lineItems, taxRounding } = builder.getSnapshot();
|
|
830
|
+
return { subtotalMinor, taxMinor, totalMinor, taxByRate: taxLinesByRate(lineItems, taxMinor, void 0, taxRounding) };
|
|
831
|
+
}
|
|
832
|
+
|
|
714
833
|
// src/order/order-manager.ts
|
|
715
834
|
import { BehaviorSubject as BehaviorSubject2, map as map2 } from "rxjs";
|
|
835
|
+
|
|
836
|
+
// src/pos-order/command.ts
|
|
837
|
+
var UnsupportedOrderVersionError = class extends Error {
|
|
838
|
+
constructor(needed, supported) {
|
|
839
|
+
super(`This sale needs order.create version ${needed}; the server supports up to ${supported}.`);
|
|
840
|
+
this.needed = needed;
|
|
841
|
+
this.supported = supported;
|
|
842
|
+
this.name = "UnsupportedOrderVersionError";
|
|
843
|
+
}
|
|
844
|
+
code = "UNSUPPORTED_ORDER_VERSION";
|
|
845
|
+
};
|
|
846
|
+
var PAYLOAD_STRING_MAX = 255;
|
|
847
|
+
var sendable = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !value.includes("\0");
|
|
848
|
+
function customerRefusal(customer) {
|
|
849
|
+
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;
|
|
850
|
+
}
|
|
851
|
+
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.`);
|
|
852
|
+
function cutText(text2, max) {
|
|
853
|
+
const clean = text2.replaceAll("\0", "");
|
|
854
|
+
if (clean.length <= max) return clean;
|
|
855
|
+
const high = clean.charCodeAt(max - 2);
|
|
856
|
+
return `${clean.slice(0, high >= 55296 && high <= 56319 ? max - 2 : max - 1)}\u2026`;
|
|
857
|
+
}
|
|
858
|
+
function netDiscountMinor(line, pricesIncludeTax) {
|
|
859
|
+
if (!(line.taxInclusive ?? pricesIncludeTax) || line.discountMinor === 0) return line.discountMinor;
|
|
860
|
+
const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
861
|
+
const taxAfter = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
|
|
862
|
+
const taxBefore = taxMicros(line.unitPriceMinor * line.quantity, ratePpm, true);
|
|
863
|
+
return roundMicrosToMinor(BigInt(line.discountMinor) * MICROS_PER_MINOR - taxBefore + taxAfter);
|
|
864
|
+
}
|
|
865
|
+
function contentVersion(order) {
|
|
866
|
+
return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
|
|
867
|
+
}
|
|
868
|
+
function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
|
|
869
|
+
const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
870
|
+
const content = contentVersion(order);
|
|
871
|
+
const cap = options?.maxVersion === void 0 ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
|
|
872
|
+
if (grossMinor > 0 && cap !== void 0 && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
|
|
873
|
+
const version = content === 3 && cap !== void 0 && cap >= 4 ? 4 : Math.min(content, cap ?? content);
|
|
874
|
+
const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
|
|
875
|
+
const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
|
|
876
|
+
const email = order.customer?.email;
|
|
877
|
+
const id2 = order.customer?.id;
|
|
878
|
+
const customerId = typeof id2 === "string" && id2.length > 0 && id2.length <= 64 ? id2 : void 0;
|
|
879
|
+
const sessionId = order.sessionId ?? order.lateSessionId;
|
|
880
|
+
return {
|
|
881
|
+
id: order.commandId,
|
|
882
|
+
type: "order.create",
|
|
883
|
+
version,
|
|
884
|
+
createdAt: order.createdAt,
|
|
885
|
+
deviceId,
|
|
886
|
+
attempt: attempt2,
|
|
887
|
+
payload: {
|
|
888
|
+
clientOrderId: order.id,
|
|
889
|
+
createdAt: order.createdAt,
|
|
890
|
+
currency: order.currency,
|
|
891
|
+
pricesIncludeTax: order.pricesIncludeTax,
|
|
892
|
+
lines: order.lines.map((line, i) => ({
|
|
893
|
+
clientLineId: line.id,
|
|
894
|
+
variantId: line.variantId ?? line.productId,
|
|
895
|
+
title: line.name,
|
|
896
|
+
quantity: line.quantity,
|
|
897
|
+
unitPriceMinor: line.unitPriceMinor,
|
|
898
|
+
...line.taxInclusive !== void 0 ? { taxInclusive: line.taxInclusive } : {},
|
|
899
|
+
...lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}
|
|
900
|
+
})),
|
|
901
|
+
payments: order.payments.map((payment) => ({
|
|
902
|
+
clientPaymentId: payment.id,
|
|
903
|
+
method: payment.method,
|
|
904
|
+
amountMinor: payment.amountMinor,
|
|
905
|
+
...payment.tenderedMinor !== void 0 ? { tenderedMinor: payment.tenderedMinor } : {},
|
|
906
|
+
...payment.changeMinor !== void 0 ? { changeMinor: payment.changeMinor } : {},
|
|
907
|
+
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
908
|
+
})),
|
|
909
|
+
subtotalMinor: order.subtotalMinor,
|
|
910
|
+
...discountMinor > 0 ? { discountMinor } : {},
|
|
911
|
+
taxMinor: order.taxMinor,
|
|
912
|
+
totalMinor: order.totalMinor,
|
|
913
|
+
...version >= 3 ? {
|
|
914
|
+
display: { ...order.display, lines: order.display.lines.map(({ lineId, amountMinor, discounts }) => ({
|
|
915
|
+
clientLineId: lineId,
|
|
916
|
+
amountMinor,
|
|
917
|
+
discounts
|
|
918
|
+
})) },
|
|
919
|
+
taxByRate: order.taxByRate.map(({ ratePpm, code, netMinor, amountMinor, grossMinor: grossMinor2 }) => ({
|
|
920
|
+
ratePpm,
|
|
921
|
+
...code !== void 0 ? { code } : {},
|
|
922
|
+
netMinor,
|
|
923
|
+
taxMinor: amountMinor,
|
|
924
|
+
grossMinor: grossMinor2
|
|
925
|
+
}))
|
|
926
|
+
} : {},
|
|
927
|
+
customer: version >= 3 ? email || customerId ? { ...email ? { email } : {}, ...customerId ? { customerId } : {} } : null : email ? { email } : null,
|
|
928
|
+
...order.registerId !== void 0 ? { registerId: order.registerId } : {},
|
|
929
|
+
...order.cashierRef !== void 0 ? { cashierRef: order.cashierRef } : {},
|
|
930
|
+
...version >= 3 && typeof sessionId === "string" && sessionId.length > 0 && sessionId.length <= 36 ? { sessionId } : {}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/order/order-manager.ts
|
|
716
936
|
function createOrderManager(options) {
|
|
717
937
|
const { taxContext, draftsCollection } = options;
|
|
718
938
|
const currency = options.currency.toUpperCase();
|
|
@@ -765,7 +985,7 @@ function createOrderManager(options) {
|
|
|
765
985
|
taxContext,
|
|
766
986
|
id: savedOrder.id
|
|
767
987
|
});
|
|
768
|
-
if (savedOrder.customer) {
|
|
988
|
+
if (savedOrder.customer && !customerRefusal(savedOrder.customer)) {
|
|
769
989
|
builder.setCustomer(savedOrder.customer);
|
|
770
990
|
}
|
|
771
991
|
if (savedOrder.note) {
|
|
@@ -822,7 +1042,7 @@ function createOrderManager(options) {
|
|
|
822
1042
|
|
|
823
1043
|
// src/receipt/build-receipt-data.ts
|
|
824
1044
|
function buildReceiptData(order, config) {
|
|
825
|
-
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
|
|
1045
|
+
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
|
|
826
1046
|
({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
|
|
827
1047
|
);
|
|
828
1048
|
let taxSoFar = order.lineItems.reduce(
|
|
@@ -836,6 +1056,7 @@ function buildReceiptData(order, config) {
|
|
|
836
1056
|
return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
|
|
837
1057
|
});
|
|
838
1058
|
const label = (d) => d.label ?? d.couponCode ?? `${d.type} discount`;
|
|
1059
|
+
const customer = order.customer?.name || order.customer?.email;
|
|
839
1060
|
return {
|
|
840
1061
|
header: {
|
|
841
1062
|
storeName: config.storeName,
|
|
@@ -843,6 +1064,7 @@ function buildReceiptData(order, config) {
|
|
|
843
1064
|
orderNumber: order.id,
|
|
844
1065
|
date: order.createdAt,
|
|
845
1066
|
cashier: config.cashier,
|
|
1067
|
+
...customer ? { customer } : {},
|
|
846
1068
|
register: config.register
|
|
847
1069
|
},
|
|
848
1070
|
lineItems: order.lineItems.map((li, index) => ({
|
|
@@ -887,7 +1109,11 @@ function buildReceiptData(order, config) {
|
|
|
887
1109
|
function searchProducts(docs, term, traits) {
|
|
888
1110
|
const query = term.trim().toLowerCase();
|
|
889
1111
|
if (!query) return docs;
|
|
890
|
-
const codes = (doc) => [
|
|
1112
|
+
const codes = (doc) => [...new Set([
|
|
1113
|
+
traits.getSku(doc),
|
|
1114
|
+
traits.getBarcode(doc),
|
|
1115
|
+
...(traits.getVariants?.(doc) ?? []).flatMap((variant) => [variant.sku, variant.barcode])
|
|
1116
|
+
].filter((code) => Boolean(code)).map((code) => code.toLowerCase()))];
|
|
891
1117
|
const exact = docs.filter((doc) => codes(doc).includes(query));
|
|
892
1118
|
if (exact.length) return exact;
|
|
893
1119
|
const words = query.split(/\s+/);
|
|
@@ -913,7 +1139,7 @@ function stockOverlayAsOf$(collection) {
|
|
|
913
1139
|
}
|
|
914
1140
|
|
|
915
1141
|
// src/sale/use-sale.ts
|
|
916
|
-
import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
1142
|
+
import { useEffect as useEffect3, useLayoutEffect, useRef as useRef3, useState as useState3 } from "react";
|
|
917
1143
|
|
|
918
1144
|
// src/pos-order/uuidv7.ts
|
|
919
1145
|
function format(ms, bytes) {
|
|
@@ -965,6 +1191,72 @@ function uuidv7(now, randomBytes) {
|
|
|
965
1191
|
}
|
|
966
1192
|
|
|
967
1193
|
// src/pos-order/finalize.ts
|
|
1194
|
+
import { minorUnitDigits } from "@tallyui/core";
|
|
1195
|
+
|
|
1196
|
+
// src/outbox/logger.ts
|
|
1197
|
+
var outboxLogger = createLogger("outbox");
|
|
1198
|
+
|
|
1199
|
+
// src/pos-order/finalize.ts
|
|
1200
|
+
var MESSAGE_NAME_MAX = 61;
|
|
1201
|
+
function withSentForm(order, posOrder) {
|
|
1202
|
+
const customer = order.customer && { ...order.customer };
|
|
1203
|
+
if (customer && posOrder.customer?.email === void 0) delete customer.email;
|
|
1204
|
+
if (customer && posOrder.customer?.id === void 0) delete customer.id;
|
|
1205
|
+
return {
|
|
1206
|
+
...order,
|
|
1207
|
+
customer,
|
|
1208
|
+
lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
|
|
1209
|
+
display: posOrder.display ? {
|
|
1210
|
+
...order.display,
|
|
1211
|
+
lines: order.display.lines.map((line, i) => ({
|
|
1212
|
+
...line,
|
|
1213
|
+
discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display.lines[i].discounts[j].label }))
|
|
1214
|
+
}))
|
|
1215
|
+
} : order.display
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function freezeSentForm(order) {
|
|
1219
|
+
let changed = false;
|
|
1220
|
+
const lines = order.lines.map((line) => {
|
|
1221
|
+
const name = cutText(line.name, PAYLOAD_STRING_MAX);
|
|
1222
|
+
changed ||= name !== line.name;
|
|
1223
|
+
return { ...line, name };
|
|
1224
|
+
});
|
|
1225
|
+
const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({
|
|
1226
|
+
...line,
|
|
1227
|
+
discounts: line.discounts.map((discount) => {
|
|
1228
|
+
if (discount.label === void 0) return discount;
|
|
1229
|
+
const label = cutText(discount.label, PAYLOAD_STRING_MAX);
|
|
1230
|
+
changed ||= label !== discount.label;
|
|
1231
|
+
return { ...discount, label };
|
|
1232
|
+
})
|
|
1233
|
+
})) };
|
|
1234
|
+
const payments = order.payments.map((payment) => {
|
|
1235
|
+
if (payment.reference === void 0) return payment;
|
|
1236
|
+
const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
|
|
1237
|
+
changed ||= reference !== payment.reference;
|
|
1238
|
+
return { ...payment, reference };
|
|
1239
|
+
});
|
|
1240
|
+
const customer = order.customer && { ...order.customer };
|
|
1241
|
+
const localWarnings = [...order.localWarnings ?? []];
|
|
1242
|
+
for (const [field, max] of [["email", 254], ["id", 64]]) {
|
|
1243
|
+
if (customer && field in customer && !sendable(customer[field], max)) {
|
|
1244
|
+
delete customer[field];
|
|
1245
|
+
changed = true;
|
|
1246
|
+
if (!localWarnings.some((warning) => warning.code === "customer_omitted" && warning.field === field)) localWarnings.push({ code: "customer_omitted", field });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return changed ? { ...order, lines, payments, customer, ...display ? { display } : {}, ...localWarnings.length ? { localWarnings } : {} } : order;
|
|
1250
|
+
}
|
|
1251
|
+
function referenceError(label, value) {
|
|
1252
|
+
const reason = referenceReason(value);
|
|
1253
|
+
return reason === "long" ? `${label} is too long (max ${PAYLOAD_STRING_MAX} characters)` : reason === "nul" ? `${label} contains a NUL character` : null;
|
|
1254
|
+
}
|
|
1255
|
+
function referenceReason(value) {
|
|
1256
|
+
if (value === void 0) return null;
|
|
1257
|
+
if (value.length > PAYLOAD_STRING_MAX) return "long";
|
|
1258
|
+
return value.includes("\0") ? "nul" : null;
|
|
1259
|
+
}
|
|
968
1260
|
function finalizeOrder(order, options = {}) {
|
|
969
1261
|
if (!order.lineItems.length) throw new Error("finalize: no lines");
|
|
970
1262
|
if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
|
|
@@ -983,6 +1275,24 @@ function finalizeOrder(order, options = {}) {
|
|
|
983
1275
|
let change = order.paidMinor - order.totalMinor;
|
|
984
1276
|
const cash = order.payments.reduce((sum, p) => sum + (p.method === "cash" ? p.amountMinor : 0), 0);
|
|
985
1277
|
if (change > cash) throw new Error("finalize: change exceeds cash");
|
|
1278
|
+
const v3 = (options.capabilities?.orderCreate ?? 1) >= 3;
|
|
1279
|
+
const references = [
|
|
1280
|
+
["registerId", options.registerId],
|
|
1281
|
+
["cashierRef", options.cashierRef],
|
|
1282
|
+
...order.lineItems.flatMap((line, i) => {
|
|
1283
|
+
const name = `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
|
|
1284
|
+
return [
|
|
1285
|
+
line.variantId !== void 0 ? [`${name}: the variant id`, line.variantId] : [`${name}: the product id`, line.productId],
|
|
1286
|
+
...(v3 ? order.display.lines[i]?.discounts ?? [] : []).map((d) => [`${name}: the discount id`, d.discountId]),
|
|
1287
|
+
...(v3 ? line.taxLines : []).map((tax) => [`${name}: the tax code`, tax.code])
|
|
1288
|
+
];
|
|
1289
|
+
}),
|
|
1290
|
+
...order.payments.map((payment) => [`the ${payment.method} payment's reference`, payment.reference])
|
|
1291
|
+
];
|
|
1292
|
+
for (const [field, value] of references) {
|
|
1293
|
+
const message = referenceError(field, value);
|
|
1294
|
+
if (message) throw new Error(`finalize: ${message}`);
|
|
1295
|
+
}
|
|
986
1296
|
const newId = options.newId ?? uuidv7;
|
|
987
1297
|
const id2 = newId();
|
|
988
1298
|
const lines = order.lineItems.map((line) => ({
|
|
@@ -1004,6 +1314,18 @@ function finalizeOrder(order, options = {}) {
|
|
|
1004
1314
|
amountMinor: payment.amountMinor,
|
|
1005
1315
|
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
1006
1316
|
}));
|
|
1317
|
+
const localWarnings = options.localWarnings?.flatMap((warning) => {
|
|
1318
|
+
if (warning.code !== "payment_reference_dropped") return [{ ...warning }];
|
|
1319
|
+
const index = order.payments.findIndex((payment) => payment.id === warning.paymentId);
|
|
1320
|
+
if (index === -1) {
|
|
1321
|
+
try {
|
|
1322
|
+
outboxLogger.warn("finalize: dropped a localWarnings entry naming an unknown payment", { orderId: id2, paymentId: warning.paymentId });
|
|
1323
|
+
} catch {
|
|
1324
|
+
}
|
|
1325
|
+
return [];
|
|
1326
|
+
}
|
|
1327
|
+
return [{ ...warning, paymentId: payments[index].id }];
|
|
1328
|
+
});
|
|
1007
1329
|
for (let i = payments.length - 1; i >= 0; i--) {
|
|
1008
1330
|
const payment = payments[i];
|
|
1009
1331
|
if (payment.method !== "cash") continue;
|
|
@@ -1015,8 +1337,44 @@ function finalizeOrder(order, options = {}) {
|
|
|
1015
1337
|
if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
|
|
1016
1338
|
throw new Error("finalize: payments do not reconcile");
|
|
1017
1339
|
}
|
|
1340
|
+
let display;
|
|
1341
|
+
let taxByRate;
|
|
1342
|
+
if ((options.capabilities?.orderCreate ?? 1) >= 3) {
|
|
1343
|
+
if (order.display.lines.length !== order.lineItems.length || order.display.lines.some((line, i) => line.lineId !== order.lineItems[i].id)) {
|
|
1344
|
+
throw new Error("finalize: display lines do not match the order lines");
|
|
1345
|
+
}
|
|
1346
|
+
display = {
|
|
1347
|
+
currency: order.currency,
|
|
1348
|
+
exponent: minorUnitDigits(order.currency),
|
|
1349
|
+
...order.display,
|
|
1350
|
+
lines: order.display.lines.map((line, i) => ({
|
|
1351
|
+
lineId: lines[i].id,
|
|
1352
|
+
amountMinor: line.amountMinor,
|
|
1353
|
+
discounts: line.discounts.map(({ discountId, label, amountMinor }) => ({
|
|
1354
|
+
discountId,
|
|
1355
|
+
...label !== void 0 ? { label } : {},
|
|
1356
|
+
amountMinor
|
|
1357
|
+
}))
|
|
1358
|
+
}))
|
|
1359
|
+
};
|
|
1360
|
+
taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, void 0, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
|
|
1361
|
+
ratePpm,
|
|
1362
|
+
...code !== void 0 ? { code } : {},
|
|
1363
|
+
netMinor,
|
|
1364
|
+
amountMinor,
|
|
1365
|
+
grossMinor: netMinor + amountMinor
|
|
1366
|
+
}));
|
|
1367
|
+
if (display.totalMinor !== order.totalMinor || display.taxMinor !== order.taxMinor || display.taxInclusive !== order.pricesIncludeTax) {
|
|
1368
|
+
throw new Error("finalize: display does not match the order");
|
|
1369
|
+
}
|
|
1370
|
+
if (taxByRate.reduce((sum, rate) => sum + rate.amountMinor, 0) !== order.taxMinor) {
|
|
1371
|
+
throw new Error("finalize: tax by rate does not sum to the order tax");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
|
|
1375
|
+
const taxRounding = rounding.granularity === "custom" ? { granularity: "custom" } : { granularity: rounding.granularity, mode: rounding.mode };
|
|
1018
1376
|
const now = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1019
|
-
return {
|
|
1377
|
+
return freezeSentForm({
|
|
1020
1378
|
id: id2,
|
|
1021
1379
|
createdAt: now,
|
|
1022
1380
|
updatedAt: now,
|
|
@@ -1030,6 +1388,8 @@ function finalizeOrder(order, options = {}) {
|
|
|
1030
1388
|
discountMinor: order.discountMinor,
|
|
1031
1389
|
taxMinor: order.taxMinor,
|
|
1032
1390
|
totalMinor: order.totalMinor,
|
|
1391
|
+
taxRounding,
|
|
1392
|
+
...display && taxByRate ? { display, taxByRate } : {},
|
|
1033
1393
|
customer: order.customer ? {
|
|
1034
1394
|
id: order.customer.id,
|
|
1035
1395
|
name: order.customer.name,
|
|
@@ -1037,58 +1397,16 @@ function finalizeOrder(order, options = {}) {
|
|
|
1037
1397
|
} : null,
|
|
1038
1398
|
...order.note ? { note: order.note } : {},
|
|
1039
1399
|
...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
|
-
};
|
|
1400
|
+
...options.cashierRef !== void 0 ? { cashierRef: options.cashierRef } : {},
|
|
1401
|
+
...localWarnings?.length ? { localWarnings } : {}
|
|
1402
|
+
});
|
|
1085
1403
|
}
|
|
1086
1404
|
|
|
1087
1405
|
// src/pos-order/schema.ts
|
|
1088
1406
|
import { addRxPlugin } from "rxdb";
|
|
1089
1407
|
import { RxDBMigrationSchemaPlugin } from "rxdb/plugins/migration-schema";
|
|
1090
1408
|
var posOrderSchema = {
|
|
1091
|
-
version:
|
|
1409
|
+
version: 6,
|
|
1092
1410
|
primaryKey: "id",
|
|
1093
1411
|
type: "object",
|
|
1094
1412
|
additionalProperties: false,
|
|
@@ -1106,7 +1424,7 @@ var posOrderSchema = {
|
|
|
1106
1424
|
syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
|
|
1107
1425
|
note: { type: "string" },
|
|
1108
1426
|
registerId: { type: "string" },
|
|
1109
|
-
sessionId: { type: "string" },
|
|
1427
|
+
sessionId: { type: "string", maxLength: 36 },
|
|
1110
1428
|
cashierRef: { type: "string" },
|
|
1111
1429
|
lines: { type: "array", items: {
|
|
1112
1430
|
type: "object",
|
|
@@ -1153,7 +1471,23 @@ var posOrderSchema = {
|
|
|
1153
1471
|
additionalProperties: true
|
|
1154
1472
|
} },
|
|
1155
1473
|
error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" } }, required: ["code", "message"] },
|
|
1474
|
+
localWarnings: { type: "array", items: { type: "object", additionalProperties: false, properties: {
|
|
1475
|
+
code: { type: "string", maxLength: 64 },
|
|
1476
|
+
field: { type: "string", maxLength: 16 },
|
|
1477
|
+
paymentId: { type: "string", maxLength: 36 }
|
|
1478
|
+
}, required: ["code"] } },
|
|
1479
|
+
serverFailures: { type: "object", additionalProperties: false, properties: {
|
|
1480
|
+
since: { type: "integer", minimum: 0 },
|
|
1481
|
+
reason: { type: "string", maxLength: 64 },
|
|
1482
|
+
isolated: { type: "boolean" }
|
|
1483
|
+
}, required: ["since", "reason", "isolated"] },
|
|
1156
1484
|
lateSessionId: { type: "string" },
|
|
1485
|
+
taxRounding: { type: "object", additionalProperties: false, properties: {
|
|
1486
|
+
granularity: { type: "string", enum: ["per_order", "per_line_items", "per_rate_group_items", "custom"] },
|
|
1487
|
+
mode: { type: "string", enum: ["half_away_from_zero", "half_up"] }
|
|
1488
|
+
}, required: ["granularity"] },
|
|
1489
|
+
sentVersion: { type: "integer", minimum: 1, maximum: 4 },
|
|
1490
|
+
downgradedFrom: { type: "integer", minimum: 1, maximum: 4 },
|
|
1157
1491
|
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
1158
1492
|
display: { type: "object", additionalProperties: false, properties: {
|
|
1159
1493
|
currency: { type: "string" },
|
|
@@ -1208,14 +1542,26 @@ var posOrderSchema = {
|
|
|
1208
1542
|
"customer",
|
|
1209
1543
|
"syncStatus",
|
|
1210
1544
|
"commandId",
|
|
1211
|
-
"updatedAt"
|
|
1545
|
+
"updatedAt",
|
|
1546
|
+
"taxRounding"
|
|
1212
1547
|
],
|
|
1213
|
-
indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"]]
|
|
1548
|
+
indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"], "sessionId"]
|
|
1214
1549
|
};
|
|
1215
1550
|
function posOrderCollection() {
|
|
1216
1551
|
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
1217
1552
|
const identity2 = (doc) => doc;
|
|
1218
|
-
|
|
1553
|
+
const recordSent = (doc) => {
|
|
1554
|
+
doc.sentVersion ??= contentVersion(doc);
|
|
1555
|
+
return doc;
|
|
1556
|
+
};
|
|
1557
|
+
const recordRounding = (doc) => {
|
|
1558
|
+
doc.taxRounding ??= { ...DEFAULT_TAX_ROUNDING };
|
|
1559
|
+
return doc;
|
|
1560
|
+
};
|
|
1561
|
+
return {
|
|
1562
|
+
schema: posOrderSchema,
|
|
1563
|
+
migrationStrategies: { 1: identity2, 2: identity2, 3: identity2, 4: identity2, 5: recordSent, 6: recordRounding }
|
|
1564
|
+
};
|
|
1219
1565
|
}
|
|
1220
1566
|
|
|
1221
1567
|
// src/pos-order/open.ts
|
|
@@ -1253,9 +1599,10 @@ function waitWithTimeout(promise, ms) {
|
|
|
1253
1599
|
});
|
|
1254
1600
|
});
|
|
1255
1601
|
}
|
|
1256
|
-
async function writeOverStaleCopies(collection, from, to) {
|
|
1602
|
+
async function writeOverStaleCopies(collection, from, to, stopIfClosing) {
|
|
1257
1603
|
const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
|
|
1258
1604
|
for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0; page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
|
|
1605
|
+
stopIfClosing();
|
|
1259
1606
|
const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
|
|
1260
1607
|
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
1608
|
const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
|
|
@@ -1276,7 +1623,8 @@ async function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE
|
|
|
1276
1623
|
return opening;
|
|
1277
1624
|
}
|
|
1278
1625
|
async function openPosOrders(db, previous) {
|
|
1279
|
-
|
|
1626
|
+
let cancelled = false;
|
|
1627
|
+
const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
|
|
1280
1628
|
const stopIfClosing = () => {
|
|
1281
1629
|
if (closing()) throw new PosOrderOpenClosedError(db.name);
|
|
1282
1630
|
};
|
|
@@ -1286,7 +1634,11 @@ async function openPosOrders(db, previous) {
|
|
|
1286
1634
|
stopIfClosing();
|
|
1287
1635
|
const mustMigrate = await state.mustMigrate;
|
|
1288
1636
|
stopIfClosing();
|
|
1289
|
-
if (!mustMigrate)
|
|
1637
|
+
if (!mustMigrate) {
|
|
1638
|
+
await state.startMigration();
|
|
1639
|
+
stopIfClosing();
|
|
1640
|
+
return collection;
|
|
1641
|
+
}
|
|
1290
1642
|
await previous;
|
|
1291
1643
|
stopIfClosing();
|
|
1292
1644
|
await state.updateStatus((status2) => {
|
|
@@ -1310,9 +1662,18 @@ async function openPosOrders(db, previous) {
|
|
|
1310
1662
|
await (closing() ? checkpoint.close() : checkpoint.remove());
|
|
1311
1663
|
stopIfClosing();
|
|
1312
1664
|
const settled = new Subject();
|
|
1665
|
+
let inFlight = 0;
|
|
1666
|
+
let drained;
|
|
1667
|
+
const track = (result) => {
|
|
1668
|
+
if (!(result instanceof Promise)) return result;
|
|
1669
|
+
inFlight++;
|
|
1670
|
+
return result.finally(() => {
|
|
1671
|
+
if (--inFlight === 0) drained?.();
|
|
1672
|
+
});
|
|
1673
|
+
};
|
|
1313
1674
|
const gate = (target, closed) => new Proxy(target, { get: (t, key) => {
|
|
1314
1675
|
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);
|
|
1676
|
+
return typeof value !== "function" ? value : (...args) => closing() && (key === "bulkWrite" || key === "findDocumentsById") ? closed(key, args[0]) : track(value.apply(t, args));
|
|
1316
1677
|
} });
|
|
1317
1678
|
const internalStore = gate(db.internalStore, async (key, first) => {
|
|
1318
1679
|
const ids = key === "bulkWrite" ? first.map((row) => row.document.id) : first;
|
|
@@ -1325,14 +1686,33 @@ async function openPosOrders(db, previous) {
|
|
|
1325
1686
|
state.database = new Proxy(db, { get: (target, key) => key === "internalStore" ? internalStore : Reflect.get(target, key) });
|
|
1326
1687
|
const migrateStorage = state.migrateStorage.bind(state);
|
|
1327
1688
|
state.migrateStorage = async (from, current, batchSize) => {
|
|
1689
|
+
stopIfClosing();
|
|
1328
1690
|
const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
|
|
1329
|
-
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
|
|
1691
|
+
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
|
|
1692
|
+
stopIfClosing();
|
|
1330
1693
|
return migrateStorage(new Proxy(from, { get: (target, key) => {
|
|
1331
1694
|
const value = key === "changeStream" ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
|
|
1332
1695
|
return typeof value === "function" ? value.bind(target) : value;
|
|
1333
1696
|
} }), to, batchSize);
|
|
1334
1697
|
};
|
|
1335
|
-
|
|
1698
|
+
let running = true;
|
|
1699
|
+
let onCancel;
|
|
1700
|
+
const cancelledRun = new Promise((resolve) => {
|
|
1701
|
+
onCancel = resolve;
|
|
1702
|
+
});
|
|
1703
|
+
const cancel = state.cancel.bind(state);
|
|
1704
|
+
state.cancel = () => {
|
|
1705
|
+
if (running) {
|
|
1706
|
+
cancelled = true;
|
|
1707
|
+
if (inFlight === 0) onCancel();
|
|
1708
|
+
else drained = onCancel;
|
|
1709
|
+
}
|
|
1710
|
+
return cancel();
|
|
1711
|
+
};
|
|
1712
|
+
await Promise.race([state.startMigration(), cancelledRun]).finally(() => {
|
|
1713
|
+
running = false;
|
|
1714
|
+
settled.next();
|
|
1715
|
+
});
|
|
1336
1716
|
stopIfClosing();
|
|
1337
1717
|
const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data;
|
|
1338
1718
|
stopIfClosing();
|
|
@@ -1363,8 +1743,9 @@ function getDeviceId(storage, key) {
|
|
|
1363
1743
|
}
|
|
1364
1744
|
|
|
1365
1745
|
// src/pos-order/needs-attention.ts
|
|
1366
|
-
|
|
1367
|
-
|
|
1746
|
+
import { knownWarnings } from "@tallyui/core";
|
|
1747
|
+
function needsAttention(orders, options = {}) {
|
|
1748
|
+
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
1749
|
}
|
|
1369
1750
|
|
|
1370
1751
|
// src/pos-order/same-sale.ts
|
|
@@ -1586,6 +1967,7 @@ var closureSchema = {
|
|
|
1586
1967
|
// src/register/register-document.ts
|
|
1587
1968
|
import { map as map4 } from "rxjs";
|
|
1588
1969
|
var REGISTER = "register";
|
|
1970
|
+
var MAX_REGISTER_ID_LENGTH = 36;
|
|
1589
1971
|
function mintUuid() {
|
|
1590
1972
|
const id2 = globalThis.crypto?.randomUUID?.();
|
|
1591
1973
|
if (id2) return id2;
|
|
@@ -1637,7 +2019,16 @@ async function readBoundRegister(host, storeKey) {
|
|
|
1637
2019
|
const id2 = getBoundRegisterId(register, storeKey);
|
|
1638
2020
|
return id2 ? { id: id2, name: register?.stores[storeKey]?.register_name ?? "" } : null;
|
|
1639
2021
|
}
|
|
1640
|
-
|
|
2022
|
+
var RegisterIdInvalidError = class extends Error {
|
|
2023
|
+
constructor() {
|
|
2024
|
+
super("register_id_invalid");
|
|
2025
|
+
this.name = "RegisterIdInvalidError";
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
async function bindRegister(host, storeKey, register) {
|
|
2029
|
+
if (typeof register.id === "string" && (register.id.length === 0 || register.id.length > MAX_REGISTER_ID_LENGTH)) {
|
|
2030
|
+
throw new RegisterIdInvalidError();
|
|
2031
|
+
}
|
|
1641
2032
|
return modifyStore(host, storeKey, (store) => ({
|
|
1642
2033
|
...store ?? { sale_counter: 0 },
|
|
1643
2034
|
register_id: register.id,
|
|
@@ -1659,6 +2050,16 @@ function withRegister(store, registerId, bucket) {
|
|
|
1659
2050
|
const base = store ?? { sale_counter: 0 };
|
|
1660
2051
|
return { ...base, registers: { ...base.registers, [registerId]: bucket } };
|
|
1661
2052
|
}
|
|
2053
|
+
async function markCommandsSince(host, storeKey, registerId, now) {
|
|
2054
|
+
let since = now;
|
|
2055
|
+
await modifyStore(host, storeKey, (store) => {
|
|
2056
|
+
const register = store?.registers?.[registerId] ?? {};
|
|
2057
|
+
since = register.commands_since ?? now;
|
|
2058
|
+
if (register.commands_since !== void 0) return void 0;
|
|
2059
|
+
return withRegister(store, registerId, { ...register, commands_since: since });
|
|
2060
|
+
});
|
|
2061
|
+
return since;
|
|
2062
|
+
}
|
|
1662
2063
|
async function mintClosureNumber(host, storeKey, registerId, closure) {
|
|
1663
2064
|
let number = 0;
|
|
1664
2065
|
await modifyStore(host, storeKey, (store) => {
|
|
@@ -1802,6 +2203,18 @@ var RegisterSessionClosedError = class extends Error {
|
|
|
1802
2203
|
this.name = "RegisterSessionClosedError";
|
|
1803
2204
|
}
|
|
1804
2205
|
};
|
|
2206
|
+
var RegisterMovementAmountError = class extends Error {
|
|
2207
|
+
constructor(type, amountMinor) {
|
|
2208
|
+
super(`movement_amount_invalid:${type}:${amountMinor}`);
|
|
2209
|
+
this.name = "RegisterMovementAmountError";
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
var RegisterMovementReasonError = class extends Error {
|
|
2213
|
+
constructor() {
|
|
2214
|
+
super("movement_reason_invalid");
|
|
2215
|
+
this.name = "RegisterMovementReasonError";
|
|
2216
|
+
}
|
|
2217
|
+
};
|
|
1805
2218
|
var RegisterMovementStrandedError = class extends Error {
|
|
1806
2219
|
id;
|
|
1807
2220
|
session_id;
|
|
@@ -1933,13 +2346,16 @@ async function closeSession(sessions, id2, input) {
|
|
|
1933
2346
|
});
|
|
1934
2347
|
}
|
|
1935
2348
|
async function recordMovement(sessions, movements, closures, input) {
|
|
2349
|
+
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);
|
|
2350
|
+
const reasonLength = input.reason.trim().length;
|
|
2351
|
+
if (reasonLength === 0 || reasonLength > MAX_REASON_LENGTH) throw new RegisterMovementReasonError();
|
|
1936
2352
|
await requireLiveSession(sessions, input.sessionId);
|
|
1937
2353
|
const row = await movements.insert({
|
|
1938
2354
|
id: mintUuid(),
|
|
1939
2355
|
session_id: input.sessionId,
|
|
1940
2356
|
type: input.type,
|
|
1941
2357
|
amountMinor: input.amountMinor,
|
|
1942
|
-
reason: input.reason,
|
|
2358
|
+
reason: input.reason.trim(),
|
|
1943
2359
|
created_by: input.actor,
|
|
1944
2360
|
created_at_gmt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1945
2361
|
});
|
|
@@ -2041,9 +2457,13 @@ async function writeClosure({
|
|
|
2041
2457
|
payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row2.amountMinor, refunds_minor: 0 };
|
|
2042
2458
|
}
|
|
2043
2459
|
const taxRates = /* @__PURE__ */ new Map();
|
|
2460
|
+
const roundings = /* @__PURE__ */ new Set();
|
|
2044
2461
|
for (const order of bound) {
|
|
2462
|
+
const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
|
|
2463
|
+
const { granularity, mode } = recorded.granularity === "custom" ? DEFAULT_TAX_ROUNDING : recorded;
|
|
2464
|
+
roundings.add(`${granularity} ${mode}`);
|
|
2045
2465
|
const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
|
|
2046
|
-
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor)) {
|
|
2466
|
+
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, void 0, order.taxRounding)) {
|
|
2047
2467
|
const existing2 = taxRates.get(String(ratePpm));
|
|
2048
2468
|
taxRates.set(String(ratePpm), {
|
|
2049
2469
|
ratePpm,
|
|
@@ -2089,6 +2509,8 @@ async function writeClosure({
|
|
|
2089
2509
|
{ name: `Tax ${ratePpm / 1e4}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor }
|
|
2090
2510
|
])
|
|
2091
2511
|
),
|
|
2512
|
+
// The session's sales used more than one tax rounding (#287): the Z report says so.
|
|
2513
|
+
...roundings.size > 1 ? { tax_rounding_mixed: true } : {},
|
|
2092
2514
|
opening_float: {
|
|
2093
2515
|
expected_minor: session.expected_float_minor ?? null,
|
|
2094
2516
|
counted_minor: session.counted_float_minor,
|
|
@@ -2311,6 +2733,7 @@ function selectClosureRows(rows, scope, timezone) {
|
|
|
2311
2733
|
}
|
|
2312
2734
|
|
|
2313
2735
|
// src/register/closure-document.ts
|
|
2736
|
+
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
2737
|
var decimalMap = (minor2, exponent) => Object.fromEntries(Object.entries(minor2).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
|
|
2315
2738
|
function decimalizeBreakdowns(breakdowns, exponent) {
|
|
2316
2739
|
const dec = (m) => typeof m === "number" ? minorToDecimal(m, exponent) : m;
|
|
@@ -2412,6 +2835,7 @@ function envelope(row, context, xreport = false) {
|
|
|
2412
2835
|
...Object.fromEntries(
|
|
2413
2836
|
["payment_methods", "tax_rates", "movements"].map((key) => [`has_${key}`, Object.keys(breakdowns[key] ?? {}).length > 0])
|
|
2414
2837
|
),
|
|
2838
|
+
...breakdowns.tax_rounding_mixed === true ? { tax_rounding_note: TAX_ROUNDING_MIXED_NOTE } : {},
|
|
2415
2839
|
...money(row, ["period_sales_total", "period_refunds_total", "perpetual_sales_total", "perpetual_refunds_total", "unsynced_total"]),
|
|
2416
2840
|
opened_at_gmt: row.opened_at,
|
|
2417
2841
|
opened_by: breakdowns.opened_by ?? null,
|
|
@@ -2530,6 +2954,155 @@ function buildXReportDocument(session, context) {
|
|
|
2530
2954
|
// src/register/use-register-session.ts
|
|
2531
2955
|
import { useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
|
|
2532
2956
|
import { combineLatest, map as map5, of, switchMap } from "rxjs";
|
|
2957
|
+
|
|
2958
|
+
// src/register/register-commands.ts
|
|
2959
|
+
var registerCommandsLogger = createLogger("register-commands");
|
|
2960
|
+
var registerCommandSchema = {
|
|
2961
|
+
title: "Register commands",
|
|
2962
|
+
version: 0,
|
|
2963
|
+
primaryKey: "key",
|
|
2964
|
+
type: "object",
|
|
2965
|
+
additionalProperties: false,
|
|
2966
|
+
properties: {
|
|
2967
|
+
key: { type: "string", maxLength: 100 },
|
|
2968
|
+
registerId: { type: "string", maxLength: 36 },
|
|
2969
|
+
seq: { type: "integer", minimum: 0, maximum: 1e15, multipleOf: 1 },
|
|
2970
|
+
commandId: { type: "string" },
|
|
2971
|
+
type: { type: "string" },
|
|
2972
|
+
version: { type: "integer" },
|
|
2973
|
+
payload: { type: "object", additionalProperties: true },
|
|
2974
|
+
createdAt: { type: "string" },
|
|
2975
|
+
updatedAt: { type: "string" },
|
|
2976
|
+
syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
|
|
2977
|
+
error: { type: "object", properties: {
|
|
2978
|
+
code: { type: "string" },
|
|
2979
|
+
message: { type: "string" },
|
|
2980
|
+
data: { type: "object", additionalProperties: true }
|
|
2981
|
+
}, required: ["code", "message"], additionalProperties: false },
|
|
2982
|
+
result: { type: "object", additionalProperties: true }
|
|
2983
|
+
},
|
|
2984
|
+
required: ["key", "registerId", "seq", "commandId", "type", "version", "payload", "createdAt", "syncStatus", "updatedAt"],
|
|
2985
|
+
indexes: [["registerId", "seq"], ["syncStatus", "registerId", "seq"]]
|
|
2986
|
+
};
|
|
2987
|
+
var registerCommandCollection = () => ({ schema: registerCommandSchema });
|
|
2988
|
+
function sessionOpenCommand(s) {
|
|
2989
|
+
return { key: `session.open:${s.id}`, type: "register.session.open", version: 1, payload: {
|
|
2990
|
+
sessionId: s.id,
|
|
2991
|
+
registerId: s.register_id,
|
|
2992
|
+
openedAt: s.opened_at_gmt,
|
|
2993
|
+
countedFloatMinor: s.counted_float_minor,
|
|
2994
|
+
...s.store_key == null ? {} : { storeKey: s.store_key },
|
|
2995
|
+
...s.business_day == null ? {} : { businessDay: s.business_day },
|
|
2996
|
+
...s.opened_by == null ? {} : { openedBy: s.opened_by },
|
|
2997
|
+
...s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor },
|
|
2998
|
+
...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }
|
|
2999
|
+
} };
|
|
3000
|
+
}
|
|
3001
|
+
function sessionTransitionCommand(s) {
|
|
3002
|
+
return { key: `session.transition:${s.id}:${s.status}:${s.status_at}`, type: "register.session.transition", version: 1, payload: {
|
|
3003
|
+
sessionId: s.id,
|
|
3004
|
+
status: s.status,
|
|
3005
|
+
at: s.status_at,
|
|
3006
|
+
...s.status !== "closed" || s.counted == null ? {} : { counted: s.counted },
|
|
3007
|
+
...s.status !== "closed" || s.closed_by == null ? {} : { closedBy: s.closed_by },
|
|
3008
|
+
...s.status !== "closed" || s.approved_by == null ? {} : { approvedBy: s.approved_by }
|
|
3009
|
+
} };
|
|
3010
|
+
}
|
|
3011
|
+
function movementCommand(m) {
|
|
3012
|
+
const common = {
|
|
3013
|
+
movementId: m.id,
|
|
3014
|
+
sessionId: m.session_id,
|
|
3015
|
+
createdAt: m.created_at_gmt,
|
|
3016
|
+
...m.created_by == null ? {} : { createdBy: m.created_by }
|
|
3017
|
+
};
|
|
3018
|
+
return m.type === "void" ? {
|
|
3019
|
+
key: `movement.void:${m.id}`,
|
|
3020
|
+
type: "register.movement.void",
|
|
3021
|
+
version: 1,
|
|
3022
|
+
payload: { ...common, voids: m.voids }
|
|
3023
|
+
} : {
|
|
3024
|
+
key: `movement.record:${m.id}`,
|
|
3025
|
+
type: "register.movement.record",
|
|
3026
|
+
version: 1,
|
|
3027
|
+
payload: { ...common, type: m.type, amountMinor: m.amountMinor, reason: m.reason }
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
function closureCommand(c) {
|
|
3031
|
+
return { key: `closure.submit:${c.id}`, type: "register.closure.submit", version: 1, payload: {
|
|
3032
|
+
closureId: c.id,
|
|
3033
|
+
sessionId: c.session_id,
|
|
3034
|
+
registerId: c.register_id,
|
|
3035
|
+
number: c.number,
|
|
3036
|
+
openedAt: c.opened_at,
|
|
3037
|
+
closedAt: c.closed_at,
|
|
3038
|
+
...c.business_day == null ? {} : { businessDay: c.business_day },
|
|
3039
|
+
...c.closed_by == null ? {} : { closedBy: c.closed_by },
|
|
3040
|
+
...typeof c.breakdowns.approved_by === "string" && c.breakdowns.approved_by ? { approvedBy: c.breakdowns.approved_by } : {},
|
|
3041
|
+
tillExpected: c.till_expected,
|
|
3042
|
+
counted: c.counted,
|
|
3043
|
+
periodSalesTotalMinor: c.period_sales_total_minor,
|
|
3044
|
+
periodRefundsTotalMinor: c.period_refunds_total_minor,
|
|
3045
|
+
perpetualSalesTotalMinor: c.perpetual_sales_total_minor,
|
|
3046
|
+
perpetualRefundsTotalMinor: c.perpetual_refunds_total_minor,
|
|
3047
|
+
unsyncedCount: c.unsynced_count,
|
|
3048
|
+
unsyncedTotalMinor: c.unsynced_total_minor,
|
|
3049
|
+
softwareVersion: c.software_version,
|
|
3050
|
+
orderIds: c.order_ids,
|
|
3051
|
+
movementIds: c.movement_ids
|
|
3052
|
+
} };
|
|
3053
|
+
}
|
|
3054
|
+
var chains = /* @__PURE__ */ new WeakMap();
|
|
3055
|
+
function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }) {
|
|
3056
|
+
let registers = chains.get(commands);
|
|
3057
|
+
if (!registers) chains.set(commands, registers = /* @__PURE__ */ new Map());
|
|
3058
|
+
const run = (registers.get(registerId) ?? Promise.resolve()).then(async () => {
|
|
3059
|
+
const since = await markCommandsSince(host, storeKey, registerId, now ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
3060
|
+
const [highest] = await readFresh(commands, { selector: { registerId }, sort: [{ seq: "desc" }], limit: 1 });
|
|
3061
|
+
let seq = highest?.seq ?? 0;
|
|
3062
|
+
const transitioned = new Set((await readFresh(commands, { selector: { registerId, type: "register.session.transition" } })).map(({ payload }) => payload.sessionId));
|
|
3063
|
+
const rows = await readFresh(sessions, { selector: { register_id: registerId } });
|
|
3064
|
+
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));
|
|
3065
|
+
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) => {
|
|
3066
|
+
const [entries, closureRows] = await Promise.all([
|
|
3067
|
+
readFresh(movements, { selector: { session_id: session.id } }),
|
|
3068
|
+
session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([])
|
|
3069
|
+
]);
|
|
3070
|
+
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 })]);
|
|
3071
|
+
events.push(
|
|
3072
|
+
...entries.filter((entry) => entry.type !== "void").map(movementCommand),
|
|
3073
|
+
...entries.filter((entry) => entry.type === "void").map(movementCommand)
|
|
3074
|
+
);
|
|
3075
|
+
if (session.status_at != null) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
|
|
3076
|
+
return { session, number: closureRows[0]?.number, built: [
|
|
3077
|
+
sessionOpenCommand(session),
|
|
3078
|
+
...events,
|
|
3079
|
+
...closureRows.map((closure) => closureCommand(closure))
|
|
3080
|
+
] };
|
|
3081
|
+
}));
|
|
3082
|
+
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));
|
|
3083
|
+
const existing = new Set((await commands.storageInstance.findDocumentsById(facts.flatMap(({ built }) => built.map(({ key }) => key)), false)).map(({ key }) => key));
|
|
3084
|
+
const appended = [];
|
|
3085
|
+
for (const { built } of facts) {
|
|
3086
|
+
for (const command of built) {
|
|
3087
|
+
if (existing.has(command.key)) continue;
|
|
3088
|
+
seq++;
|
|
3089
|
+
const at2 = now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3090
|
+
try {
|
|
3091
|
+
await commands.insert({ ...command, registerId, seq, commandId: uuidv7(), createdAt: at2, updatedAt: at2, syncStatus: "pending" });
|
|
3092
|
+
appended.push(command.key);
|
|
3093
|
+
} catch (error) {
|
|
3094
|
+
if (error?.code !== "CONFLICT") throw error;
|
|
3095
|
+
}
|
|
3096
|
+
existing.add(command.key);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
return appended;
|
|
3100
|
+
});
|
|
3101
|
+
registers.set(registerId, run.then(() => void 0, () => void 0));
|
|
3102
|
+
return run;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// src/register/use-register-session.ts
|
|
2533
3106
|
var RegisterTenderInProgressError = class extends Error {
|
|
2534
3107
|
constructor() {
|
|
2535
3108
|
super("Finish or cancel the sale in progress first.");
|
|
@@ -2581,6 +3154,25 @@ function ledgerRows(orders) {
|
|
|
2581
3154
|
}
|
|
2582
3155
|
function useRegisterSession(options) {
|
|
2583
3156
|
const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
|
|
3157
|
+
const { commands } = options;
|
|
3158
|
+
const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
|
|
3159
|
+
const commandTarget = useMemo3(
|
|
3160
|
+
() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
|
|
3161
|
+
[enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]
|
|
3162
|
+
);
|
|
3163
|
+
const commandTargetRef = useRef2(commandTarget);
|
|
3164
|
+
commandTargetRef.current = commandTarget;
|
|
3165
|
+
const reconcile = (row) => {
|
|
3166
|
+
const target = commandTargetRef.current;
|
|
3167
|
+
if (!target) return;
|
|
3168
|
+
void reconcileRegisterCommands({ ...target, observed: row ? [row] : void 0 }).catch((error) => {
|
|
3169
|
+
try {
|
|
3170
|
+
registerCommandsLogger.error("Register command reconcile failed", { context: { registerId: target.registerId, error: String(error) } });
|
|
3171
|
+
} catch {
|
|
3172
|
+
}
|
|
3173
|
+
});
|
|
3174
|
+
};
|
|
3175
|
+
useEffect2(reconcile, [commandTarget]);
|
|
2584
3176
|
const source = useMemo3(() => {
|
|
2585
3177
|
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
2586
3178
|
return combineLatest([
|
|
@@ -2733,18 +3325,29 @@ function useRegisterSession(options) {
|
|
|
2733
3325
|
return await run;
|
|
2734
3326
|
} finally {
|
|
2735
3327
|
openingByRegister.delete(key);
|
|
3328
|
+
reconcile();
|
|
2736
3329
|
}
|
|
2737
3330
|
},
|
|
2738
3331
|
startCounting: async () => {
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3332
|
+
let row;
|
|
3333
|
+
try {
|
|
3334
|
+
refuseDuringTender();
|
|
3335
|
+
row = await startCounting(live().sessions, current().id);
|
|
3336
|
+
recordRegisterFact({ kind: "counting-started", actor, sessionId: row.id, registerId: row.register_id });
|
|
3337
|
+
return row;
|
|
3338
|
+
} finally {
|
|
3339
|
+
reconcile(row?.toJSON());
|
|
3340
|
+
}
|
|
2743
3341
|
},
|
|
2744
3342
|
backToSelling: async () => {
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
3343
|
+
let row;
|
|
3344
|
+
try {
|
|
3345
|
+
row = await backToSelling(live().sessions, current().id);
|
|
3346
|
+
recordRegisterFact({ kind: "counting-abandoned", actor, sessionId: row.id, registerId: row.register_id });
|
|
3347
|
+
return row;
|
|
3348
|
+
} finally {
|
|
3349
|
+
reconcile(row?.toJSON());
|
|
3350
|
+
}
|
|
2748
3351
|
},
|
|
2749
3352
|
/**
|
|
2750
3353
|
* `counted` maps each tender to its counted minor units. An interrupted close resumes with
|
|
@@ -2821,38 +3424,47 @@ function useRegisterSession(options) {
|
|
|
2821
3424
|
} finally {
|
|
2822
3425
|
closingByRegister.delete(key);
|
|
2823
3426
|
notifyClosing();
|
|
3427
|
+
reconcile();
|
|
2824
3428
|
}
|
|
2825
3429
|
},
|
|
2826
3430
|
recordMovement: async (input) => {
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3431
|
+
try {
|
|
3432
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
3433
|
+
const sessionId = await requireOpen();
|
|
3434
|
+
const row = await recordMovement(sessions2, movements2, closures2, { ...input, sessionId, actor: actor.id });
|
|
3435
|
+
recordRegisterFact({
|
|
3436
|
+
kind: "movement-recorded",
|
|
3437
|
+
actor,
|
|
3438
|
+
sessionId: row.session_id,
|
|
3439
|
+
registerId: registerId2,
|
|
3440
|
+
movementId: row.id,
|
|
3441
|
+
movementType: row.type,
|
|
3442
|
+
amount: row.amountMinor
|
|
3443
|
+
});
|
|
3444
|
+
return row;
|
|
3445
|
+
} finally {
|
|
3446
|
+
reconcile();
|
|
3447
|
+
}
|
|
2840
3448
|
},
|
|
2841
3449
|
voidMovement: async (id2) => {
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
3450
|
+
try {
|
|
3451
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
3452
|
+
await requireOpen();
|
|
3453
|
+
const row = await voidMovement(sessions2, movements2, id2, actor.id, closures2);
|
|
3454
|
+
recordRegisterFact({
|
|
3455
|
+
kind: "movement-voided",
|
|
3456
|
+
actor,
|
|
3457
|
+
sessionId: row.session_id,
|
|
3458
|
+
registerId: registerId2,
|
|
3459
|
+
movementId: row.id,
|
|
3460
|
+
movementType: row.type,
|
|
3461
|
+
amount: row.amountMinor,
|
|
3462
|
+
voids: id2
|
|
3463
|
+
});
|
|
3464
|
+
return row;
|
|
3465
|
+
} finally {
|
|
3466
|
+
reconcile();
|
|
3467
|
+
}
|
|
2856
3468
|
}
|
|
2857
3469
|
}
|
|
2858
3470
|
};
|
|
@@ -2878,7 +3490,8 @@ function addEntryToCart(builder, entry, traits, currency) {
|
|
|
2878
3490
|
name,
|
|
2879
3491
|
sku: variant.sku ?? "",
|
|
2880
3492
|
imageUrl: traits.getImageUrl?.(product),
|
|
2881
|
-
unitPrice
|
|
3493
|
+
unitPrice,
|
|
3494
|
+
taxClass: traits.getTaxClass?.(product, variant.id)
|
|
2882
3495
|
});
|
|
2883
3496
|
}
|
|
2884
3497
|
|
|
@@ -2890,13 +3503,17 @@ var HUNG_SAVE_CHECK_MS = 5e3;
|
|
|
2890
3503
|
function useSale(settings, opts) {
|
|
2891
3504
|
const taxContext = useTax();
|
|
2892
3505
|
const madeWith = useRef3({ taxContext, currency: settings.currency });
|
|
2893
|
-
const [
|
|
2894
|
-
const
|
|
3506
|
+
const [rendered, setBuilder] = useState3(() => createOrderBuilder({ currency: settings.currency, taxContext }));
|
|
3507
|
+
const builderNow = useRef3(rendered);
|
|
3508
|
+
const [order, setOrder] = useState3(() => rendered.getSnapshot());
|
|
2895
3509
|
const [stage, setStage] = useState3({ kind: "cart" });
|
|
2896
|
-
const [
|
|
3510
|
+
const [saleError, setError] = useState3(null);
|
|
3511
|
+
const configError = referenceError("cashierRef", opts.cashierRef) ?? referenceError("registerId", opts.registerId);
|
|
2897
3512
|
const pending = useRef3(null);
|
|
2898
3513
|
const [saving, setSaving] = useState3(false);
|
|
3514
|
+
const error = saving && saleError ? saleError : configError ?? saleError;
|
|
2899
3515
|
const tenderSession = useRef3(null);
|
|
3516
|
+
const droppedReference = useRef3(null);
|
|
2900
3517
|
const confirmed = useRef3(null);
|
|
2901
3518
|
const [canContinue, setCanContinue] = useState3(false);
|
|
2902
3519
|
const attempts = useRef3(0);
|
|
@@ -2921,13 +3538,36 @@ function useSale(settings, opts) {
|
|
|
2921
3538
|
if (active) setError(SALE_SAVING);
|
|
2922
3539
|
return active;
|
|
2923
3540
|
}
|
|
3541
|
+
function resetSale(customer) {
|
|
3542
|
+
if (inFlight.current && !pending.current) return setError(SALE_SAVING);
|
|
3543
|
+
if (pending.current && confirmed.current !== pending.current) {
|
|
3544
|
+
if (inFlight.current) checkStored(pending.current);
|
|
3545
|
+
return setError(SALE_SAVING);
|
|
3546
|
+
}
|
|
3547
|
+
confirm(null);
|
|
3548
|
+
pending.current = null;
|
|
3549
|
+
inFlight.current = null;
|
|
3550
|
+
tenderSession.current = null;
|
|
3551
|
+
droppedReference.current = null;
|
|
3552
|
+
setSaving(false);
|
|
3553
|
+
madeWith.current = { taxContext, currency: settings.currency };
|
|
3554
|
+
const next = createOrderBuilder({ currency: settings.currency, taxContext });
|
|
3555
|
+
if (customer !== null) next.setCustomer(customer);
|
|
3556
|
+
builderNow.current = next;
|
|
3557
|
+
setBuilder(next);
|
|
3558
|
+
setOrder(next.getSnapshot());
|
|
3559
|
+
setStage({ kind: "cart" });
|
|
3560
|
+
setError(null);
|
|
3561
|
+
}
|
|
2924
3562
|
useEffect3(() => {
|
|
2925
|
-
const subscription =
|
|
3563
|
+
const subscription = rendered.order$.subscribe(setOrder);
|
|
2926
3564
|
return () => subscription.unsubscribe();
|
|
2927
|
-
}, [
|
|
3565
|
+
}, [rendered]);
|
|
2928
3566
|
const idle2 = stage.kind === "cart" && !order.lineItems.length;
|
|
2929
|
-
|
|
2930
|
-
|
|
3567
|
+
useLayoutEffect(() => {
|
|
3568
|
+
const live = builderNow.current.getSnapshot();
|
|
3569
|
+
const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
|
|
3570
|
+
if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
|
|
2931
3571
|
});
|
|
2932
3572
|
useEffect3(() => () => {
|
|
2933
3573
|
clearHungSaveTimer();
|
|
@@ -2940,10 +3580,19 @@ function useSale(settings, opts) {
|
|
|
2940
3580
|
}, []);
|
|
2941
3581
|
function setTender(tender) {
|
|
2942
3582
|
if (locked()) return;
|
|
3583
|
+
const reason = referenceReason(tender?.reference);
|
|
3584
|
+
const dropped = reason && (reason === "nul" ? "it contains a NUL character" : `it is over ${PAYLOAD_STRING_MAX} characters`);
|
|
3585
|
+
const kept = tender && dropped ? { method: tender.method, amountMinor: tender.amountMinor } : tender;
|
|
3586
|
+
const builder = builderNow.current;
|
|
2943
3587
|
const previous = builder.getSnapshot().payments[0];
|
|
2944
3588
|
if (previous) builder.removePayment(previous.id);
|
|
2945
|
-
|
|
2946
|
-
|
|
3589
|
+
const paymentId = kept ? builder.addPayment(kept) : null;
|
|
3590
|
+
droppedReference.current = dropped ? paymentId : null;
|
|
3591
|
+
setError(dropped && `The terminal's payment reference couldn't be kept (${dropped}); the payment is recorded without it.`);
|
|
3592
|
+
try {
|
|
3593
|
+
if (dropped) saleLogger.warn("the terminal's payment reference was dropped", { reason: dropped, method: tender.method });
|
|
3594
|
+
} catch {
|
|
3595
|
+
}
|
|
2947
3596
|
}
|
|
2948
3597
|
function checkStored(completion) {
|
|
2949
3598
|
const attempt2 = attempts.current;
|
|
@@ -2981,10 +3630,11 @@ function useSale(settings, opts) {
|
|
|
2981
3630
|
if (hungSaveTimer.current === timer) clearHungSaveTimer();
|
|
2982
3631
|
if (pending.current !== completion) return;
|
|
2983
3632
|
pending.current = null;
|
|
3633
|
+
droppedReference.current = null;
|
|
2984
3634
|
confirm(null);
|
|
2985
3635
|
setSaving(false);
|
|
2986
3636
|
setError(null);
|
|
2987
|
-
stageNow.current = { kind: "receipt", order: completion.order, posOrder: completion.posOrder };
|
|
3637
|
+
stageNow.current = { kind: "receipt", order: withSentForm(completion.order, completion.posOrder), posOrder: completion.posOrder };
|
|
2988
3638
|
setStage(stageNow.current);
|
|
2989
3639
|
}
|
|
2990
3640
|
function once(attempt2) {
|
|
@@ -3007,8 +3657,21 @@ function useSale(settings, opts) {
|
|
|
3007
3657
|
canContinue,
|
|
3008
3658
|
add(entry, traits) {
|
|
3009
3659
|
if (locked()) return;
|
|
3660
|
+
const builder = builderNow.current;
|
|
3010
3661
|
try {
|
|
3011
|
-
|
|
3662
|
+
const known = new Set(builder.getSnapshot().lineItems.map((line2) => line2.id));
|
|
3663
|
+
const lineId = addEntryToCart(builder, entry, traits, madeWith.current.currency);
|
|
3664
|
+
const line = builder.getSnapshot().lineItems.find((item) => item.id === lineId && !known.has(item.id));
|
|
3665
|
+
const name = line && `"${cutText(line.name, MESSAGE_NAME_MAX)}"`;
|
|
3666
|
+
const taxLines = line && (opts.capabilities?.orderCreate ?? 1) >= 3 ? line.taxLines : [];
|
|
3667
|
+
const refused = line && [
|
|
3668
|
+
line.variantId !== void 0 ? referenceError(`${name}: the variant id`, line.variantId) : referenceError(`${name}: the product id`, line.productId),
|
|
3669
|
+
...taxLines.map((tax) => referenceError(`${name}: the tax code`, tax.code))
|
|
3670
|
+
].find((message) => message !== null);
|
|
3671
|
+
if (refused) {
|
|
3672
|
+
builder.removeItem(lineId);
|
|
3673
|
+
return setError(refused);
|
|
3674
|
+
}
|
|
3012
3675
|
setError(null);
|
|
3013
3676
|
} catch (error2) {
|
|
3014
3677
|
if (!(error2 instanceof CartError)) throw error2;
|
|
@@ -3016,15 +3679,16 @@ function useSale(settings, opts) {
|
|
|
3016
3679
|
}
|
|
3017
3680
|
},
|
|
3018
3681
|
setQuantity(lineId, quantity) {
|
|
3019
|
-
if (!locked())
|
|
3682
|
+
if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
|
|
3020
3683
|
},
|
|
3021
3684
|
remove(lineId) {
|
|
3022
|
-
if (!locked())
|
|
3685
|
+
if (!locked()) builderNow.current.removeItem(lineId);
|
|
3023
3686
|
},
|
|
3024
3687
|
/** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
|
|
3025
3688
|
applyDiscount(lineId, discount) {
|
|
3026
3689
|
if (locked()) return SALE_SAVING;
|
|
3027
3690
|
if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
|
|
3691
|
+
const builder = builderNow.current;
|
|
3028
3692
|
const applied = (snapshot) => lineId === null ? snapshot.discounts : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
|
|
3029
3693
|
const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
|
|
3030
3694
|
if (lineId === null) builder.applyOrderDiscount(discount);
|
|
@@ -3037,7 +3701,15 @@ function useSale(settings, opts) {
|
|
|
3037
3701
|
return null;
|
|
3038
3702
|
},
|
|
3039
3703
|
removeDiscount(id2) {
|
|
3040
|
-
if (!locked())
|
|
3704
|
+
if (!locked()) builderNow.current.removeDiscount(id2);
|
|
3705
|
+
},
|
|
3706
|
+
/** the picked customer reaches the server as order.create v3's customer.customerId */
|
|
3707
|
+
setCustomer(customer) {
|
|
3708
|
+
if (locked()) return;
|
|
3709
|
+
const refused = customer && customerRefusal(customer);
|
|
3710
|
+
if (refused) return setError(refused);
|
|
3711
|
+
builderNow.current.setCustomer(customer);
|
|
3712
|
+
setError((current) => current !== null && CUSTOMER_REFUSALS.includes(current) ? null : current);
|
|
3041
3713
|
},
|
|
3042
3714
|
/**
|
|
3043
3715
|
* Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
|
|
@@ -3046,7 +3718,7 @@ function useSale(settings, opts) {
|
|
|
3046
3718
|
*/
|
|
3047
3719
|
startTender(method, options) {
|
|
3048
3720
|
if (locked()) return;
|
|
3049
|
-
const current =
|
|
3721
|
+
const current = builderNow.current.getSnapshot();
|
|
3050
3722
|
if (!current.lineItems.length) return;
|
|
3051
3723
|
tenderSession.current ??= { session: options?.session ?? opts.session };
|
|
3052
3724
|
setTender(method === "external" ? { method, amountMinor: current.totalMinor } : null);
|
|
@@ -3070,17 +3742,24 @@ function useSale(settings, opts) {
|
|
|
3070
3742
|
* and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
|
|
3071
3743
|
* again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
|
|
3072
3744
|
* 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.
|
|
3745
|
+
* another is in flight returns that call's promise, and a call on the receipt does nothing. While
|
|
3746
|
+
* `cashierRef` or `registerId` is out of bounds (shown as `error`), a call does nothing either,
|
|
3747
|
+
* unless it's the Retry of a pending completion, which was built with the options as they were.
|
|
3074
3748
|
*/
|
|
3075
|
-
complete: () => once(async () => {
|
|
3749
|
+
complete: () => configError && !pending.current && !inFlight.current ? Promise.resolve() : once(async () => {
|
|
3076
3750
|
attempts.current++;
|
|
3077
3751
|
confirm(null);
|
|
3078
3752
|
if (pending.current) return deliver(pending.current);
|
|
3079
3753
|
setSaving(true);
|
|
3080
|
-
const current =
|
|
3754
|
+
const current = builderNow.current.getSnapshot();
|
|
3081
3755
|
let posOrder;
|
|
3082
3756
|
try {
|
|
3083
|
-
posOrder = finalizeOrder(current, {
|
|
3757
|
+
posOrder = finalizeOrder(current, {
|
|
3758
|
+
registerId: opts.registerId,
|
|
3759
|
+
cashierRef: opts.cashierRef,
|
|
3760
|
+
capabilities: opts.capabilities,
|
|
3761
|
+
...droppedReference.current ? { localWarnings: [{ code: "payment_reference_dropped", paymentId: droppedReference.current }] } : {}
|
|
3762
|
+
});
|
|
3084
3763
|
} catch (error2) {
|
|
3085
3764
|
setSaving(false);
|
|
3086
3765
|
setError(error2.message);
|
|
@@ -3131,22 +3810,7 @@ function useSale(settings, opts) {
|
|
|
3131
3810
|
* updates or requeues `pos_orders` itself.
|
|
3132
3811
|
*/
|
|
3133
3812
|
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);
|
|
3813
|
+
resetSale(null);
|
|
3150
3814
|
},
|
|
3151
3815
|
/**
|
|
3152
3816
|
* Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
|
|
@@ -3222,11 +3886,11 @@ function createHttpCommandTransport(options) {
|
|
|
3222
3886
|
try {
|
|
3223
3887
|
body = await response.json();
|
|
3224
3888
|
} catch {
|
|
3225
|
-
return retry(controller.signal.aborted ? "
|
|
3889
|
+
return retry(controller.signal.aborted ? "timeout" : "bad_body");
|
|
3226
3890
|
}
|
|
3227
3891
|
return isCommandBatchResponse(body) ? { kind: "results", results: body.results } : retry("bad_body");
|
|
3228
3892
|
} catch {
|
|
3229
|
-
return { kind: "retry", reason: "network" };
|
|
3893
|
+
return { kind: "retry", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
3230
3894
|
} finally {
|
|
3231
3895
|
clearTimeout(timeout);
|
|
3232
3896
|
}
|
|
@@ -3235,9 +3899,36 @@ function createHttpCommandTransport(options) {
|
|
|
3235
3899
|
}
|
|
3236
3900
|
|
|
3237
3901
|
// src/outbox/order-outbox.ts
|
|
3902
|
+
import { deepEqual as deepEqual2 } from "rxdb";
|
|
3903
|
+
import { BehaviorSubject as BehaviorSubject4 } from "rxjs";
|
|
3904
|
+
|
|
3905
|
+
// src/outbox/backend-not-found.ts
|
|
3238
3906
|
import { BehaviorSubject as BehaviorSubject3 } from "rxjs";
|
|
3907
|
+
var NOT_FOUND_BEFORE_NOTICE = 3;
|
|
3908
|
+
function createBackendNotFound() {
|
|
3909
|
+
let count = 0;
|
|
3910
|
+
let since = 0;
|
|
3911
|
+
const backendMissing$ = new BehaviorSubject3(void 0);
|
|
3912
|
+
return {
|
|
3913
|
+
backendMissing$,
|
|
3914
|
+
record(outcome, at2) {
|
|
3915
|
+
if (outcome.kind === "retry" && outcome.reason === "network") return;
|
|
3916
|
+
if (outcome.kind === "retry" && outcome.reason === "status_404") {
|
|
3917
|
+
if (count++ === 0) since = at2;
|
|
3918
|
+
} else count = 0;
|
|
3919
|
+
const missing = count >= NOT_FOUND_BEFORE_NOTICE;
|
|
3920
|
+
if (missing !== !!backendMissing$.value) backendMissing$.next(missing ? { since } : void 0);
|
|
3921
|
+
}
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
// src/outbox/order-outbox.ts
|
|
3239
3926
|
var AUTH_FAILURES_BEFORE_PROMPT = 3;
|
|
3240
3927
|
var NOT_REQUEUEABLE = /* @__PURE__ */ new Set(["idempotency_mismatch"]);
|
|
3928
|
+
var REFUSAL_LOG_MESSAGE_MAX = 200;
|
|
3929
|
+
var ISOLATE_AFTER_ATTEMPTS = 5;
|
|
3930
|
+
var STUCK_AFTER_MS = 15 * 6e4;
|
|
3931
|
+
var validMax = (max) => typeof max === "number" && Number.isSafeInteger(max) && max > 0 ? max : void 0;
|
|
3241
3932
|
function createOrderOutbox(options) {
|
|
3242
3933
|
const { collection, transport, deviceId } = options;
|
|
3243
3934
|
const batchSize = Math.min(options.batchSize ?? 10, 10);
|
|
@@ -3245,7 +3936,13 @@ function createOrderOutbox(options) {
|
|
|
3245
3936
|
const maxBackoff = options.maxBackoffMs ?? 6e4;
|
|
3246
3937
|
const random = options.random ?? Math.random;
|
|
3247
3938
|
const now = options.now ?? Date.now;
|
|
3248
|
-
const state$ = new
|
|
3939
|
+
const state$ = new BehaviorSubject4({ pending: 0, sending: false });
|
|
3940
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
3941
|
+
let missingSubscription;
|
|
3942
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
3943
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
3944
|
+
});
|
|
3945
|
+
watchBackendMissing();
|
|
3249
3946
|
const attempts = /* @__PURE__ */ new Map();
|
|
3250
3947
|
let backoff = initialBackoff;
|
|
3251
3948
|
let unauthorizedSinceAccepted = 0;
|
|
@@ -3255,20 +3952,103 @@ function createOrderOutbox(options) {
|
|
|
3255
3952
|
let stopped = false;
|
|
3256
3953
|
let insertedDuringRun = false;
|
|
3257
3954
|
let stoppedDuringRun = false;
|
|
3955
|
+
const isolateAfter = options.isolateAfterAttempts ?? ISOLATE_AFTER_ATTEMPTS;
|
|
3956
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
3957
|
+
let head;
|
|
3958
|
+
let walk;
|
|
3959
|
+
const isolated = /* @__PURE__ */ new Map();
|
|
3960
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
3961
|
+
let failureSeq = 0;
|
|
3962
|
+
let turn = "batch";
|
|
3963
|
+
let isolatedWait = false;
|
|
3964
|
+
let runProgressed = false;
|
|
3965
|
+
let timerFiredDuringRun = false;
|
|
3966
|
+
let restored = false;
|
|
3967
|
+
function stuckState() {
|
|
3968
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
3969
|
+
if (!stuck.length) return void 0;
|
|
3970
|
+
return {
|
|
3971
|
+
commandIds: stuck.map(([id2]) => id2),
|
|
3972
|
+
since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
3973
|
+
reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
|
|
3974
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
|
|
3975
|
+
};
|
|
3976
|
+
}
|
|
3977
|
+
function isDue(id2) {
|
|
3978
|
+
const entry = isolated.get(id2);
|
|
3979
|
+
return !!entry && (entry.nextAt <= now() || entry.nextAt - now() > maxBackoff);
|
|
3980
|
+
}
|
|
3981
|
+
function failuresOf(commandId) {
|
|
3982
|
+
const clock = clocks.get(commandId);
|
|
3983
|
+
return clock && {
|
|
3984
|
+
since: clock.since,
|
|
3985
|
+
reason: clock.reason.slice(0, 64),
|
|
3986
|
+
isolated: isolated.has(commandId) || !!walk?.failed.has(commandId)
|
|
3987
|
+
};
|
|
3988
|
+
}
|
|
3989
|
+
async function mirror() {
|
|
3990
|
+
if (!clocks.size) return;
|
|
3991
|
+
for (const order of await readFresh(collection, { selector: { syncStatus: "pending", commandId: { $in: [...clocks.keys()] } } })) {
|
|
3992
|
+
const value = failuresOf(order.commandId);
|
|
3993
|
+
if (!value || deepEqual2(value, order.serverFailures)) continue;
|
|
3994
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
|
|
3995
|
+
if (data.syncStatus === "pending" && data.commandId === order.commandId) data.serverFailures = value;
|
|
3996
|
+
return data;
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
async function restore() {
|
|
4001
|
+
for (const { commandId, serverFailures } of await readFresh(collection, { selector: {
|
|
4002
|
+
syncStatus: "pending",
|
|
4003
|
+
serverFailures: { $exists: true }
|
|
4004
|
+
}, sort: [{ createdAt: "asc" }] })) if (serverFailures) {
|
|
4005
|
+
const { since, reason } = serverFailures;
|
|
4006
|
+
clocks.set(commandId, { since, pausedAt: now(), seq: ++failureSeq, reason });
|
|
4007
|
+
if (serverFailures.isolated) isolated.set(commandId, { backoff: initialBackoff, nextAt: now(), reason });
|
|
4008
|
+
}
|
|
4009
|
+
restored = true;
|
|
4010
|
+
}
|
|
4011
|
+
const bare = ({ serverFailures, _rev, _meta, ...data }) => data;
|
|
4012
|
+
const failuresOnly = (before, after) => !deepEqual2(before.serverFailures, after.serverFailures) && deepEqual2(bare(before), bare(after));
|
|
4013
|
+
const recordsSentForm = (before, after) => deepEqual2(bare({ ...freezeSentForm(before), sentVersion: before.sentVersion ?? after.sentVersion }), bare(after));
|
|
3258
4014
|
async function updateState(patch = {}) {
|
|
3259
4015
|
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
3260
|
-
|
|
4016
|
+
const rejected = await countFresh(collection, { syncStatus: "rejected" });
|
|
4017
|
+
state$.next({ ...state$.value, ...patch, pending, rejected, stuck: stuckState() });
|
|
3261
4018
|
}
|
|
3262
|
-
function
|
|
4019
|
+
function isolate(id2, reason, retryAfterMs = 0) {
|
|
4020
|
+
const entry = isolated.get(id2) ?? { backoff: initialBackoff };
|
|
4021
|
+
const delay = Math.min(Math.max(retryAfterMs, entry.backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
4022
|
+
isolated.set(id2, { reason, backoff: Math.min(entry.backoff * 2, maxBackoff), nextAt: now() + delay });
|
|
4023
|
+
}
|
|
4024
|
+
function serverFailed(orders, alone, reason, retryAfterMs) {
|
|
4025
|
+
const at2 = now();
|
|
4026
|
+
for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
|
|
4027
|
+
const id2 = orders[0].commandId;
|
|
4028
|
+
if (alone === "isolated") {
|
|
4029
|
+
isolate(id2, reason, retryAfterMs);
|
|
4030
|
+
if (runProgressed && retryAfterMs === void 0 && reason !== "status_429") return void 0;
|
|
4031
|
+
return Math.max(retryAfterMs ?? 0, isolated.get(id2).nextAt - now());
|
|
4032
|
+
}
|
|
4033
|
+
if (alone === "probe") walk?.failed.set(id2, reason);
|
|
4034
|
+
else {
|
|
4035
|
+
head = { id: id2, count: head?.id === id2 ? head.count + 1 : 1 };
|
|
4036
|
+
if (head.count >= isolateAfter) walk ??= { failed: /* @__PURE__ */ new Map() };
|
|
4037
|
+
}
|
|
4038
|
+
return retryAfterMs ?? 0;
|
|
4039
|
+
}
|
|
4040
|
+
function scheduleRetry(reason, retryAfterMs = 0, wait) {
|
|
3263
4041
|
state$.next({ ...state$.value, lastRetryReason: reason });
|
|
3264
4042
|
if (stopped) {
|
|
3265
4043
|
stoppedDuringRun = true;
|
|
3266
4044
|
return;
|
|
3267
4045
|
}
|
|
3268
|
-
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
3269
|
-
backoff = Math.min(backoff * 2, maxBackoff);
|
|
4046
|
+
const delay = wait ?? Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
4047
|
+
if (wait === void 0) backoff = Math.min(backoff * 2, maxBackoff);
|
|
4048
|
+
isolatedWait = wait !== void 0;
|
|
3270
4049
|
timer = setTimeout(() => {
|
|
3271
4050
|
timer = void 0;
|
|
4051
|
+
if (running) timerFiredDuringRun = true;
|
|
3272
4052
|
flush().catch(() => {
|
|
3273
4053
|
});
|
|
3274
4054
|
}, delay);
|
|
@@ -3276,26 +4056,89 @@ function createOrderOutbox(options) {
|
|
|
3276
4056
|
}
|
|
3277
4057
|
async function run() {
|
|
3278
4058
|
stoppedDuringRun = false;
|
|
4059
|
+
runProgressed = false;
|
|
4060
|
+
const sentAlone = /* @__PURE__ */ new Set();
|
|
3279
4061
|
while (!stopped) try {
|
|
4062
|
+
if (!restored) await restore();
|
|
3280
4063
|
await updateState({ sending: true, nextAttemptAt: void 0 });
|
|
3281
4064
|
insertedDuringRun = false;
|
|
3282
|
-
const
|
|
3283
|
-
|
|
4065
|
+
const tracked = [.../* @__PURE__ */ new Set([...isolated.keys(), ...clocks.keys()])];
|
|
4066
|
+
const held = tracked.length ? await readFresh(collection, {
|
|
4067
|
+
selector: { syncStatus: "pending", commandId: { $in: tracked } },
|
|
4068
|
+
sort: [{ createdAt: "asc" }]
|
|
4069
|
+
}) : [];
|
|
4070
|
+
for (const id2 of tracked) if (!held.some((order) => order.commandId === id2)) {
|
|
4071
|
+
isolated.delete(id2);
|
|
4072
|
+
clocks.delete(id2);
|
|
4073
|
+
}
|
|
4074
|
+
let probe;
|
|
4075
|
+
if (walk) {
|
|
4076
|
+
[probe] = await readFresh(collection, { selector: {
|
|
4077
|
+
syncStatus: "pending",
|
|
4078
|
+
commandId: { $nin: [...isolated.keys(), ...walk.failed.keys()] }
|
|
4079
|
+
}, sort: [{ createdAt: "asc" }], limit: 1 });
|
|
4080
|
+
if (!probe) {
|
|
4081
|
+
walk = void 0;
|
|
4082
|
+
head = void 0;
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
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);
|
|
4086
|
+
let orders = turn === "isolated" && due ? [due] : probe ? [probe] : await readFresh(collection, {
|
|
4087
|
+
selector: { syncStatus: "pending", ...isolated.size ? { commandId: { $nin: [...isolated.keys()] } } : {} },
|
|
3284
4088
|
sort: [{ createdAt: "asc" }],
|
|
3285
4089
|
limit: batchSize
|
|
3286
4090
|
});
|
|
4091
|
+
if (!orders.length && due) orders = [due];
|
|
3287
4092
|
if (!orders.length || stopped) {
|
|
3288
|
-
|
|
3289
|
-
|
|
4093
|
+
if (stopped || !isolated.size) {
|
|
4094
|
+
stoppedDuringRun = stopped;
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
4097
|
+
if (insertedDuringRun) continue;
|
|
4098
|
+
const next = [...isolated.values()].sort((a, b) => a.nextAt - b.nextAt)[0];
|
|
4099
|
+
return scheduleRetry(next.reason, 0, Math.min(maxBackoff, Math.max(initialBackoff, next.nextAt - now())));
|
|
3290
4100
|
}
|
|
3291
|
-
const
|
|
4101
|
+
const alone = due && orders[0] === due ? "isolated" : probe ? "probe" : void 0;
|
|
4102
|
+
if (alone === "isolated") sentAlone.add(orders[0].commandId);
|
|
4103
|
+
turn = alone === "isolated" ? "batch" : "isolated";
|
|
4104
|
+
const firstSend = orders.some((order) => order.sentVersion === void 0);
|
|
4105
|
+
const serverMax = firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.()).catch((cause) => {
|
|
4106
|
+
outboxLogger.warn("Failed to read capabilities", { cause });
|
|
4107
|
+
})) || 3;
|
|
4108
|
+
const batch = await Promise.all(orders.map(async (order) => {
|
|
4109
|
+
const frozen = freezeSentForm(order);
|
|
3292
4110
|
const attempt2 = (attempts.get(order.commandId) ?? 0) + 1;
|
|
3293
4111
|
attempts.set(order.commandId, attempt2);
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
4112
|
+
let envelope2;
|
|
4113
|
+
try {
|
|
4114
|
+
envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: order.sentVersion ?? serverMax });
|
|
4115
|
+
} catch (cause) {
|
|
4116
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
4117
|
+
envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: 3 });
|
|
4118
|
+
}
|
|
4119
|
+
if (frozen !== order || order.sentVersion === void 0) {
|
|
4120
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => ({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope2.version }));
|
|
4121
|
+
}
|
|
4122
|
+
return envelope2;
|
|
4123
|
+
}));
|
|
4124
|
+
let outcome = await transport.send(batch);
|
|
4125
|
+
backendNotFound.record(outcome, now());
|
|
4126
|
+
if (outcome.kind !== "retry" || outcome.reason !== "network") {
|
|
4127
|
+
const at2 = now();
|
|
4128
|
+
for (const clock of clocks.values()) if (clock.pausedAt !== void 0) {
|
|
4129
|
+
clock.since += at2 - clock.pausedAt;
|
|
4130
|
+
clock.pausedAt = void 0;
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
let batchMax;
|
|
4134
|
+
let capabilitiesRefreshed = false;
|
|
3297
4135
|
if (outcome.kind === "retry") {
|
|
3298
|
-
|
|
4136
|
+
let floor = outcome.retryAfterMs;
|
|
4137
|
+
if (outcome.reason === "network") {
|
|
4138
|
+
for (const clock of clocks.values()) clock.pausedAt ??= now();
|
|
4139
|
+
walk = void 0;
|
|
4140
|
+
} else if ((floor = serverFailed(orders, alone, outcome.reason, outcome.retryAfterMs)) === void 0) continue;
|
|
4141
|
+
return scheduleRetry(outcome.reason, floor);
|
|
3299
4142
|
}
|
|
3300
4143
|
if (outcome.kind === "unauthorized") {
|
|
3301
4144
|
unauthorizedSinceAccepted++;
|
|
@@ -3309,6 +4152,28 @@ function createOrderOutbox(options) {
|
|
|
3309
4152
|
});
|
|
3310
4153
|
return;
|
|
3311
4154
|
}
|
|
4155
|
+
if (outcome.kind === "refused" && outcome.status === 400 && options.getMaxOrderCreateVersion) {
|
|
4156
|
+
const match = /^Invalid commands\[(\d+)\]\.version$/.exec(outcome.reason);
|
|
4157
|
+
if (match) {
|
|
4158
|
+
try {
|
|
4159
|
+
await options.refreshCapabilities?.();
|
|
4160
|
+
} catch (cause) {
|
|
4161
|
+
outboxLogger.warn("Failed to refresh capabilities", { cause });
|
|
4162
|
+
}
|
|
4163
|
+
const advertised = await options.getMaxOrderCreateVersion();
|
|
4164
|
+
const max = typeof advertised === "number" && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : void 0;
|
|
4165
|
+
if (max !== void 0 && max < batch[Number(match[1])]?.version) {
|
|
4166
|
+
batchMax = max;
|
|
4167
|
+
capabilitiesRefreshed = true;
|
|
4168
|
+
const message = outcome.reason;
|
|
4169
|
+
outcome = { kind: "results", results: batch.filter((command) => command.version > max).map((command) => ({
|
|
4170
|
+
id: command.id,
|
|
4171
|
+
status: "rejected",
|
|
4172
|
+
error: { code: "unsupported_version", message, data: { orderCreate: max } }
|
|
4173
|
+
})) };
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
3312
4177
|
if (outcome.kind === "refused") {
|
|
3313
4178
|
unauthorizedSinceAccepted = 0;
|
|
3314
4179
|
state$.next({
|
|
@@ -3324,6 +4189,7 @@ function createOrderOutbox(options) {
|
|
|
3324
4189
|
unauthorizedSinceAccepted = 0;
|
|
3325
4190
|
state$.next({ ...state$.value, authRequired: false, refused: void 0 });
|
|
3326
4191
|
let progressed = false;
|
|
4192
|
+
let downgraded = false;
|
|
3327
4193
|
for (const order of orders) {
|
|
3328
4194
|
const result = outcome.results.find((entry) => entry.id === order.commandId);
|
|
3329
4195
|
if (!result) continue;
|
|
@@ -3331,20 +4197,79 @@ function createOrderOutbox(options) {
|
|
|
3331
4197
|
if (stored?.syncStatus !== "pending" || stored.commandId !== order.commandId) continue;
|
|
3332
4198
|
const current = await collection.findOne(order.id).exec();
|
|
3333
4199
|
if (!current) continue;
|
|
4200
|
+
let error = result.error;
|
|
4201
|
+
if (result.status === "rejected" && error?.code === "unsupported_version" && options.getMaxOrderCreateVersion) {
|
|
4202
|
+
if (!capabilitiesRefreshed) {
|
|
4203
|
+
try {
|
|
4204
|
+
await options.refreshCapabilities?.();
|
|
4205
|
+
} catch (cause) {
|
|
4206
|
+
outboxLogger.warn("Failed to refresh capabilities", { cause });
|
|
4207
|
+
}
|
|
4208
|
+
const advertised = error.data?.orderCreate;
|
|
4209
|
+
const max2 = typeof advertised === "number" && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : await options.getMaxOrderCreateVersion();
|
|
4210
|
+
batchMax = typeof max2 === "number" && Number.isSafeInteger(max2) && max2 > 0 ? max2 : void 0;
|
|
4211
|
+
capabilitiesRefreshed = true;
|
|
4212
|
+
}
|
|
4213
|
+
const max = batchMax;
|
|
4214
|
+
const from = batch.find((command) => command.id === order.commandId).version;
|
|
4215
|
+
if (max !== void 0 && max < (stored.sentVersion ?? from)) {
|
|
4216
|
+
try {
|
|
4217
|
+
const to = toOrderCreateEnvelope(stored, deviceId, 1, { maxVersion: max }).version;
|
|
4218
|
+
let changed = false;
|
|
4219
|
+
await current.incrementalModify((data) => {
|
|
4220
|
+
changed = data.syncStatus === "pending" && data.commandId === order.commandId && max < (data.sentVersion ?? from);
|
|
4221
|
+
if (changed) {
|
|
4222
|
+
data.sentVersion = to;
|
|
4223
|
+
data.downgradedFrom ??= from;
|
|
4224
|
+
delete data.serverFailures;
|
|
4225
|
+
}
|
|
4226
|
+
return data;
|
|
4227
|
+
});
|
|
4228
|
+
if (changed) {
|
|
4229
|
+
outboxLogger.warn("Sent an order at a lower order.create version", { orderId: order.id, from, to });
|
|
4230
|
+
progressed = downgraded = true;
|
|
4231
|
+
clocks.delete(order.commandId);
|
|
4232
|
+
}
|
|
4233
|
+
continue;
|
|
4234
|
+
} catch (cause) {
|
|
4235
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
4236
|
+
error = { code: "unsupported_version", message: cause.message };
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
if (result.status === "rejected") outboxLogger[error?.code === "unsupported_version" ? "error" : "warn"](
|
|
4241
|
+
"Order refused by the store",
|
|
4242
|
+
{ orderId: order.id, code: error?.code, message: error?.message?.slice(0, REFUSAL_LOG_MESSAGE_MAX) }
|
|
4243
|
+
);
|
|
3334
4244
|
const updatedAt = new Date(now()).toISOString();
|
|
3335
|
-
await current.
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
4245
|
+
await current.incrementalModify((data) => {
|
|
4246
|
+
delete data.serverFailures;
|
|
4247
|
+
return Object.assign(data, result.status === "rejected" ? { syncStatus: "rejected", error, updatedAt } : {
|
|
4248
|
+
syncStatus: "applied",
|
|
4249
|
+
serverRefs: result.serverRefs,
|
|
4250
|
+
...result.warnings ? { warnings: result.warnings } : {},
|
|
4251
|
+
updatedAt
|
|
4252
|
+
});
|
|
3340
4253
|
});
|
|
3341
4254
|
attempts.delete(order.commandId);
|
|
4255
|
+
isolated.delete(order.commandId);
|
|
4256
|
+
clocks.delete(order.commandId);
|
|
3342
4257
|
progressed = true;
|
|
3343
4258
|
await updateState();
|
|
3344
4259
|
}
|
|
3345
|
-
if (!progressed)
|
|
4260
|
+
if (!progressed) {
|
|
4261
|
+
const floor = serverFailed(orders, alone, "no_progress");
|
|
4262
|
+
if (floor === void 0) continue;
|
|
4263
|
+
return scheduleRetry("no_progress", floor);
|
|
4264
|
+
}
|
|
4265
|
+
runProgressed = true;
|
|
4266
|
+
if (alone !== "isolated") head = void 0;
|
|
4267
|
+
if (walk && alone === "probe") {
|
|
4268
|
+
for (const [id2, reason] of walk.failed) isolate(id2, reason);
|
|
4269
|
+
walk = void 0;
|
|
4270
|
+
}
|
|
3346
4271
|
backoff = initialBackoff;
|
|
3347
|
-
state$.next({ ...state$.value, lastRetryReason: void 0 });
|
|
4272
|
+
state$.next({ ...state$.value, lastRetryReason: downgraded ? "downgraded" : void 0 });
|
|
3348
4273
|
} catch (error) {
|
|
3349
4274
|
scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
|
|
3350
4275
|
return;
|
|
@@ -3354,16 +4279,26 @@ function createOrderOutbox(options) {
|
|
|
3354
4279
|
function flush() {
|
|
3355
4280
|
if (running) return running;
|
|
3356
4281
|
stopped = false;
|
|
4282
|
+
watchBackendMissing();
|
|
3357
4283
|
clearTimeout(timer);
|
|
3358
4284
|
timer = void 0;
|
|
3359
4285
|
running = Promise.resolve().then(run).finally(async () => {
|
|
4286
|
+
try {
|
|
4287
|
+
await mirror();
|
|
4288
|
+
} catch (cause) {
|
|
4289
|
+
outboxLogger.warn("Failed to store server failures", { cause });
|
|
4290
|
+
}
|
|
3360
4291
|
try {
|
|
3361
4292
|
await updateState({ sending: false });
|
|
3362
4293
|
} catch {
|
|
3363
4294
|
}
|
|
3364
4295
|
running = void 0;
|
|
3365
|
-
|
|
3366
|
-
|
|
4296
|
+
const missed = timerFiredDuringRun;
|
|
4297
|
+
timerFiredDuringRun = false;
|
|
4298
|
+
if (!stopped && (missed || (insertedDuringRun || stoppedDuringRun) && (timer === void 0 || insertedDuringRun && isolatedWait))) {
|
|
4299
|
+
flush().catch(() => {
|
|
4300
|
+
});
|
|
4301
|
+
}
|
|
3367
4302
|
});
|
|
3368
4303
|
return running;
|
|
3369
4304
|
}
|
|
@@ -3386,6 +4321,9 @@ function createOrderOutbox(options) {
|
|
|
3386
4321
|
if (!changed) return data;
|
|
3387
4322
|
data.syncStatus = "pending";
|
|
3388
4323
|
delete data.error;
|
|
4324
|
+
delete data.serverFailures;
|
|
4325
|
+
delete data.sentVersion;
|
|
4326
|
+
delete data.downgradedFrom;
|
|
3389
4327
|
data.commandId = uuidv7();
|
|
3390
4328
|
data.updatedAt = new Date(now()).toISOString();
|
|
3391
4329
|
return data;
|
|
@@ -3402,6 +4340,275 @@ function createOrderOutbox(options) {
|
|
|
3402
4340
|
},
|
|
3403
4341
|
start() {
|
|
3404
4342
|
stopped = false;
|
|
4343
|
+
watchBackendMissing();
|
|
4344
|
+
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
4345
|
+
if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE" && !(event.previousDocumentData !== void 0 && (failuresOnly(event.previousDocumentData, event.documentData) || recordsSentForm(event.previousDocumentData, event.documentData))))) {
|
|
4346
|
+
insertedDuringRun = true;
|
|
4347
|
+
flush().catch(() => {
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
});
|
|
4351
|
+
if (!stopped) flush().catch(() => {
|
|
4352
|
+
});
|
|
4353
|
+
},
|
|
4354
|
+
stop() {
|
|
4355
|
+
stopped = true;
|
|
4356
|
+
clearTimeout(timer);
|
|
4357
|
+
timer = void 0;
|
|
4358
|
+
subscription?.unsubscribe();
|
|
4359
|
+
subscription = void 0;
|
|
4360
|
+
missingSubscription?.unsubscribe();
|
|
4361
|
+
missingSubscription = void 0;
|
|
4362
|
+
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
4363
|
+
}
|
|
4364
|
+
};
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
// src/outbox/register-outbox.ts
|
|
4368
|
+
import { BehaviorSubject as BehaviorSubject5 } from "rxjs";
|
|
4369
|
+
function plainCommand({
|
|
4370
|
+
key,
|
|
4371
|
+
registerId,
|
|
4372
|
+
seq,
|
|
4373
|
+
commandId,
|
|
4374
|
+
type,
|
|
4375
|
+
version,
|
|
4376
|
+
payload,
|
|
4377
|
+
createdAt,
|
|
4378
|
+
syncStatus,
|
|
4379
|
+
error,
|
|
4380
|
+
result,
|
|
4381
|
+
updatedAt
|
|
4382
|
+
}) {
|
|
4383
|
+
return structuredClone({
|
|
4384
|
+
key,
|
|
4385
|
+
registerId,
|
|
4386
|
+
seq,
|
|
4387
|
+
commandId,
|
|
4388
|
+
type,
|
|
4389
|
+
version,
|
|
4390
|
+
payload,
|
|
4391
|
+
createdAt,
|
|
4392
|
+
syncStatus,
|
|
4393
|
+
...error ? { error } : {},
|
|
4394
|
+
...result ? { result } : {},
|
|
4395
|
+
updatedAt
|
|
4396
|
+
});
|
|
4397
|
+
}
|
|
4398
|
+
function createRegisterOutbox(options) {
|
|
4399
|
+
const { collection, transport, deviceId } = options;
|
|
4400
|
+
const size = options.batchSize;
|
|
4401
|
+
const batchSize = Math.max(1, Math.min(typeof size === "number" && Number.isFinite(size) ? Math.floor(size) : 10, 10));
|
|
4402
|
+
const initialBackoff = options.initialBackoffMs ?? 1e3;
|
|
4403
|
+
const maxBackoff = options.maxBackoffMs ?? 6e4;
|
|
4404
|
+
const random = options.random ?? Math.random;
|
|
4405
|
+
const now = options.now ?? Date.now;
|
|
4406
|
+
const state$ = new BehaviorSubject5({ pending: 0, sending: false });
|
|
4407
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
4408
|
+
let missingSubscription;
|
|
4409
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
4410
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
4411
|
+
});
|
|
4412
|
+
watchBackendMissing();
|
|
4413
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
4414
|
+
let backoff = initialBackoff;
|
|
4415
|
+
let unauthorizedSinceAccepted = 0;
|
|
4416
|
+
let running;
|
|
4417
|
+
let timer;
|
|
4418
|
+
let subscription;
|
|
4419
|
+
let stopped = false;
|
|
4420
|
+
let insertedDuringRun = false;
|
|
4421
|
+
let stoppedDuringRun = false;
|
|
4422
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
4423
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
4424
|
+
let failureSeq = 0;
|
|
4425
|
+
function stuckState() {
|
|
4426
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
4427
|
+
if (!stuck.length) return void 0;
|
|
4428
|
+
return {
|
|
4429
|
+
commandIds: stuck.map(([id2]) => id2),
|
|
4430
|
+
since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
4431
|
+
reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
|
|
4432
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
function serverFailed(commands, reason) {
|
|
4436
|
+
const at2 = now();
|
|
4437
|
+
for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
|
|
4438
|
+
}
|
|
4439
|
+
async function updateState(patch = {}) {
|
|
4440
|
+
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
4441
|
+
const sendable2 = /* @__PURE__ */ new Set();
|
|
4442
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
4443
|
+
if (clocks.size) for (const command of await readFresh(collection, {
|
|
4444
|
+
selector: { syncStatus: { $in: ["pending", "rejected"] } },
|
|
4445
|
+
sort: [{ seq: "asc" }, { key: "asc" }]
|
|
4446
|
+
})) {
|
|
4447
|
+
if (command.syncStatus === "rejected") blocked.add(command.registerId);
|
|
4448
|
+
else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
|
|
4449
|
+
}
|
|
4450
|
+
for (const id2 of clocks.keys()) if (!sendable2.has(id2)) clocks.delete(id2);
|
|
4451
|
+
state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
|
|
4452
|
+
}
|
|
4453
|
+
function scheduleRetry(reason, retryAfterMs = 0) {
|
|
4454
|
+
state$.next({ ...state$.value, lastRetryReason: reason, stuck: stuckState() });
|
|
4455
|
+
if (stopped) {
|
|
4456
|
+
stoppedDuringRun = true;
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
4460
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
4461
|
+
timer = setTimeout(() => {
|
|
4462
|
+
timer = void 0;
|
|
4463
|
+
flush().catch(() => {
|
|
4464
|
+
});
|
|
4465
|
+
}, delay);
|
|
4466
|
+
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
4467
|
+
}
|
|
4468
|
+
async function run() {
|
|
4469
|
+
stoppedDuringRun = false;
|
|
4470
|
+
while (!stopped) try {
|
|
4471
|
+
await updateState({ sending: true, nextAttemptAt: void 0 });
|
|
4472
|
+
insertedDuringRun = false;
|
|
4473
|
+
if (options.isEnabled?.() === false) return;
|
|
4474
|
+
const pending = await readFresh(collection, { selector: { syncStatus: "pending" } });
|
|
4475
|
+
const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
|
|
4476
|
+
let sent = false;
|
|
4477
|
+
for (const registerId of registerIds) {
|
|
4478
|
+
const ledger = await readFresh(collection, {
|
|
4479
|
+
selector: { registerId, syncStatus: { $in: ["pending", "rejected"] } },
|
|
4480
|
+
sort: [{ seq: "asc" }, { key: "asc" }]
|
|
4481
|
+
});
|
|
4482
|
+
const commands = [];
|
|
4483
|
+
for (const command of ledger) {
|
|
4484
|
+
if (command.syncStatus === "rejected") break;
|
|
4485
|
+
commands.push(command);
|
|
4486
|
+
if (commands.length >= batchSize) break;
|
|
4487
|
+
}
|
|
4488
|
+
if (stopped) {
|
|
4489
|
+
stoppedDuringRun = true;
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
if (!commands.length) continue;
|
|
4493
|
+
sent = true;
|
|
4494
|
+
const batch = commands.map((doc) => {
|
|
4495
|
+
const attempt2 = (attempts.get(doc.commandId) ?? 0) + 1;
|
|
4496
|
+
attempts.set(doc.commandId, attempt2);
|
|
4497
|
+
return {
|
|
4498
|
+
id: doc.commandId,
|
|
4499
|
+
type: doc.type,
|
|
4500
|
+
version: doc.version,
|
|
4501
|
+
payload: doc.payload,
|
|
4502
|
+
createdAt: doc.createdAt,
|
|
4503
|
+
deviceId,
|
|
4504
|
+
attempt: attempt2
|
|
4505
|
+
};
|
|
4506
|
+
});
|
|
4507
|
+
const outcome = await transport.send(batch);
|
|
4508
|
+
backendNotFound.record(outcome, now());
|
|
4509
|
+
const at2 = now();
|
|
4510
|
+
if (outcome.kind === "retry" && outcome.reason === "network") for (const clock of clocks.values()) clock.pausedAt ??= at2;
|
|
4511
|
+
else for (const clock of clocks.values()) if (clock.pausedAt !== void 0) {
|
|
4512
|
+
clock.since += at2 - clock.pausedAt;
|
|
4513
|
+
clock.pausedAt = void 0;
|
|
4514
|
+
}
|
|
4515
|
+
if (outcome.kind === "retry") {
|
|
4516
|
+
if (outcome.reason !== "network") serverFailed(commands, outcome.reason);
|
|
4517
|
+
return scheduleRetry(outcome.reason, outcome.retryAfterMs);
|
|
4518
|
+
}
|
|
4519
|
+
if (outcome.kind === "unauthorized") {
|
|
4520
|
+
unauthorizedSinceAccepted++;
|
|
4521
|
+
if (unauthorizedSinceAccepted < 3) return scheduleRetry("unauthorized");
|
|
4522
|
+
state$.next({
|
|
4523
|
+
...state$.value,
|
|
4524
|
+
authRequired: true,
|
|
4525
|
+
lastRetryReason: "unauthorized",
|
|
4526
|
+
sending: false,
|
|
4527
|
+
nextAttemptAt: void 0
|
|
4528
|
+
});
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
if (outcome.kind === "refused") {
|
|
4532
|
+
unauthorizedSinceAccepted = 0;
|
|
4533
|
+
state$.next({
|
|
4534
|
+
...state$.value,
|
|
4535
|
+
refused: { status: outcome.status, reason: outcome.reason },
|
|
4536
|
+
authRequired: false,
|
|
4537
|
+
lastRetryReason: "refused",
|
|
4538
|
+
sending: false,
|
|
4539
|
+
nextAttemptAt: void 0
|
|
4540
|
+
});
|
|
4541
|
+
return;
|
|
4542
|
+
}
|
|
4543
|
+
unauthorizedSinceAccepted = 0;
|
|
4544
|
+
state$.next({ ...state$.value, authRequired: false, refused: void 0 });
|
|
4545
|
+
let progressed = false;
|
|
4546
|
+
for (const command of commands) {
|
|
4547
|
+
const result = outcome.results.find((entry) => entry.id === command.commandId);
|
|
4548
|
+
if (!result) continue;
|
|
4549
|
+
const [stored] = await collection.storageInstance.findDocumentsById([command.key], false);
|
|
4550
|
+
if (stored?.syncStatus !== "pending" || stored.commandId !== command.commandId) continue;
|
|
4551
|
+
const current = await collection.findOne(command.key).exec();
|
|
4552
|
+
if (!current) continue;
|
|
4553
|
+
const error = result.error && {
|
|
4554
|
+
code: result.error.code,
|
|
4555
|
+
message: result.error.message,
|
|
4556
|
+
...result.error.data ? { data: result.error.data } : {}
|
|
4557
|
+
};
|
|
4558
|
+
const normalized = result.status === "duplicate" && error ? { id: result.id, status: "rejected", error } : { ...result, ...error ? { error } : {} };
|
|
4559
|
+
try {
|
|
4560
|
+
await options.onResult?.(plainCommand(stored), normalized);
|
|
4561
|
+
} catch (cause) {
|
|
4562
|
+
registerCommandsLogger.warn("Failed to apply register command result", { commandId: command.commandId, cause });
|
|
4563
|
+
}
|
|
4564
|
+
const updatedAt = new Date(now()).toISOString();
|
|
4565
|
+
await current.incrementalModify((row) => {
|
|
4566
|
+
if (row.syncStatus !== "pending" || row.commandId !== command.commandId) return row;
|
|
4567
|
+
return Object.assign(row, normalized.status === "rejected" ? { syncStatus: "rejected", error: normalized.error, updatedAt } : { syncStatus: "applied", ...normalized.register ? { result: normalized.register } : {}, updatedAt });
|
|
4568
|
+
});
|
|
4569
|
+
attempts.delete(command.commandId);
|
|
4570
|
+
progressed = true;
|
|
4571
|
+
await updateState();
|
|
4572
|
+
}
|
|
4573
|
+
if (!progressed) {
|
|
4574
|
+
serverFailed(commands, "no_progress");
|
|
4575
|
+
return scheduleRetry("no_progress");
|
|
4576
|
+
}
|
|
4577
|
+
backoff = initialBackoff;
|
|
4578
|
+
state$.next({ ...state$.value, lastRetryReason: void 0 });
|
|
4579
|
+
}
|
|
4580
|
+
if (!sent) return;
|
|
4581
|
+
} catch (error) {
|
|
4582
|
+
scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
|
|
4583
|
+
return;
|
|
4584
|
+
}
|
|
4585
|
+
stoppedDuringRun = true;
|
|
4586
|
+
}
|
|
4587
|
+
function flush() {
|
|
4588
|
+
if (running) return running;
|
|
4589
|
+
stopped = false;
|
|
4590
|
+
watchBackendMissing();
|
|
4591
|
+
clearTimeout(timer);
|
|
4592
|
+
timer = void 0;
|
|
4593
|
+
running = Promise.resolve().then(run).finally(async () => {
|
|
4594
|
+
try {
|
|
4595
|
+
await updateState({ sending: false });
|
|
4596
|
+
} catch {
|
|
4597
|
+
}
|
|
4598
|
+
running = void 0;
|
|
4599
|
+
if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === void 0) flush().catch(() => {
|
|
4600
|
+
});
|
|
4601
|
+
});
|
|
4602
|
+
return running;
|
|
4603
|
+
}
|
|
4604
|
+
updateState().catch(() => {
|
|
4605
|
+
});
|
|
4606
|
+
return {
|
|
4607
|
+
state$,
|
|
4608
|
+
flush,
|
|
4609
|
+
start() {
|
|
4610
|
+
stopped = false;
|
|
4611
|
+
watchBackendMissing();
|
|
3405
4612
|
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
3406
4613
|
if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE")) {
|
|
3407
4614
|
insertedDuringRun = true;
|
|
@@ -3418,15 +4625,17 @@ function createOrderOutbox(options) {
|
|
|
3418
4625
|
timer = void 0;
|
|
3419
4626
|
subscription?.unsubscribe();
|
|
3420
4627
|
subscription = void 0;
|
|
4628
|
+
missingSubscription?.unsubscribe();
|
|
4629
|
+
missingSubscription = void 0;
|
|
3421
4630
|
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
3422
4631
|
}
|
|
3423
4632
|
};
|
|
3424
4633
|
}
|
|
3425
4634
|
|
|
3426
4635
|
// src/outbox/use-order-outbox.ts
|
|
3427
|
-
import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
|
|
4636
|
+
import { useEffect as useEffect4, useMemo as useMemo4, useRef as useRef4, useState as useState4 } from "react";
|
|
3428
4637
|
var idle = { pending: 0, sending: false };
|
|
3429
|
-
var
|
|
4638
|
+
var noneStuck = [];
|
|
3430
4639
|
function useOrderOutbox(options) {
|
|
3431
4640
|
const { storeKey, deviceId } = options;
|
|
3432
4641
|
const latest = useRef4(options);
|
|
@@ -3452,7 +4661,15 @@ function useOrderOutbox(options) {
|
|
|
3452
4661
|
await store.close();
|
|
3453
4662
|
return;
|
|
3454
4663
|
}
|
|
3455
|
-
const outbox = createOrderOutbox({
|
|
4664
|
+
const outbox = createOrderOutbox({
|
|
4665
|
+
collection: store.orders,
|
|
4666
|
+
deviceId,
|
|
4667
|
+
transport: latest.current.transport(storeKey),
|
|
4668
|
+
getMaxOrderCreateVersion: latest.current.getMaxOrderCreateVersion ? () => latest.current.getMaxOrderCreateVersion?.() : void 0,
|
|
4669
|
+
refreshCapabilities: latest.current.refreshCapabilities ? async () => {
|
|
4670
|
+
await latest.current.refreshCapabilities?.();
|
|
4671
|
+
} : void 0
|
|
4672
|
+
});
|
|
3456
4673
|
current.current = { storeKey, orders: store.orders, outbox };
|
|
3457
4674
|
const status = outbox.state$.subscribe(setState);
|
|
3458
4675
|
const history = watchFresh(store.orders, { sort: [{ createdAt: "desc" }], limit: 50 }).subscribe(setRecent);
|
|
@@ -3479,6 +4696,8 @@ function useOrderOutbox(options) {
|
|
|
3479
4696
|
return () => latest.current.onBusy?.(false);
|
|
3480
4697
|
}, [state.sending]);
|
|
3481
4698
|
const ready = current.current?.storeKey === storeKey && !!storeKey;
|
|
4699
|
+
const stuckKey = ready ? state.stuck?.commandIds.join(",") ?? "" : "";
|
|
4700
|
+
const stuckCommandIds = useMemo4(() => stuckKey ? stuckKey.split(",") : noneStuck, [stuckKey]);
|
|
3482
4701
|
return {
|
|
3483
4702
|
orders: ready ? orders : null,
|
|
3484
4703
|
state: ready ? state : idle,
|
|
@@ -3492,6 +4711,7 @@ function useOrderOutbox(options) {
|
|
|
3492
4711
|
return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
|
|
3493
4712
|
},
|
|
3494
4713
|
savesInFlight: savesInFlightCount,
|
|
4714
|
+
stuckCommandIds,
|
|
3495
4715
|
async record(posOrder) {
|
|
3496
4716
|
savesInFlight.current++;
|
|
3497
4717
|
setSavesInFlightCount(savesInFlight.current);
|
|
@@ -3734,13 +4954,18 @@ export {
|
|
|
3734
4954
|
PosOrderOpenClosedError,
|
|
3735
4955
|
RegisterApprovalRequiredError,
|
|
3736
4956
|
RegisterCloseIncompleteError,
|
|
4957
|
+
RegisterIdInvalidError,
|
|
4958
|
+
RegisterMovementAmountError,
|
|
4959
|
+
RegisterMovementReasonError,
|
|
3737
4960
|
RegisterMovementStrandedError,
|
|
3738
4961
|
RegisterSessionAlreadyOpenError,
|
|
3739
4962
|
RegisterSessionClosedError,
|
|
3740
4963
|
RegisterSessionRequiredError,
|
|
3741
4964
|
RegisterTenderInProgressError,
|
|
3742
4965
|
SALE_SAVING,
|
|
4966
|
+
TAX_ROUNDING_MIXED_NOTE,
|
|
3743
4967
|
TaxProvider,
|
|
4968
|
+
UnsupportedOrderVersionError,
|
|
3744
4969
|
activePlan,
|
|
3745
4970
|
addEntryToCart,
|
|
3746
4971
|
addPosOrderCollection,
|
|
@@ -3759,16 +4984,19 @@ export {
|
|
|
3759
4984
|
clampClosureScope,
|
|
3760
4985
|
closeNeedsApproval,
|
|
3761
4986
|
closeSession,
|
|
4987
|
+
closureCommand,
|
|
3762
4988
|
closureSchema,
|
|
3763
4989
|
computeOrderTax,
|
|
3764
4990
|
consoleSink,
|
|
3765
4991
|
countFresh,
|
|
3766
4992
|
countVariance,
|
|
4993
|
+
createBackendNotFound,
|
|
3767
4994
|
createHttpCommandTransport,
|
|
3768
4995
|
createLogger,
|
|
3769
4996
|
createOrderBuilder,
|
|
3770
4997
|
createOrderManager,
|
|
3771
4998
|
createOrderOutbox,
|
|
4999
|
+
createRegisterOutbox,
|
|
3772
5000
|
createRepository,
|
|
3773
5001
|
denominationTotal,
|
|
3774
5002
|
denominations,
|
|
@@ -3790,6 +5018,7 @@ export {
|
|
|
3790
5018
|
minorToDecimal,
|
|
3791
5019
|
mintClosureNumber,
|
|
3792
5020
|
mintUuid,
|
|
5021
|
+
movementCommand,
|
|
3793
5022
|
movementFieldError,
|
|
3794
5023
|
needsAttention,
|
|
3795
5024
|
nextSaleCounter,
|
|
@@ -3809,8 +5038,12 @@ export {
|
|
|
3809
5038
|
readBoundRegister,
|
|
3810
5039
|
readFresh,
|
|
3811
5040
|
readRegister,
|
|
5041
|
+
reconcileRegisterCommands,
|
|
3812
5042
|
recordMovement,
|
|
3813
5043
|
recordRegisterFact,
|
|
5044
|
+
registerCommandCollection,
|
|
5045
|
+
registerCommandSchema,
|
|
5046
|
+
registerCommandsLogger,
|
|
3814
5047
|
registerFactsLogger,
|
|
3815
5048
|
registerSessionCollection,
|
|
3816
5049
|
registerSessionSchema,
|
|
@@ -3821,11 +5054,15 @@ export {
|
|
|
3821
5054
|
sameSale,
|
|
3822
5055
|
searchProducts,
|
|
3823
5056
|
selectClosureRows,
|
|
5057
|
+
sessionOpenCommand,
|
|
5058
|
+
sessionTransitionCommand,
|
|
3824
5059
|
stampSession,
|
|
3825
5060
|
startCounting,
|
|
3826
5061
|
stockOverlay$,
|
|
3827
5062
|
stockOverlayAsOf$,
|
|
5063
|
+
taxFiguresForBasket,
|
|
3828
5064
|
taxLinesByRate,
|
|
5065
|
+
taxLogger,
|
|
3829
5066
|
taxMicros,
|
|
3830
5067
|
taxProviderProps,
|
|
3831
5068
|
tenderReducer,
|