@tallyui/pos 2.0.0 → 3.0.0-next.0

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