@tallyui/pos 2.0.0 → 3.0.0-next.1

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