@tallyui/pos 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
package/dist/index.js CHANGED
@@ -82,26 +82,9 @@ function callbackSink(options) {
82
82
  };
83
83
  }
84
84
 
85
- // src/currency/format-currency.ts
86
- var formatters = /* @__PURE__ */ new Map();
87
- function getFormatter(currencyCode, locale) {
88
- const key = `${currencyCode}:${locale ?? "default"}`;
89
- let formatter = formatters.get(key);
90
- if (!formatter) {
91
- formatter = new Intl.NumberFormat(locale, {
92
- style: "currency",
93
- currency: currencyCode
94
- });
95
- formatters.set(key, formatter);
96
- }
97
- return formatter;
98
- }
99
- function formatCurrency(amount, currencyCode, locale) {
100
- return getFormatter(currencyCode, locale).format(amount);
101
- }
102
-
103
85
  // src/currency/currency-provider.tsx
104
86
  import { createContext, useContext, useMemo, useCallback } from "react";
87
+ import { formatMoney } from "@tallyui/core";
105
88
  import { jsx } from "react/jsx-runtime";
106
89
  var CurrencyCtx = createContext(null);
107
90
  function CurrencyProvider({ currencyCode, locale, children }) {
@@ -114,55 +97,131 @@ function useCurrencyFormatter() {
114
97
  throw new Error("useCurrencyFormatter() must be used within a <CurrencyProvider>");
115
98
  }
116
99
  return useCallback(
117
- (amount) => formatCurrency(amount, ctx.currencyCode, ctx.locale),
118
- [ctx.currencyCode, ctx.locale]
100
+ (money) => formatMoney(money, ctx.locale) ?? `${money.amount} ${money.currency}`,
101
+ [ctx.locale]
119
102
  );
120
103
  }
104
+ function useCurrencyCode() {
105
+ const ctx = useContext(CurrencyCtx);
106
+ if (!ctx) {
107
+ throw new Error("useCurrencyCode() must be used within a <CurrencyProvider>");
108
+ }
109
+ return ctx.currencyCode;
110
+ }
121
111
 
122
- // src/tax/calculate.ts
123
- function round2(n) {
124
- return Math.round(n * 100) / 100;
112
+ // src/tax/exact.ts
113
+ var MICROS_PER_MINOR = 1000000n;
114
+ function ratePpmFromPercent(percent) {
115
+ const value = String(percent);
116
+ const match = /^(\d+)(?:\.(\d{1,4}))?$/.exec(value);
117
+ if (!match || match[0] !== value) {
118
+ throw new RangeError("Percent must be a plain decimal with at most four fractional digits");
119
+ }
120
+ const ratePpm = BigInt(match[1]) * 10000n + BigInt((match[2] ?? "").padEnd(4, "0"));
121
+ if (ratePpm > BigInt(Number.MAX_SAFE_INTEGER)) {
122
+ throw new RangeError("Rate must be a safe integer");
123
+ }
124
+ return Number(ratePpm);
125
125
  }
126
- function addTax(priceExclTax, rate) {
127
- return round2(priceExclTax * (1 + rate));
126
+ function taxMicros(amountMinor, ratePpm, pricesIncludeTax) {
127
+ if (!Number.isSafeInteger(amountMinor) || !Number.isSafeInteger(ratePpm) || ratePpm < 0) {
128
+ throw new RangeError("Amount must be a safe integer and rate a non-negative safe integer");
129
+ }
130
+ const tax = BigInt(amountMinor) * BigInt(ratePpm);
131
+ if (!pricesIncludeTax) return tax;
132
+ const numerator = tax * MICROS_PER_MINOR;
133
+ const denominator = MICROS_PER_MINOR + BigInt(ratePpm);
134
+ const sign = numerator < 0n ? -1n : 1n;
135
+ return sign * ((sign * numerator + denominator / 2n) / denominator);
128
136
  }
129
- function extractTax(priceInclTax, rate) {
130
- if (rate === 0) return 0;
131
- return round2(priceInclTax - priceInclTax / (1 + rate));
137
+ function roundMicrosToMinor(micros) {
138
+ const sign = micros < 0n ? -1n : 1n;
139
+ const minor2 = sign * ((sign * micros + MICROS_PER_MINOR / 2n) / MICROS_PER_MINOR);
140
+ if (minor2 < -BigInt(Number.MAX_SAFE_INTEGER) || minor2 > BigInt(Number.MAX_SAFE_INTEGER)) {
141
+ throw new RangeError("Rounded amount must be a safe integer");
142
+ }
143
+ return Number(minor2);
132
144
  }
133
- function calculateTax(price, rate, pricesIncludeTax) {
134
- if (pricesIncludeTax) {
135
- const taxAmount2 = extractTax(price, rate);
136
- return {
137
- priceInclTax: round2(price),
138
- priceExclTax: round2(price - taxAmount2),
139
- taxAmount: taxAmount2
140
- };
145
+ function computeOrderTax(lines, pricesIncludeTax) {
146
+ let amountTotal = 0n;
147
+ let taxTotal = 0n;
148
+ const lineTaxMicros = [];
149
+ for (const line of lines) {
150
+ if (!Number.isSafeInteger(line.quantity) || line.quantity < 1) {
151
+ throw new RangeError("Quantity must be a safe integer >= 1");
152
+ }
153
+ let tax = taxMicros(line.unitPriceMinor, line.ratePpm, false) * BigInt(line.quantity);
154
+ const amount = BigInt(line.unitPriceMinor) * BigInt(line.quantity);
155
+ if (pricesIncludeTax) {
156
+ const numerator = tax * MICROS_PER_MINOR;
157
+ const denominator = MICROS_PER_MINOR + BigInt(line.ratePpm);
158
+ const sign = numerator < 0n ? -1n : 1n;
159
+ tax = sign * ((sign * numerator + denominator / 2n) / denominator);
160
+ }
161
+ amountTotal += amount;
162
+ taxTotal += tax;
163
+ lineTaxMicros.push(tax);
164
+ }
165
+ const taxMinor = taxTotal / MICROS_PER_MINOR + BigInt(roundMicrosToMinor(taxTotal % MICROS_PER_MINOR));
166
+ const subtotalMinor = pricesIncludeTax ? amountTotal - taxMinor : amountTotal;
167
+ const totalMinor = pricesIncludeTax ? amountTotal : amountTotal + taxMinor;
168
+ for (const value of [subtotalMinor, taxMinor, totalMinor]) {
169
+ if (value < -BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
170
+ throw new RangeError("Order totals must be safe integers");
171
+ }
141
172
  }
142
- const taxAmount = round2(price * rate);
143
173
  return {
144
- priceExclTax: round2(price),
145
- priceInclTax: round2(price + taxAmount),
146
- taxAmount
174
+ subtotalMinor: Number(subtotalMinor),
175
+ taxMinor: Number(taxMinor),
176
+ totalMinor: Number(totalMinor),
177
+ lineTaxMicros
147
178
  };
148
179
  }
180
+ function taxLinesByRate(lines, orderTaxMinor, taxLabels) {
181
+ const byRate = /* @__PURE__ */ new Map();
182
+ for (const line of lines) {
183
+ const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
184
+ const base = line.taxInclusive ? line.netMinor - roundMicrosToMinor(lineTaxMicros) : line.netMinor;
185
+ for (const tax of line.taxLines) {
186
+ const key = JSON.stringify([tax.code ?? "", tax.ratePpm]);
187
+ const existing = byRate.get(key);
188
+ byRate.set(key, {
189
+ code: tax.code,
190
+ ratePpm: tax.ratePpm,
191
+ micros: (existing?.micros ?? 0n) + BigInt(tax.taxMicros),
192
+ netMinor: (existing?.netMinor ?? 0) + base
193
+ });
194
+ }
195
+ }
196
+ const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
197
+ const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
198
+ return {
199
+ line: { label: taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 1e4}%`, code, ratePpm, netMinor, amountMinor: Number(floor) },
200
+ remainder: micros - floor * MICROS_PER_MINOR
201
+ };
202
+ });
203
+ const leftover = orderTaxMinor - groups.reduce((sum, group) => sum + group.line.amountMinor, 0);
204
+ const ranked = [...groups].sort((a, b) => a.remainder === b.remainder ? b.line.ratePpm - a.line.ratePpm : a.remainder > b.remainder ? -1 : 1);
205
+ for (const group of ranked.slice(0, leftover)) group.line.amountMinor += 1;
206
+ return groups.map((group) => group.line);
207
+ }
149
208
 
150
209
  // src/tax/tax-provider.tsx
151
210
  import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
152
211
  import { jsx as jsx2 } from "react/jsx-runtime";
153
212
  var TaxCtx = createContext2(null);
154
- function TaxProvider({ rates, pricesIncludeTax, children }) {
213
+ function TaxProvider({ ratesPpm, pricesIncludeTax, children }) {
155
214
  const value = useMemo2(
156
- () => ({
157
- getTaxRate(taxClass) {
158
- if (taxClass && taxClass in rates) {
159
- return rates[taxClass];
160
- }
161
- return rates.default ?? 0;
162
- },
163
- pricesIncludeTax
164
- }),
165
- [rates, pricesIncludeTax]
215
+ () => {
216
+ if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
217
+ throw new RangeError("TaxProvider: rates must be integer ppm");
218
+ }
219
+ return {
220
+ getTaxRatePpm: (taxClass) => (taxClass === void 0 ? void 0 : ratesPpm[taxClass]) ?? ratesPpm.default ?? 0,
221
+ pricesIncludeTax
222
+ };
223
+ },
224
+ [ratesPpm, pricesIncludeTax]
166
225
  );
167
226
  return /* @__PURE__ */ jsx2(TaxCtx.Provider, { value, children });
168
227
  }
@@ -174,12 +233,89 @@ function useTax() {
174
233
  return ctx;
175
234
  }
176
235
 
236
+ // src/store-settings/resolve-store-settings.ts
237
+ import { StoreSettingsError } from "@tallyui/core";
238
+ function narrowInitial(tried, choices) {
239
+ const region = !choices.regions || choices.regions.some((r) => r.id === tried.region) ? tried.region : void 0;
240
+ const country = !choices.countries || tried.country !== void 0 && choices.countries.includes(tried.country) ? tried.country : void 0;
241
+ const channel = !choices.channels || choices.channels.some((c) => c.id === tried.channel) ? tried.channel : void 0;
242
+ const initial = { ...region !== void 0 && { region }, ...country !== void 0 && { country }, ...channel !== void 0 && { channel } };
243
+ return Object.keys(initial).length > 0 ? initial : void 0;
244
+ }
245
+ async function resolveStoreSettings(options, choice) {
246
+ const { connector, context, loadChoice, saveChoice, onSaveError } = options;
247
+ if (!connector.storeSettings) return { status: "unsupported" };
248
+ const tried = choice ?? await Promise.resolve().then(loadChoice).catch(() => void 0);
249
+ let settings;
250
+ try {
251
+ settings = await connector.storeSettings(context, tried);
252
+ } catch (error) {
253
+ if (error instanceof StoreSettingsError && error.code === "choice_required") {
254
+ const offered = error.choices ?? {};
255
+ const initial = tried && narrowInitial(tried, offered);
256
+ return { status: "choose", choices: offered, ...initial && { initial } };
257
+ }
258
+ throw error;
259
+ }
260
+ if (choice) await Promise.resolve().then(() => saveChoice(choice)).catch((error) => onSaveError?.(error));
261
+ return { status: "ready", settings, ...tried && { choice: tried } };
262
+ }
263
+
264
+ // src/store-settings/map-store-settings.ts
265
+ function withPricingContext(context, settings) {
266
+ const { pricingContext: _previous, ...rest } = context;
267
+ return settings.pricingContext ? { ...rest, pricingContext: settings.pricingContext } : rest;
268
+ }
269
+ function taxProviderProps(settings) {
270
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax };
271
+ }
272
+
273
+ // src/store-settings/use-store-settings.ts
274
+ import { useCallback as useCallback2, useEffect, useRef, useState } from "react";
275
+ var LOADING = { state: "loading" };
276
+ function useStoreSettings(options) {
277
+ const { connector, context } = options;
278
+ const optionsRef = useRef(options);
279
+ optionsRef.current = options;
280
+ const requestRef = useRef(0);
281
+ const [state, setState] = useState(LOADING);
282
+ const resolve = useCallback2((choice, from) => {
283
+ if (from !== void 0 && from !== requestRef.current) return;
284
+ const request = ++requestRef.current;
285
+ const current = () => request === requestRef.current;
286
+ setState(LOADING);
287
+ resolveStoreSettings(optionsRef.current, choice).then(
288
+ (result) => {
289
+ if (!current()) return;
290
+ if (result.status === "ready") setState({ state: "ready", settings: result.settings, choice: result.choice });
291
+ else if (result.status === "choose")
292
+ setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
293
+ else setState({ state: "unsupported" });
294
+ },
295
+ // A retry keeps the choice that failed: a new pick is saved only once it resolves.
296
+ (error) => {
297
+ if (current()) setState({ state: "error", error, retry: () => resolve(choice, request) });
298
+ }
299
+ );
300
+ }, []);
301
+ useEffect(() => {
302
+ resolve();
303
+ return () => {
304
+ requestRef.current += 1;
305
+ };
306
+ }, [connector, context, resolve]);
307
+ return state;
308
+ }
309
+
310
+ // src/rxdb/index.ts
311
+ import { readFresh, countFresh, watchFresh } from "@tallyui/core/rxdb";
312
+
177
313
  // src/repository/create-repository.ts
178
314
  import { map } from "rxjs";
179
315
  function createRepository(collection) {
180
316
  return {
181
- findById$(id) {
182
- return collection.findOne(id).$.pipe(
317
+ findById$(id2) {
318
+ return collection.findOne(id2).$.pipe(
183
319
  map((doc) => doc ? doc.toJSON() : null)
184
320
  );
185
321
  },
@@ -213,15 +349,15 @@ function createRepository(collection) {
213
349
  const doc = await collection.insert(data);
214
350
  return doc.toJSON();
215
351
  },
216
- async update(id, patch) {
217
- const doc = await collection.findOne(id).exec();
218
- if (!doc) throw new Error(`Document ${id} not found`);
352
+ async update(id2, patch) {
353
+ const doc = await collection.findOne(id2).exec();
354
+ if (!doc) throw new Error(`Document ${id2} not found`);
219
355
  const updated = await doc.incrementalPatch(patch);
220
356
  return updated.toJSON();
221
357
  },
222
- async remove(id) {
223
- const doc = await collection.findOne(id).exec();
224
- if (!doc) throw new Error(`Document ${id} not found`);
358
+ async remove(id2) {
359
+ const doc = await collection.findOne(id2).exec();
360
+ if (!doc) throw new Error(`Document ${id2} not found`);
225
361
  await doc.remove();
226
362
  },
227
363
  async bulkCreate(items) {
@@ -236,12 +372,59 @@ function createRepository(collection) {
236
372
 
237
373
  // src/order/order-builder.ts
238
374
  import { BehaviorSubject } from "rxjs";
375
+ import { resolvePrice } from "@tallyui/core";
376
+
377
+ // src/order/allocate-order-discount.ts
378
+ function allocateOrderDiscount(lineAmountsMinor, totalMinor) {
379
+ if (!Number.isSafeInteger(totalMinor) || totalMinor < 0 || !lineAmountsMinor.every(Number.isSafeInteger)) {
380
+ throw new RangeError("Amounts must be safe integers and the discount non-negative");
381
+ }
382
+ const weights = lineAmountsMinor.map((amount) => BigInt(Math.max(0, amount)));
383
+ const weightTotal = weights.reduce((sum, weight) => sum + weight, 0n);
384
+ const total = BigInt(totalMinor);
385
+ if (total > weightTotal) throw new RangeError("Order discount exceeds the lines it applies to");
386
+ if (total === 0n) return weights.map(() => 0);
387
+ const shares = weights.map((weight) => total * weight / weightTotal);
388
+ const remainders = weights.map((weight) => total * weight % weightTotal);
389
+ let leftover = total - shares.reduce((sum, share) => sum + share, 0n);
390
+ const ranked = remainders.map((_, index) => index).sort((a, b) => remainders[a] === remainders[b] ? a - b : remainders[a] > remainders[b] ? -1 : 1);
391
+ for (const index of ranked) {
392
+ if (leftover === 0n) break;
393
+ shares[index] += 1n;
394
+ leftover -= 1n;
395
+ }
396
+ return shares.map(Number);
397
+ }
398
+
399
+ // src/order/order-builder.ts
239
400
  var nextId = 0;
240
401
  function uid() {
241
402
  return `${Date.now()}-${++nextId}`;
242
403
  }
404
+ function roundHalfAway(n) {
405
+ return Math.sign(n) * Math.round(Math.abs(n));
406
+ }
407
+ function sumLines(lines) {
408
+ const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
409
+ const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
410
+ 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);
413
+ return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
414
+ }
415
+ function toDisplayMode(line, amountMinor, displayInclusive) {
416
+ if (line.taxInclusive === displayInclusive) return amountMinor;
417
+ const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
418
+ const taxMinor = roundMicrosToMinor(taxMicros(amountMinor, ratePpm, line.taxInclusive));
419
+ return line.taxInclusive ? amountMinor - taxMinor : amountMinor + taxMinor;
420
+ }
421
+ function assertDisplayResidue(residue, convertedLines, conversions) {
422
+ const bound = convertedLines === 0 ? 0 : Math.floor(conversions / 2) + 1;
423
+ if (Math.abs(residue) > bound) throw new Error(`Display residue ${residue} exceeds its bound of ${bound} (ADR-063)`);
424
+ }
243
425
  function createOrderBuilder(options) {
244
- const { currency, taxContext } = options;
426
+ const { taxContext } = options;
427
+ const currency = options.currency.toUpperCase();
245
428
  const orderId = options.id ?? uid();
246
429
  let lineItems = [];
247
430
  let orderDiscounts = [];
@@ -250,65 +433,131 @@ function createOrderBuilder(options) {
250
433
  let note = "";
251
434
  const now = (/* @__PURE__ */ new Date()).toISOString();
252
435
  const subject = new BehaviorSubject(buildOrder());
253
- function recalculateLine(line) {
254
- const gross = line.price * line.quantity;
255
- const recalcedDiscounts = line.discounts.map((d) => ({
256
- ...d,
257
- amount: computeDiscountAmount(d, gross)
258
- }));
259
- const discountAmount = recalcedDiscounts.reduce((sum, d) => sum + d.amount, 0);
260
- const afterDiscount = gross - discountAmount;
261
- const tax = calculateTax(afterDiscount, line.taxRate, taxContext.pricesIncludeTax);
436
+ function recalculateLine(line, orderDiscountMinor = 0) {
437
+ const grossMinor = line.unitPriceMinor * line.quantity;
438
+ let remaining = grossMinor;
439
+ const recalcedDiscounts = line.discounts.map((d) => {
440
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === "percentage" ? grossMinor : remaining));
441
+ remaining -= amountMinor;
442
+ return { ...d, amountMinor };
443
+ });
444
+ const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
445
+ const discountMinor = lineDiscountMinor + orderDiscountMinor;
446
+ const netMinor = grossMinor - discountMinor;
447
+ const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
448
+ const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
449
+ let allocatedTax = 0n;
450
+ const taxLines = line.taxLines.map((tax, index) => {
451
+ const micros = line.taxInclusive ? index === line.taxLines.length - 1 ? inclusiveTax - allocatedTax : combinedRate === 0 ? 0n : inclusiveTax * BigInt(tax.ratePpm) / BigInt(combinedRate) : taxMicros(netMinor, tax.ratePpm, false);
452
+ allocatedTax += micros;
453
+ return { ...tax, taxMicros: micros.toString() };
454
+ });
262
455
  return {
263
456
  ...line,
264
457
  discounts: recalcedDiscounts,
265
- discountAmount,
266
- taxAmount: tax.taxAmount,
267
- lineTotal: taxContext.pricesIncludeTax ? afterDiscount : afterDiscount + tax.taxAmount
458
+ discountMinor,
459
+ orderDiscountMinor,
460
+ netMinor,
461
+ taxLines,
462
+ taxMicros: allocatedTax.toString()
268
463
  };
269
464
  }
270
465
  function computeDiscountAmount(discount, base) {
271
466
  if (discount.type === "percentage") {
272
- return Math.round(base * discount.value / 100 * 100) / 100;
467
+ return Math.max(0, roundHalfAway(base * discount.value / 100));
273
468
  }
274
- return Math.min(discount.value, base);
469
+ if (!Number.isInteger(discount.value)) throw new RangeError("Fixed discount must be integer minor units");
470
+ return Math.max(0, Math.min(discount.value, base));
275
471
  }
276
472
  function buildOrder() {
277
- const subtotalLines = lineItems.reduce((sum, li) => {
278
- const lineGross = li.price * li.quantity - li.discountAmount;
279
- if (taxContext.pricesIncludeTax) {
280
- const tax = calculateTax(lineGross, li.taxRate, true);
281
- return sum + tax.priceExclTax;
282
- }
283
- return sum + lineGross;
284
- }, 0);
285
- const subtotal = Math.round(subtotalLines * 100) / 100;
286
- const recalcedOrderDiscounts = orderDiscounts.map((d) => ({
287
- ...d,
288
- amount: computeDiscountAmount(d, subtotal)
289
- }));
290
- const discountTotal = lineItems.reduce((sum, li) => sum + li.discountAmount, 0) + recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
291
- const taxTotal = lineItems.reduce((sum, li) => sum + li.taxAmount, 0);
292
- const total = lineItems.reduce((sum, li) => sum + li.lineTotal, 0) - recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
293
- const roundedTotal = Math.round(total * 100) / 100;
294
- const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
295
- const balanceDue = Math.max(0, Math.round((roundedTotal - paidAmount) * 100) / 100);
296
- const changeDue = Math.max(0, Math.round((paidAmount - roundedTotal) * 100) / 100);
473
+ const lineAmounts = lineItems.map((li) => li.netMinor);
474
+ const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
475
+ let remaining = base;
476
+ const recalcedOrderDiscounts = orderDiscounts.map((d) => {
477
+ const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === "percentage" ? base : remaining));
478
+ remaining -= amountMinor;
479
+ return { ...d, amountMinor };
480
+ });
481
+ const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
482
+ const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
483
+ const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines);
484
+ const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
485
+ const taxInclusive = taxContext.pricesIncludeTax;
486
+ const figures = lines.map((li) => {
487
+ const returned = li.unitPriceMinor < 0;
488
+ const discounts = returned ? [] : li.discounts.map((d) => ({
489
+ discountId: d.id,
490
+ ...d.label !== void 0 ? { label: d.label } : {},
491
+ amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive)
492
+ }));
493
+ const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
494
+ const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
495
+ const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
496
+ return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
497
+ });
498
+ const displayLines = figures.map((f) => f.line);
499
+ const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
500
+ const displayDiscount = displayLines.reduce(
501
+ (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum),
502
+ orderDiscountDisplay
503
+ );
504
+ const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
505
+ const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
506
+ const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
507
+ const conversions = converted.reduce((count, index) => count + [
508
+ lines[index].netMinor,
509
+ lines[index].orderDiscountMinor,
510
+ ...lines[index].discounts.map((d) => d.amountMinor)
511
+ ].filter((x) => x !== 0).length, 0);
512
+ assertDisplayResidue(residue, converted.length, conversions);
513
+ let left = residue;
514
+ const takers = converted.filter((index) => !figures[index].returned);
515
+ for (const index of takers.sort((a, b) => figures[b].remainingMinor - figures[a].remainingMinor || a - b)) {
516
+ if (left === 0) break;
517
+ const take = left > 0 ? left : Math.max(left, -figures[index].remainingMinor);
518
+ displayLines[index].amountMinor += take;
519
+ left -= take;
520
+ }
521
+ if (left !== 0) throw new Error(`Display residue ${left} left over with no converted line to take it (ADR-063)`);
522
+ if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
523
+ for (const { line, shareMinor, returned } of figures) {
524
+ if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
525
+ if (returned) continue;
526
+ const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
527
+ if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
528
+ throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
529
+ }
530
+ }
531
+ const display = {
532
+ taxInclusive,
533
+ subtotalMinor: displaySubtotal,
534
+ discountMinor: displayDiscount,
535
+ taxMinor,
536
+ totalMinor,
537
+ lines: displayLines,
538
+ orderDiscountMinor: orderDiscountDisplay
539
+ };
540
+ const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
541
+ const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
542
+ const changeDueMinor = Math.max(0, paidMinor - totalMinor);
297
543
  return {
298
544
  id: orderId,
299
545
  status: "draft",
300
- lineItems: [...lineItems],
546
+ lineItems: lines,
301
547
  discounts: recalcedOrderDiscounts,
302
548
  payments: [...payments],
303
549
  customer,
304
550
  note,
305
- subtotal,
306
- discountTotal: Math.round(discountTotal * 100) / 100,
307
- taxTotal: Math.round(taxTotal * 100) / 100,
308
- total: roundedTotal,
309
- balanceDue,
310
- changeDue,
551
+ subtotalMinor,
552
+ discountMinor,
553
+ taxMinor,
554
+ totalMinor,
555
+ display,
556
+ paidMinor,
557
+ balanceDueMinor,
558
+ changeDueMinor,
311
559
  currency,
560
+ pricesIncludeTax: taxContext.pricesIncludeTax,
312
561
  createdAt: now,
313
562
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
314
563
  };
@@ -316,45 +565,72 @@ function createOrderBuilder(options) {
316
565
  function emit() {
317
566
  subject.next(buildOrder());
318
567
  }
568
+ function addLine(input) {
569
+ const { productId, variantId, unitPrice } = input;
570
+ const quantity = input.quantity ?? 1;
571
+ if (unitPrice.currency !== currency) throw new RangeError("Line currency must match " + currency);
572
+ if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
573
+ if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
574
+ const taxRates = input.taxRates ?? [{ ratePpm: taxContext.getTaxRatePpm() }];
575
+ const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
576
+ const existing = lineItems.find(
577
+ (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)
578
+ );
579
+ if (existing) {
580
+ lineItems = lineItems.map(
581
+ (li) => li.id === existing.id ? recalculateLine({ ...li, quantity: li.quantity + quantity }) : li
582
+ );
583
+ emit();
584
+ return existing.id;
585
+ }
586
+ const lineId = uid();
587
+ const line = {
588
+ id: lineId,
589
+ productId,
590
+ variantId,
591
+ name: input.name,
592
+ sku: input.sku ?? "",
593
+ imageUrl: input.imageUrl,
594
+ unitPriceMinor: unitPrice.amount,
595
+ quantity,
596
+ taxLines: taxRates.map((tax) => ({ ...tax, taxMicros: "0" })),
597
+ discounts: [],
598
+ discountMinor: 0,
599
+ orderDiscountMinor: 0,
600
+ netMinor: 0,
601
+ taxMicros: "0",
602
+ taxInclusive,
603
+ // TV4b derives the store-level flag with Medusa's own precedence (region
604
+ // preference, then currency), so a disagreement should be rare. It can
605
+ // still happen, for example when a price preference changes after the
606
+ // settings were read. Keeping the line in its own mode means the customer
607
+ // pays exactly the shelf price, as Medusa's checkout would.
608
+ ...taxInclusive !== taxContext.pricesIncludeTax ? { priceTaxModeConverted: taxInclusive ? "inclusive-to-exclusive" : "exclusive-to-inclusive" } : {}
609
+ };
610
+ lineItems = [...lineItems, recalculateLine(line)];
611
+ emit();
612
+ return lineId;
613
+ }
319
614
  return {
320
615
  order$: subject.asObservable(),
616
+ addLine,
321
617
  addProduct(doc, traits, opts) {
322
618
  const productId = traits.getId(doc);
323
- const variantId = opts?.variantId;
324
- const quantity = opts?.quantity ?? 1;
325
- const existing = lineItems.find(
326
- (li) => li.productId === productId && li.variantId === variantId
327
- );
328
- if (existing) {
329
- existing.quantity += quantity;
330
- lineItems = lineItems.map(
331
- (li) => li.id === existing.id ? recalculateLine(existing) : li
332
- );
333
- emit();
334
- return existing.id;
335
- }
336
- const priceStr = traits.getPrice(doc);
337
- const price = priceStr ? parseFloat(priceStr) : 0;
338
- const taxRate = taxContext.getTaxRate();
339
- const lineId = uid();
340
- const line = {
341
- id: lineId,
619
+ if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
620
+ const variant = opts?.variantId !== void 0 && traits.getVariants ? traits.getVariants(doc, { currency }).find((variant2) => variant2.id === opts.variantId) : void 0;
621
+ if (opts?.variantId !== void 0 && traits.getVariants && !variant)
622
+ throw new Error(`Unknown variant ${opts.variantId} for product ${productId}`);
623
+ const unitPrice = resolvePrice(variant ? variant.prices : traits.getPrices(doc, { currency }), currency)?.current;
624
+ if (!unitPrice) throw new Error("No price in " + currency + " for product " + productId);
625
+ return addLine({
342
626
  productId,
343
- variantId,
627
+ variantId: opts?.variantId,
344
628
  name: traits.getName(doc),
345
- sku: traits.getSku(doc) ?? "",
629
+ sku: variant?.sku ?? traits.getSku(doc),
346
630
  imageUrl: traits.getImageUrl(doc),
347
- price,
348
- quantity,
349
- taxRate,
350
- taxAmount: 0,
351
- discounts: [],
352
- discountAmount: 0,
353
- lineTotal: 0
354
- };
355
- lineItems = [...lineItems, recalculateLine(line)];
356
- emit();
357
- return lineId;
631
+ unitPrice,
632
+ quantity: opts?.quantity
633
+ });
358
634
  },
359
635
  updateQuantity(lineId, quantity) {
360
636
  if (quantity <= 0) {
@@ -373,11 +649,11 @@ function createOrderBuilder(options) {
373
649
  applyLineDiscount(lineId, discount) {
374
650
  lineItems = lineItems.map((li) => {
375
651
  if (li.id !== lineId) return li;
376
- const base = li.price * li.quantity;
652
+ const base = li.unitPriceMinor * li.quantity;
377
653
  const applied = {
378
654
  ...discount,
379
655
  id: uid(),
380
- amount: computeDiscountAmount(discount, base)
656
+ amountMinor: computeDiscountAmount(discount, base)
381
657
  };
382
658
  return recalculateLine({ ...li, discounts: [...li.discounts, applied] });
383
659
  });
@@ -387,7 +663,7 @@ function createOrderBuilder(options) {
387
663
  const applied = {
388
664
  ...discount,
389
665
  id: uid(),
390
- amount: 0
666
+ amountMinor: computeDiscountAmount(discount, 0)
391
667
  };
392
668
  orderDiscounts = [...orderDiscounts, applied];
393
669
  emit();
@@ -404,10 +680,10 @@ function createOrderBuilder(options) {
404
680
  emit();
405
681
  },
406
682
  addPayment(payment) {
407
- const id = uid();
408
- payments = [...payments, { ...payment, id }];
683
+ const id2 = uid();
684
+ payments = [...payments, { ...payment, id: id2 }];
409
685
  emit();
410
- return id;
686
+ return id2;
411
687
  },
412
688
  removePayment(paymentId) {
413
689
  payments = payments.filter((p) => p.id !== paymentId);
@@ -438,7 +714,8 @@ function createOrderBuilder(options) {
438
714
  // src/order/order-manager.ts
439
715
  import { BehaviorSubject as BehaviorSubject2, map as map2 } from "rxjs";
440
716
  function createOrderManager(options) {
441
- const { currency, taxContext, draftsCollection } = options;
717
+ const { taxContext, draftsCollection } = options;
718
+ const currency = options.currency.toUpperCase();
442
719
  let activeBuilder = createOrderBuilder({ currency, taxContext });
443
720
  const activeSubject = new BehaviorSubject2(activeBuilder);
444
721
  const parkedOrders$ = draftsCollection.find().$.pipe(
@@ -449,7 +726,7 @@ function createOrderManager(options) {
449
726
  id: json.id,
450
727
  customerName: json.customerName || void 0,
451
728
  itemCount: json.itemCount ?? 0,
452
- total: json.total ?? 0,
729
+ totalMinor: json.total ?? 0,
453
730
  parkedAt: json.parkedAt ?? "",
454
731
  source: "local"
455
732
  };
@@ -471,7 +748,7 @@ function createOrderManager(options) {
471
748
  data: JSON.stringify(snapshot),
472
749
  customerName: snapshot.customer?.name ?? "",
473
750
  itemCount: snapshot.lineItems.length,
474
- total: snapshot.total,
751
+ total: snapshot.totalMinor,
475
752
  parkedAt: (/* @__PURE__ */ new Date()).toISOString()
476
753
  });
477
754
  activeBuilder = createOrderBuilder({ currency, taxContext });
@@ -495,43 +772,23 @@ function createOrderManager(options) {
495
772
  builder.setNote(savedOrder.note);
496
773
  }
497
774
  for (const line of savedOrder.lineItems) {
498
- const syntheticTraits = {
499
- getId: (d) => d.productId,
500
- getName: (d) => d.name,
501
- getSku: (d) => d.sku,
502
- getPrice: (d) => String(d.price),
503
- getRegularPrice: (d) => String(d.price),
504
- getSalePrice: () => void 0,
505
- isOnSale: () => false,
506
- getImageUrl: (d) => d.imageUrl,
507
- getImageUrls: (d) => d.imageUrl ? [d.imageUrl] : [],
508
- getDescription: () => void 0,
509
- getStockStatus: () => "instock",
510
- getStockQuantity: () => null,
511
- hasVariants: () => false,
512
- getType: () => "simple",
513
- getBarcode: () => void 0,
514
- getCategoryNames: () => []
515
- };
516
- builder.addProduct(line, syntheticTraits, {
775
+ const lineId = builder.addLine({
776
+ productId: line.productId,
517
777
  variantId: line.variantId,
518
- quantity: line.quantity
778
+ name: line.name,
779
+ sku: line.sku,
780
+ imageUrl: line.imageUrl,
781
+ unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
782
+ quantity: line.quantity,
783
+ taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm }))
519
784
  });
520
- }
521
- const resumedOrder = builder.getSnapshot();
522
- for (const savedLine of savedOrder.lineItems) {
523
- const newLine = resumedOrder.lineItems.find(
524
- (li) => li.productId === savedLine.productId && li.variantId === savedLine.variantId
525
- );
526
- if (newLine) {
527
- for (const discount of savedLine.discounts) {
528
- builder.applyLineDiscount(newLine.id, {
529
- type: discount.type,
530
- value: discount.value,
531
- label: discount.label,
532
- couponCode: discount.couponCode
533
- });
534
- }
785
+ for (const discount of line.discounts) {
786
+ builder.applyLineDiscount(lineId, {
787
+ type: discount.type,
788
+ value: discount.value,
789
+ label: discount.label,
790
+ couponCode: discount.couponCode
791
+ });
535
792
  }
536
793
  }
537
794
  for (const discount of savedOrder.discounts) {
@@ -545,7 +802,9 @@ function createOrderManager(options) {
545
802
  for (const payment of savedOrder.payments) {
546
803
  builder.addPayment({
547
804
  method: payment.method,
548
- amount: payment.amount,
805
+ amountMinor: payment.amountMinor,
806
+ tenderedMinor: payment.tenderedMinor,
807
+ changeMinor: payment.changeMinor,
549
808
  reference: payment.reference
550
809
  });
551
810
  }
@@ -563,16 +822,20 @@ function createOrderManager(options) {
563
822
 
564
823
  // src/receipt/build-receipt-data.ts
565
824
  function buildReceiptData(order, config) {
566
- const taxByRate = /* @__PURE__ */ new Map();
567
- for (const line of order.lineItems) {
568
- const existing = taxByRate.get(line.taxRate) ?? 0;
569
- taxByRate.set(line.taxRate, existing + line.taxAmount);
570
- }
571
- const taxLines = Array.from(taxByRate.entries()).map(([rate, amount]) => ({
572
- label: config.taxLabels?.[rate] ?? `Tax ${Math.round(rate * 100)}%`,
573
- rate,
574
- amount: Math.round(amount * 100) / 100
575
- }));
825
+ const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
826
+ ({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
827
+ );
828
+ let taxSoFar = order.lineItems.reduce(
829
+ (sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros),
830
+ 0n
831
+ );
832
+ const lineTotals = order.lineItems.map((li) => {
833
+ if (!li.priceTaxModeConverted) return li.netMinor;
834
+ const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
835
+ taxSoFar += BigInt(li.taxMicros);
836
+ return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
837
+ });
838
+ const label = (d) => d.label ?? d.couponCode ?? `${d.type} discount`;
576
839
  return {
577
840
  header: {
578
841
  storeName: config.storeName,
@@ -582,30 +845,36 @@ function buildReceiptData(order, config) {
582
845
  cashier: config.cashier,
583
846
  register: config.register
584
847
  },
585
- lineItems: order.lineItems.map((li) => ({
848
+ lineItems: order.lineItems.map((li, index) => ({
586
849
  name: li.name,
587
850
  sku: li.sku,
588
851
  quantity: li.quantity,
589
- unitPrice: li.price,
590
- lineTotal: li.lineTotal
852
+ unitPriceMinor: li.unitPriceMinor,
853
+ lineTotalMinor: lineTotals[index],
854
+ // Line plus allocated order discounts, in the line's own mode; lineTotalMinor is already after it (ADR-062).
855
+ ...li.discountMinor > 0 ? { discountMinor: li.discountMinor } : {},
856
+ displayAmountMinor: order.display.lines[index].amountMinor,
857
+ displayDiscounts: order.display.lines[index].discounts.map((row, i) => ({ label: label(li.discounts[i]), amountMinor: row.amountMinor }))
591
858
  })),
592
859
  discounts: order.discounts.map((d) => ({
593
- label: d.label ?? d.couponCode ?? `${d.type} discount`,
594
- amount: d.amount
860
+ label: label(d),
861
+ amountMinor: d.amountMinor
595
862
  })),
863
+ orderDiscountMinor: order.display.orderDiscountMinor,
596
864
  totals: {
597
- subtotal: order.subtotal,
598
- discountTotal: order.discountTotal,
865
+ taxInclusive: order.display.taxInclusive,
866
+ subtotalMinor: order.display.subtotalMinor,
867
+ discountMinor: order.display.discountMinor,
599
868
  taxLines,
600
- taxTotal: order.taxTotal,
601
- total: order.total
869
+ taxMinor: order.display.taxMinor,
870
+ totalMinor: order.display.totalMinor
602
871
  },
603
872
  payments: order.payments.map((p) => ({
604
873
  method: p.method,
605
- amount: p.amount,
874
+ amountMinor: p.amountMinor,
606
875
  reference: p.reference
607
876
  })),
608
- changeDue: order.changeDue,
877
+ changeDueMinor: order.changeDueMinor,
609
878
  footer: {
610
879
  note: order.note || void 0,
611
880
  barcode: order.id
@@ -613,20 +882,2969 @@ function buildReceiptData(order, config) {
613
882
  currency: order.currency
614
883
  };
615
884
  }
885
+
886
+ // src/product/search-products.ts
887
+ function searchProducts(docs, term, traits) {
888
+ const query = term.trim().toLowerCase();
889
+ if (!query) return docs;
890
+ const codes = (doc) => [traits.getSku(doc), traits.getBarcode(doc)].filter((code) => Boolean(code)).map((code) => code.toLowerCase());
891
+ const exact = docs.filter((doc) => codes(doc).includes(query));
892
+ if (exact.length) return exact;
893
+ const words = query.split(/\s+/);
894
+ return docs.filter((doc) => {
895
+ const haystack = [traits.getName(doc).toLowerCase(), ...codes(doc)].join(" ");
896
+ return words.every((word) => haystack.includes(word));
897
+ });
898
+ }
899
+
900
+ // src/product/stock.ts
901
+ import { map as map3 } from "rxjs";
902
+ import { STOCK_LEVELS_LAST_PASS } from "@tallyui/core";
903
+ import { withStockOverlay, getProductStock } from "@tallyui/core";
904
+ function stockOverlay$(collection) {
905
+ return collection.find().$.pipe(
906
+ map3((rows) => new Map(rows.map((row) => [row.primary, row.get("value")])))
907
+ );
908
+ }
909
+ function stockOverlayAsOf$(collection) {
910
+ return collection.getLocal$(STOCK_LEVELS_LAST_PASS).pipe(
911
+ map3((doc) => doc?.get("completedAt"))
912
+ );
913
+ }
914
+
915
+ // src/sale/use-sale.ts
916
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
917
+
918
+ // src/pos-order/uuidv7.ts
919
+ function format(ms, bytes) {
920
+ let timestamp = BigInt(ms);
921
+ for (let i = 5; i >= 0; i--) {
922
+ bytes[i] = Number(timestamp & 0xffn);
923
+ timestamp >>= 8n;
924
+ }
925
+ bytes[6] = bytes[6] & 15 | 112;
926
+ bytes[8] = bytes[8] & 63 | 128;
927
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
928
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
929
+ }
930
+ function createUuidv7(options) {
931
+ let lastMs = -Infinity;
932
+ let lastRand = 0n;
933
+ return () => {
934
+ const randomBytes = options?.randomBytes;
935
+ if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error("uuidv7: no random source");
936
+ let ms = Math.max((options?.now ?? Date.now)(), lastMs);
937
+ let rand = lastRand + 1n;
938
+ if (ms === lastMs && rand === 2n ** 74n) ms = lastMs + 1;
939
+ if (ms !== lastMs) {
940
+ const bytes2 = randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16));
941
+ rand = BigInt(bytes2[6] & 15) << 8n | BigInt(bytes2[7]);
942
+ rand = rand << 6n | BigInt(bytes2[8] & 63);
943
+ for (let i = 9; i < 16; i++) rand = rand << 8n | BigInt(bytes2[i]);
944
+ }
945
+ lastMs = ms;
946
+ lastRand = rand;
947
+ const bytes = new Uint8Array(16);
948
+ for (let i = 15; i >= 9; i--) {
949
+ bytes[i] = Number(rand & 0xffn);
950
+ rand >>= 8n;
951
+ }
952
+ bytes[8] = Number(rand & 0x3fn);
953
+ rand >>= 6n;
954
+ bytes[7] = Number(rand & 0xffn);
955
+ bytes[6] = Number(rand >> 8n);
956
+ return format(ms, bytes);
957
+ };
958
+ }
959
+ var monotonic = createUuidv7();
960
+ function uuidv7(now, randomBytes) {
961
+ if (now === void 0 && randomBytes === void 0) return monotonic();
962
+ const ms = now ?? Date.now();
963
+ if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error("uuidv7: no random source");
964
+ return format(ms, randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16)));
965
+ }
966
+
967
+ // src/pos-order/finalize.ts
968
+ function finalizeOrder(order, options = {}) {
969
+ if (!order.lineItems.length) throw new Error("finalize: no lines");
970
+ if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
971
+ throw new Error("finalize: negative discount");
972
+ }
973
+ const hasDiscount = order.discountMinor !== 0 || order.lineItems.some((line) => line.discountMinor !== 0) || order.discounts.some((d) => d.amountMinor !== 0);
974
+ if (hasDiscount && (options.capabilities?.orderCreate ?? 1) < 2) {
975
+ throw new Error("finalize: discounts are not supported by the server yet (order.create v2)");
976
+ }
977
+ for (const payment of order.payments) {
978
+ if (payment.method !== "cash" && payment.method !== "external") {
979
+ throw new Error(`finalize: unsupported payment method ${payment.method}`);
980
+ }
981
+ }
982
+ if (order.paidMinor < order.totalMinor) throw new Error("finalize: underpaid");
983
+ let change = order.paidMinor - order.totalMinor;
984
+ const cash = order.payments.reduce((sum, p) => sum + (p.method === "cash" ? p.amountMinor : 0), 0);
985
+ if (change > cash) throw new Error("finalize: change exceeds cash");
986
+ const newId = options.newId ?? uuidv7;
987
+ const id2 = newId();
988
+ const lines = order.lineItems.map((line) => ({
989
+ id: newId(),
990
+ productId: line.productId,
991
+ ...line.variantId !== void 0 ? { variantId: line.variantId } : {},
992
+ name: line.name,
993
+ sku: line.sku,
994
+ quantity: line.quantity,
995
+ unitPriceMinor: line.unitPriceMinor,
996
+ discountMinor: line.discountMinor,
997
+ netMinor: line.netMinor,
998
+ taxLines: line.taxLines.map((tax) => ({ ...tax })),
999
+ ...line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}
1000
+ }));
1001
+ const payments = order.payments.map((payment) => ({
1002
+ id: newId(),
1003
+ method: payment.method,
1004
+ amountMinor: payment.amountMinor,
1005
+ ...payment.reference !== void 0 ? { reference: payment.reference } : {}
1006
+ }));
1007
+ for (let i = payments.length - 1; i >= 0; i--) {
1008
+ const payment = payments[i];
1009
+ if (payment.method !== "cash") continue;
1010
+ payment.tenderedMinor = payment.amountMinor;
1011
+ payment.changeMinor = Math.min(change, payment.tenderedMinor);
1012
+ payment.amountMinor -= payment.changeMinor;
1013
+ change -= payment.changeMinor;
1014
+ }
1015
+ if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
1016
+ throw new Error("finalize: payments do not reconcile");
1017
+ }
1018
+ const now = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
1019
+ return {
1020
+ id: id2,
1021
+ createdAt: now,
1022
+ updatedAt: now,
1023
+ commandId: newId(),
1024
+ syncStatus: "pending",
1025
+ currency: order.currency,
1026
+ pricesIncludeTax: order.pricesIncludeTax,
1027
+ lines,
1028
+ payments,
1029
+ subtotalMinor: order.subtotalMinor,
1030
+ discountMinor: order.discountMinor,
1031
+ taxMinor: order.taxMinor,
1032
+ totalMinor: order.totalMinor,
1033
+ customer: order.customer ? {
1034
+ id: order.customer.id,
1035
+ name: order.customer.name,
1036
+ ...order.customer.email !== void 0 ? { email: order.customer.email } : {}
1037
+ } : null,
1038
+ ...order.note ? { note: order.note } : {},
1039
+ ...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
+ };
1085
+ }
1086
+
1087
+ // src/pos-order/schema.ts
1088
+ import { addRxPlugin } from "rxdb";
1089
+ import { RxDBMigrationSchemaPlugin } from "rxdb/plugins/migration-schema";
1090
+ var posOrderSchema = {
1091
+ version: 2,
1092
+ primaryKey: "id",
1093
+ type: "object",
1094
+ additionalProperties: false,
1095
+ properties: {
1096
+ id: { type: "string", maxLength: 36 },
1097
+ commandId: { type: "string", maxLength: 36 },
1098
+ createdAt: { type: "string", maxLength: 40 },
1099
+ updatedAt: { type: "string" },
1100
+ currency: { type: "string" },
1101
+ pricesIncludeTax: { type: "boolean" },
1102
+ subtotalMinor: { type: "integer" },
1103
+ discountMinor: { type: "integer" },
1104
+ taxMinor: { type: "integer" },
1105
+ totalMinor: { type: "integer" },
1106
+ syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
1107
+ note: { type: "string" },
1108
+ registerId: { type: "string" },
1109
+ sessionId: { type: "string" },
1110
+ cashierRef: { type: "string" },
1111
+ lines: { type: "array", items: {
1112
+ type: "object",
1113
+ properties: {
1114
+ id: { type: "string", maxLength: 36 },
1115
+ productId: { type: "string" },
1116
+ variantId: { type: "string" },
1117
+ name: { type: "string" },
1118
+ sku: { type: "string" },
1119
+ quantity: { type: "integer" },
1120
+ unitPriceMinor: { type: "integer" },
1121
+ discountMinor: { type: "integer" },
1122
+ netMinor: { type: "integer" },
1123
+ taxLines: { type: "array", items: {
1124
+ type: "object",
1125
+ properties: { code: { type: "string" }, ratePpm: { type: "integer" }, taxMicros: { type: "string" } },
1126
+ required: ["ratePpm", "taxMicros"]
1127
+ } }
1128
+ },
1129
+ required: ["id", "productId", "name", "sku", "quantity", "unitPriceMinor", "discountMinor", "netMinor", "taxLines"]
1130
+ } },
1131
+ payments: { type: "array", items: {
1132
+ type: "object",
1133
+ properties: {
1134
+ id: { type: "string", maxLength: 36 },
1135
+ method: { type: "string", enum: ["cash", "external"] },
1136
+ amountMinor: { type: "integer" },
1137
+ tenderedMinor: { type: "integer" },
1138
+ changeMinor: { type: "integer" },
1139
+ reference: { type: "string" }
1140
+ },
1141
+ required: ["id", "method", "amountMinor"]
1142
+ } },
1143
+ customer: { type: ["object", "null"], properties: { id: { type: "string" }, name: { type: "string" }, email: { type: "string" } } },
1144
+ serverRefs: { type: "object", properties: {
1145
+ orderId: { type: "string" },
1146
+ displayId: { type: "string" },
1147
+ totalMinor: { type: "integer" }
1148
+ }, required: ["orderId", "totalMinor"] },
1149
+ warnings: { type: "array", items: {
1150
+ type: "object",
1151
+ properties: { code: { type: "string", maxLength: 64 } },
1152
+ required: ["code"],
1153
+ additionalProperties: true
1154
+ } },
1155
+ error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" } }, required: ["code", "message"] },
1156
+ lateSessionId: { type: "string" },
1157
+ // The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
1158
+ display: { type: "object", additionalProperties: false, properties: {
1159
+ currency: { type: "string" },
1160
+ exponent: { type: "integer" },
1161
+ taxInclusive: { type: "boolean" },
1162
+ subtotalMinor: { type: "integer" },
1163
+ discountMinor: { type: "integer" },
1164
+ taxMinor: { type: "integer" },
1165
+ totalMinor: { type: "integer" },
1166
+ orderDiscountMinor: { type: "integer" },
1167
+ lines: { type: "array", items: {
1168
+ type: "object",
1169
+ additionalProperties: false,
1170
+ properties: {
1171
+ lineId: { type: "string" },
1172
+ amountMinor: { type: "integer" },
1173
+ discounts: { type: "array", items: {
1174
+ type: "object",
1175
+ additionalProperties: false,
1176
+ properties: { discountId: { type: "string" }, label: { type: "string" }, amountMinor: { type: "integer" } },
1177
+ required: ["discountId", "amountMinor"]
1178
+ } }
1179
+ },
1180
+ required: ["lineId", "amountMinor", "discounts"]
1181
+ } }
1182
+ }, required: ["currency", "exponent", "taxInclusive", "subtotalMinor", "discountMinor", "taxMinor", "totalMinor", "orderDiscountMinor", "lines"] },
1183
+ taxByRate: { type: "array", items: {
1184
+ type: "object",
1185
+ additionalProperties: false,
1186
+ properties: {
1187
+ ratePpm: { type: "integer" },
1188
+ code: { type: "string" },
1189
+ label: { type: "string" },
1190
+ netMinor: { type: "integer" },
1191
+ amountMinor: { type: "integer" },
1192
+ grossMinor: { type: "integer" }
1193
+ },
1194
+ required: ["ratePpm", "netMinor", "amountMinor", "grossMinor"]
1195
+ } }
1196
+ },
1197
+ required: [
1198
+ "id",
1199
+ "createdAt",
1200
+ "currency",
1201
+ "pricesIncludeTax",
1202
+ "lines",
1203
+ "subtotalMinor",
1204
+ "discountMinor",
1205
+ "taxMinor",
1206
+ "totalMinor",
1207
+ "payments",
1208
+ "customer",
1209
+ "syncStatus",
1210
+ "commandId",
1211
+ "updatedAt"
1212
+ ],
1213
+ indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"]]
1214
+ };
1215
+ function posOrderCollection() {
1216
+ addRxPlugin(RxDBMigrationSchemaPlugin);
1217
+ const identity2 = (doc) => doc;
1218
+ return { schema: posOrderSchema, migrationStrategies: { 1: identity2, 2: identity2 } };
1219
+ }
1220
+
1221
+ // src/pos-order/open.ts
1222
+ import {
1223
+ deepEqual,
1224
+ defaultConflictHandler,
1225
+ getChangedDocumentsSince,
1226
+ getRxReplicationMetaInstanceSchema,
1227
+ getSingleDocument,
1228
+ hasEncryption,
1229
+ newRxError,
1230
+ overwritable,
1231
+ rxStorageInstanceToReplicationHandler
1232
+ } from "rxdb";
1233
+ import { getOldCollectionMeta, migrateDocumentData } from "rxdb/plugins/migration-schema";
1234
+ import { Subject, takeUntil } from "rxjs";
1235
+ var posOrdersLogger = createLogger("pos-orders");
1236
+ var POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 1e4;
1237
+ var PosOrderOpenClosedError = class extends Error {
1238
+ constructor(databaseName) {
1239
+ super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
1240
+ this.databaseName = databaseName;
1241
+ this.name = "PosOrderOpenClosedError";
1242
+ }
1243
+ code = "POS_ORDER_OPEN_CLOSED";
1244
+ };
1245
+ var latestOpens = /* @__PURE__ */ new WeakMap();
1246
+ var closedUnderOpen = /* @__PURE__ */ new WeakSet();
1247
+ function waitWithTimeout(promise, ms) {
1248
+ return new Promise((resolve) => {
1249
+ const timer = setTimeout(() => resolve(false), ms);
1250
+ promise.finally(() => {
1251
+ clearTimeout(timer);
1252
+ resolve(true);
1253
+ });
1254
+ });
1255
+ }
1256
+ async function writeOverStaleCopies(collection, from, to) {
1257
+ const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
1258
+ for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0; page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
1259
+ const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
1260
+ 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
+ const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
1262
+ if (stale.length > 0) await handler.masterWrite(stale);
1263
+ }
1264
+ }
1265
+ async function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS) {
1266
+ if (db.multiInstance) throw new Error("addPosOrderCollection: multiInstance databases are not supported (ADR-061)");
1267
+ if (db.closed || db.closePromise) throw new PosOrderOpenClosedError(db.name);
1268
+ const previous = latestOpens.get(db);
1269
+ if (!previous) {
1270
+ db.onClose.push(() => waitWithTimeout(latestOpens.get(db), closeWaitMs).then((settled) => {
1271
+ if (!settled) closedUnderOpen.add(db);
1272
+ }));
1273
+ }
1274
+ const opening = openPosOrders(db, previous);
1275
+ latestOpens.set(db, Promise.all([previous, opening.catch(() => void 0)]));
1276
+ return opening;
1277
+ }
1278
+ async function openPosOrders(db, previous) {
1279
+ const closing = () => closedUnderOpen.has(db) || db.closed;
1280
+ const stopIfClosing = () => {
1281
+ if (closing()) throw new PosOrderOpenClosedError(db.name);
1282
+ };
1283
+ const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
1284
+ const state = collection.getMigrationState();
1285
+ try {
1286
+ stopIfClosing();
1287
+ const mustMigrate = await state.mustMigrate;
1288
+ stopIfClosing();
1289
+ if (!mustMigrate) return collection;
1290
+ await previous;
1291
+ stopIfClosing();
1292
+ await state.updateStatus((status2) => {
1293
+ delete status2.error;
1294
+ status2.status = "RUNNING";
1295
+ status2.count = { total: 0, handled: 0, percent: 0 };
1296
+ return status2;
1297
+ });
1298
+ const old = (await state.oldCollectionMeta).data.schema;
1299
+ stopIfClosing();
1300
+ const checkpoint = await db.storage.createStorageInstance({
1301
+ databaseName: db.name,
1302
+ collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
1303
+ databaseInstanceToken: db.token,
1304
+ multiInstance: db.multiInstance,
1305
+ options: {},
1306
+ password: db.password,
1307
+ schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)),
1308
+ devMode: overwritable.isDevMode()
1309
+ });
1310
+ await (closing() ? checkpoint.close() : checkpoint.remove());
1311
+ stopIfClosing();
1312
+ const settled = new Subject();
1313
+ const gate = (target, closed) => new Proxy(target, { get: (t, key) => {
1314
+ 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);
1316
+ } });
1317
+ const internalStore = gate(db.internalStore, async (key, first) => {
1318
+ const ids = key === "bulkWrite" ? first.map((row) => row.document.id) : first;
1319
+ posOrdersLogger.warn(
1320
+ `The database closed during the migration: dropped a ${key === "bulkWrite" ? "status write" : "status read"}`,
1321
+ { database: db.name, ids }
1322
+ );
1323
+ return key === "bulkWrite" ? { error: [] } : [];
1324
+ });
1325
+ state.database = new Proxy(db, { get: (target, key) => key === "internalStore" ? internalStore : Reflect.get(target, key) });
1326
+ const migrateStorage = state.migrateStorage.bind(state);
1327
+ state.migrateStorage = async (from, current, batchSize) => {
1328
+ const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
1329
+ if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
1330
+ return migrateStorage(new Proxy(from, { get: (target, key) => {
1331
+ const value = key === "changeStream" ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
1332
+ return typeof value === "function" ? value.bind(target) : value;
1333
+ } }), to, batchSize);
1334
+ };
1335
+ await state.startMigration().finally(() => settled.next());
1336
+ stopIfClosing();
1337
+ const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data;
1338
+ stopIfClosing();
1339
+ const oldMeta = status?.status === "DONE" ? await getOldCollectionMeta(state) : void 0;
1340
+ stopIfClosing();
1341
+ if (status?.status === "DONE" && !oldMeta) return collection;
1342
+ throw newRxError("DM4", { collection: collection.name, error: status?.error });
1343
+ } catch (error) {
1344
+ await collection.close();
1345
+ throw closing() ? new PosOrderOpenClosedError(db.name) : error;
1346
+ }
1347
+ }
1348
+
1349
+ // src/pos-order/device-id.ts
1350
+ var processId;
1351
+ function getDeviceId(storage, key) {
1352
+ try {
1353
+ if (storage) {
1354
+ const existing = storage.getItem(key);
1355
+ if (existing) return existing;
1356
+ const id2 = uuidv7();
1357
+ storage.setItem(key, id2);
1358
+ return id2;
1359
+ }
1360
+ } catch {
1361
+ }
1362
+ return processId ??= uuidv7();
1363
+ }
1364
+
1365
+ // 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));
1368
+ }
1369
+
1370
+ // src/pos-order/same-sale.ts
1371
+ function sameSale(stored, retried) {
1372
+ return stored.totalMinor === retried.totalMinor && stored.currency === retried.currency && stored.lines.length === retried.lines.length && stored.lines.every((line, i) => {
1373
+ const other = retried.lines[i];
1374
+ return line.id === other.id && line.quantity === other.quantity && line.netMinor === other.netMinor;
1375
+ }) && stored.payments.length === retried.payments.length && stored.payments.every((payment, i) => payment.method === retried.payments[i].method && payment.amountMinor === retried.payments[i].amountMinor);
1376
+ }
1377
+ var OrderContentMismatchError = class extends Error {
1378
+ constructor(orderId) {
1379
+ super(`Order ${orderId} is already stored with different content`);
1380
+ this.orderId = orderId;
1381
+ this.name = "OrderContentMismatchError";
1382
+ }
1383
+ };
1384
+
1385
+ // src/register/movement-input.ts
1386
+ var SERVER_DECIMAL = /^\d+(?:\.\d+)?$/;
1387
+ var MAX_INTEGER_DIGITS = 15;
1388
+ var MAX_REASON_LENGTH = 500;
1389
+ var KEYPAD_DECIMAL_COMMA = /^(\d+),(\d{1,2})$/;
1390
+ function normalizeAmount(raw) {
1391
+ const trimmed = raw.trim();
1392
+ const dotted = trimmed.replace(KEYPAD_DECIMAL_COMMA, "$1.$2");
1393
+ return dotted.replace(/^\./, "0.").replace(/\.$/, "");
1394
+ }
1395
+ function isServerDecimal(value) {
1396
+ if (!SERVER_DECIMAL.test(value)) return false;
1397
+ return value.split(".")[0].replace(/^0+/, "").length <= MAX_INTEGER_DIGITS;
1398
+ }
1399
+ function movementFieldError(input) {
1400
+ if (input.type !== "no_sale") {
1401
+ const amount = normalizeAmount(input.amount);
1402
+ if (!isServerDecimal(amount) || Number(amount) <= 0) return "amount";
1403
+ }
1404
+ const reason = input.reason.trim();
1405
+ if (!reason || reason.length > MAX_REASON_LENGTH) return "reason";
1406
+ return null;
1407
+ }
1408
+
1409
+ // src/register/expected.ts
1410
+ function deriveExpected({
1411
+ session,
1412
+ movements,
1413
+ ledgerRowsBySession
1414
+ }) {
1415
+ const totals = { cash: session.countedFloatMinor };
1416
+ for (const row of ledgerRowsBySession) {
1417
+ if (row.session_id !== session.id || row.status !== "captured") continue;
1418
+ const method = row.kind === "cash" ? "cash" : row.method_id;
1419
+ totals[method] = (totals[method] ?? 0) + row.amountMinor;
1420
+ }
1421
+ const voids = new Set(
1422
+ movements.filter((row) => row.session_id === session.id && row.type === "void").map((row) => row.voids)
1423
+ );
1424
+ for (const row of movements) {
1425
+ if (row.session_id !== session.id || row.voided_by || voids.has(row.id)) continue;
1426
+ if (row.type === "paid_in") totals.cash += row.amountMinor;
1427
+ if (row.type === "paid_out") totals.cash -= row.amountMinor;
1428
+ }
1429
+ return totals;
1430
+ }
1431
+
1432
+ // src/register/register-count.helpers.ts
1433
+ function parseMinor(text2, exponent) {
1434
+ const [whole, fraction = ""] = text2.trim().split(".");
1435
+ if (fraction.length > exponent) return NaN;
1436
+ return Number(whole || "0") * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || "0");
1437
+ }
1438
+ var validAmount = (value, exponent) => /^(?:\d+\.?\d*|\.\d+)$/.test(value) && Number.isFinite(Number(value)) && Number.isSafeInteger(parseMinor(value, exponent));
1439
+ var countVariance = (countedMinor, expectedMinor) => countedMinor - expectedMinor;
1440
+ var overThreshold = (variance, thresholdMinor) => thresholdMinor != null && Math.abs(variance) > thresholdMinor;
1441
+ var closeNeedsApproval = (countedCashMinor, expectedCashMinor, thresholdMinor) => overThreshold(countVariance(countedCashMinor, expectedCashMinor), thresholdMinor);
1442
+ var denominationTotal = (pieces) => Object.entries(pieces).reduce((sum, [value, count]) => sum + Number(value) * count, 0);
1443
+ function varianceText(variance, exponent, format2, t) {
1444
+ if (!variance) return t("register.exact");
1445
+ return `${format2(Math.abs(variance) / 10 ** exponent)} ${t(variance < 0 ? "register.short" : "register.over")}`;
1446
+ }
1447
+
1448
+ // src/register/register-count.denominations.ts
1449
+ var coinsMinor = [200, 100, 50, 20, 10, 5, 2, 1];
1450
+ var denominations = {
1451
+ GBP: [5e3, 2e3, 1e3, 500, ...coinsMinor],
1452
+ EUR: [5e4, 2e4, 1e4, 5e3, 2e3, 1e3, 500, ...coinsMinor],
1453
+ USD: [1e4, 5e3, 2e3, 1e3, 500, 100, 25, 10, 5, 1],
1454
+ default: [1e4, 5e3, 2e3, 1e3, 500, 200, 100, 50, 25, 10, 5, 1]
1455
+ };
1456
+
1457
+ // src/register/schemas.ts
1458
+ import { addRxPlugin as addRxPlugin2 } from "rxdb";
1459
+ import { RxDBLocalDocumentsPlugin } from "rxdb/plugins/local-documents";
1460
+ var id = { type: "string", maxLength: 36 };
1461
+ var text = { type: "string" };
1462
+ var nullableText = { type: ["string", "null"] };
1463
+ var minor = { type: "integer" };
1464
+ var nullableMinor = { type: ["integer", "null"] };
1465
+ var tenders = { type: "object", additionalProperties: minor };
1466
+ var registerSessionSchema = {
1467
+ title: "Register sessions",
1468
+ version: 0,
1469
+ primaryKey: "id",
1470
+ type: "object",
1471
+ additionalProperties: false,
1472
+ properties: {
1473
+ id,
1474
+ register_id: id,
1475
+ store_key: nullableText,
1476
+ status: { type: "string", enum: ["open", "counting", "closed"], maxLength: 8 },
1477
+ business_day: { type: "string", maxLength: 10 },
1478
+ opened_at_gmt: text,
1479
+ opened_by: nullableText,
1480
+ expected_float_minor: nullableMinor,
1481
+ counted_float_minor: minor,
1482
+ opening_variance_minor: nullableMinor,
1483
+ counting_started_at_gmt: nullableText,
1484
+ closed_at_gmt: nullableText,
1485
+ closed_by: nullableText,
1486
+ approval_required: { type: "boolean", default: false },
1487
+ approved_by: nullableText,
1488
+ counted: { type: ["object", "null"], additionalProperties: minor },
1489
+ closure_id: nullableText,
1490
+ pending_status: { ...nullableText, default: null },
1491
+ server_status: { ...nullableText, default: null },
1492
+ status_at: { ...nullableText, default: null },
1493
+ approver_token: { ...nullableText, default: null },
1494
+ server_expected: { type: ["object", "null"], additionalProperties: minor, default: null },
1495
+ server_sales_count: { type: ["integer", "null"], default: null }
1496
+ },
1497
+ required: ["id", "register_id", "status", "opened_at_gmt", "counted_float_minor"],
1498
+ indexes: [["register_id", "status"]]
1499
+ };
1500
+ function registerSessionCollection() {
1501
+ addRxPlugin2(RxDBLocalDocumentsPlugin);
1502
+ return { schema: registerSessionSchema, localDocuments: true };
1503
+ }
1504
+ var cashMovementSchema = {
1505
+ title: "Cash movements",
1506
+ version: 0,
1507
+ primaryKey: "id",
1508
+ type: "object",
1509
+ additionalProperties: false,
1510
+ properties: {
1511
+ id,
1512
+ session_id: id,
1513
+ type: { type: "string", enum: ["paid_in", "paid_out", "no_sale", "void"] },
1514
+ amountMinor: minor,
1515
+ reason: text,
1516
+ created_at_gmt: text,
1517
+ created_by: nullableText,
1518
+ voids: nullableText,
1519
+ voided_by: nullableText
1520
+ },
1521
+ required: ["id", "session_id", "type", "amountMinor", "reason", "created_at_gmt"],
1522
+ indexes: [["session_id"]]
1523
+ };
1524
+ var closureSchema = {
1525
+ title: "Register closures",
1526
+ version: 0,
1527
+ primaryKey: "id",
1528
+ type: "object",
1529
+ additionalProperties: false,
1530
+ properties: {
1531
+ id,
1532
+ session_id: id,
1533
+ register_id: id,
1534
+ store_key: nullableText,
1535
+ business_day: { type: "string", maxLength: 10 },
1536
+ closed_by: nullableText,
1537
+ corrections_count: { type: "integer" },
1538
+ number: { type: "integer" },
1539
+ opened_at: text,
1540
+ closed_at: text,
1541
+ till_expected: tenders,
1542
+ expected: tenders,
1543
+ counted: tenders,
1544
+ variance: tenders,
1545
+ period_sales_total_minor: minor,
1546
+ period_refunds_total_minor: minor,
1547
+ perpetual_sales_total_minor: minor,
1548
+ perpetual_refunds_total_minor: minor,
1549
+ unsynced_count: { type: "integer" },
1550
+ unsynced_total_minor: minor,
1551
+ software_version: text,
1552
+ breakdowns: { type: "object", additionalProperties: true },
1553
+ order_ids: { type: "array", items: text },
1554
+ movement_ids: { type: "array", items: text },
1555
+ server_closure_id: { ...nullableText, default: null },
1556
+ printed_at: { ...nullableText, default: null },
1557
+ print_count: { type: "integer", default: 0 },
1558
+ number_retried: { type: "boolean", default: false }
1559
+ },
1560
+ required: [
1561
+ "id",
1562
+ "session_id",
1563
+ "register_id",
1564
+ "number",
1565
+ "opened_at",
1566
+ "closed_at",
1567
+ "till_expected",
1568
+ "expected",
1569
+ "counted",
1570
+ "variance",
1571
+ "period_sales_total_minor",
1572
+ "period_refunds_total_minor",
1573
+ "perpetual_sales_total_minor",
1574
+ "perpetual_refunds_total_minor",
1575
+ "unsynced_count",
1576
+ "unsynced_total_minor",
1577
+ "software_version",
1578
+ "breakdowns",
1579
+ "order_ids",
1580
+ "movement_ids",
1581
+ "print_count"
1582
+ ],
1583
+ indexes: [["register_id"], ["session_id"]]
1584
+ };
1585
+
1586
+ // src/register/register-document.ts
1587
+ import { map as map4 } from "rxjs";
1588
+ var REGISTER = "register";
1589
+ function mintUuid() {
1590
+ const id2 = globalThis.crypto?.randomUUID?.();
1591
+ if (id2) return id2;
1592
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
1593
+ bytes[6] = bytes[6] & 15 | 64;
1594
+ bytes[8] = bytes[8] & 63 | 128;
1595
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1596
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1597
+ }
1598
+ async function readRegister(host) {
1599
+ return (await host.getLocal(REGISTER))?.toJSON(true).data ?? null;
1600
+ }
1601
+ async function ensureRegister(host, platform) {
1602
+ const existing = await readRegister(host);
1603
+ if (existing) return existing;
1604
+ const id2 = mintUuid().toLowerCase();
1605
+ const data = {
1606
+ id: id2,
1607
+ name: `Register ${id2.slice(-4).toUpperCase()}`,
1608
+ platform,
1609
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1610
+ stores: {}
1611
+ };
1612
+ try {
1613
+ await host.insertLocal(REGISTER, data);
1614
+ return data;
1615
+ } catch (error) {
1616
+ const winner = await readRegister(host);
1617
+ if (winner) return winner;
1618
+ throw error;
1619
+ }
1620
+ }
1621
+ function observeRegister$(host) {
1622
+ return host.getLocal$(REGISTER).pipe(map4((doc) => doc?.toJSON(true).data ?? null));
1623
+ }
1624
+ async function modifyStore(host, storeKey, modify) {
1625
+ const doc = await host.getLocal(REGISTER);
1626
+ if (!doc) throw new Error("Register is not initialized");
1627
+ await doc.incrementalModify((data) => {
1628
+ const store = modify(data.stores[storeKey]);
1629
+ return store ? { ...data, stores: { ...data.stores, [storeKey]: store } } : data;
1630
+ });
1631
+ }
1632
+ function getBoundRegisterId(register, storeKey) {
1633
+ return register?.stores?.[storeKey]?.register_id ?? null;
1634
+ }
1635
+ async function readBoundRegister(host, storeKey) {
1636
+ const register = await readRegister(host);
1637
+ const id2 = getBoundRegisterId(register, storeKey);
1638
+ return id2 ? { id: id2, name: register?.stores[storeKey]?.register_name ?? "" } : null;
1639
+ }
1640
+ function bindRegister(host, storeKey, register) {
1641
+ return modifyStore(host, storeKey, (store) => ({
1642
+ ...store ?? { sale_counter: 0 },
1643
+ register_id: register.id,
1644
+ register_name: register.name
1645
+ }));
1646
+ }
1647
+ function unbindRegister(host, storeKey) {
1648
+ return modifyStore(host, storeKey, (store) => store && { ...store, register_id: null, register_name: null });
1649
+ }
1650
+ async function nextSaleCounter(host, storeKey) {
1651
+ let counter = 0;
1652
+ await modifyStore(host, storeKey, (store) => {
1653
+ counter = (store?.sale_counter ?? 0) + 1;
1654
+ return { ...store ?? {}, sale_counter: counter };
1655
+ });
1656
+ return counter;
1657
+ }
1658
+ function withRegister(store, registerId, bucket) {
1659
+ const base = store ?? { sale_counter: 0 };
1660
+ return { ...base, registers: { ...base.registers, [registerId]: bucket } };
1661
+ }
1662
+ async function mintClosureNumber(host, storeKey, registerId, closure) {
1663
+ let number = 0;
1664
+ await modifyStore(host, storeKey, (store) => {
1665
+ const register = store?.registers?.[registerId] ?? {};
1666
+ const reservation = register.closure_reservation;
1667
+ if (closure && reservation?.row.id === closure.id && !!reservation.row.number_retried === !!closure.number_retried) {
1668
+ number = reservation.row.number;
1669
+ return void 0;
1670
+ }
1671
+ if (closure && reservation && reservation.row.id !== closure.id && !reservation.applied) {
1672
+ throw new Error("closure_write_incomplete");
1673
+ }
1674
+ number = (register.last_closure_number ?? 0) + 1;
1675
+ const applied = closure && reservation?.row.id === closure.id && reservation.applied ? reservation.row : null;
1676
+ const sales = closure ? (register.perpetual_sales_total_minor ?? 0) - (applied?.period_sales_total_minor ?? 0) + closure.period_sales_total_minor : 0;
1677
+ const refunds = closure ? (register.perpetual_refunds_total_minor ?? 0) - (applied?.period_refunds_total_minor ?? 0) + closure.period_refunds_total_minor : 0;
1678
+ const next = closure ? {
1679
+ row: { ...closure, number, perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds },
1680
+ applied: !!applied || !!closure.number_retried
1681
+ } : reservation;
1682
+ return withRegister(store, registerId, {
1683
+ ...register,
1684
+ last_closure_number: number,
1685
+ ...next ? { closure_reservation: next } : {},
1686
+ // An applied period follows the closure row it came from.
1687
+ ...applied ? { perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds } : {}
1688
+ });
1689
+ });
1690
+ return number;
1691
+ }
1692
+ function advancePerpetual(host, storeKey, registerId, period) {
1693
+ return modifyStore(host, storeKey, (store) => {
1694
+ const register = store?.registers?.[registerId] ?? {};
1695
+ const reservation = register.closure_reservation;
1696
+ if (period.closureId && (!reservation || reservation.row.id !== period.closureId || reservation.applied)) {
1697
+ return void 0;
1698
+ }
1699
+ const sales = register.perpetual_sales_total_minor ?? 0;
1700
+ const refunds = register.perpetual_refunds_total_minor ?? 0;
1701
+ return withRegister(store, registerId, {
1702
+ ...register,
1703
+ ...period.closureId && reservation ? { closure_reservation: { ...reservation, applied: true } } : {},
1704
+ perpetual_sales_total_minor: period.closureId && reservation ? Math.max(sales, reservation.row.perpetual_sales_total_minor) : sales + period.salesMinor,
1705
+ perpetual_refunds_total_minor: period.closureId && reservation ? Math.max(refunds, reservation.row.perpetual_refunds_total_minor) : refunds + period.refundsMinor,
1706
+ counters_started_at: register.counters_started_at ?? (/* @__PURE__ */ new Date()).toISOString()
1707
+ });
1708
+ });
1709
+ }
1710
+ function markClosuresSwept(host, storeKey, registerId, closureIds) {
1711
+ return modifyStore(host, storeKey, (store) => {
1712
+ const register = store?.registers?.[registerId] ?? {};
1713
+ const swept = new Set(register.swept_closure_ids);
1714
+ closureIds.forEach((closureId) => swept.add(closureId));
1715
+ return withRegister(store, registerId, { ...register, swept_closure_ids: [...swept] });
1716
+ });
1717
+ }
1718
+
1719
+ // src/register/facts.ts
1720
+ var registerFactsLogger = createLogger("register-session");
1721
+ var attempt = () => ({ operationId: mintUuid().replace(/-/g, "") });
1722
+ function identity(fact) {
1723
+ switch (fact.kind) {
1724
+ case "session-opened":
1725
+ return { operationId: fact.sessionId?.replace(/-/g, "") ?? "" };
1726
+ case "session-closed":
1727
+ return { operationId: fact.closureId.replace(/-/g, "") };
1728
+ case "movement-recorded":
1729
+ case "movement-voided":
1730
+ return { operationId: fact.movementId.replace(/-/g, "") };
1731
+ case "late-sale":
1732
+ return { operationId: fact.orderId.replace(/-/g, "") };
1733
+ default:
1734
+ return attempt();
1735
+ }
1736
+ }
1737
+ function recordRegisterFact(fact) {
1738
+ const terminal = identity(fact);
1739
+ const pair = { sessionId: fact.sessionId, registerId: fact.registerId };
1740
+ const emit = (message, type, context = {}, level = "info") => registerFactsLogger[level](message, { actor: fact.actor, terminal, context: { type, ...pair, ...context } });
1741
+ switch (fact.kind) {
1742
+ case "session-opened":
1743
+ return emit("Register session opened", "register.session-opened", { amount: fact.amount, variance: fact.variance });
1744
+ case "counting-started":
1745
+ return emit("Register session counting started", "register.counting-started");
1746
+ case "counting-abandoned":
1747
+ return emit("Register session counting abandoned", "register.counting-abandoned");
1748
+ case "session-closed":
1749
+ return emit("Register session closed", "register.session-closed", {
1750
+ closureId: fact.closureId,
1751
+ number: fact.number,
1752
+ counted: fact.counted,
1753
+ variance: fact.variance
1754
+ });
1755
+ case "movement-recorded":
1756
+ if (fact.movementType === "no_sale")
1757
+ return emit("Register no-sale recorded", "register.no-sale-recorded", { movementId: fact.movementId });
1758
+ return emit("Register cash movement recorded", "register.movement-recorded", {
1759
+ movementId: fact.movementId,
1760
+ movementType: fact.movementType,
1761
+ amount: fact.amount
1762
+ });
1763
+ case "movement-voided":
1764
+ return emit("Register cash movement voided", "register.movement-voided", {
1765
+ movementId: fact.movementId,
1766
+ movementType: fact.movementType,
1767
+ amount: fact.amount,
1768
+ voids: fact.voids
1769
+ });
1770
+ case "approval-granted":
1771
+ return emit("Register session approval granted", "register.approval-granted", { approvedBy: fact.approvedBy });
1772
+ case "approval-refused":
1773
+ return emit("Register session approval refused", "register.approval-refused", {}, "warn");
1774
+ case "variance-over-threshold":
1775
+ return emit(
1776
+ "Register count exceeds variance threshold",
1777
+ "register.variance-over-threshold",
1778
+ { variance: fact.variance, threshold: fact.threshold },
1779
+ "warn"
1780
+ );
1781
+ // These prove dispatch success, not that paper printed or a physical drawer opened.
1782
+ case "x-report-dispatched":
1783
+ return emit("Register X-report print dispatched", "register.x-report-printed");
1784
+ case "drawer-dispatched":
1785
+ return emit("Register drawer kick dispatched", "register.drawer-opened");
1786
+ // The money was taken, so the sale is kept and queued, outside every closure.
1787
+ case "late-sale":
1788
+ return emit("Register late sale kept outside its session", "register.late-sale", { orderId: fact.orderId }, "warn");
1789
+ }
1790
+ }
1791
+
1792
+ // src/register/session-store.ts
1793
+ var RegisterSessionRequiredError = class extends Error {
1794
+ constructor() {
1795
+ super("register_session_not_open");
1796
+ this.name = "RegisterSessionRequiredError";
1797
+ }
1798
+ };
1799
+ var RegisterSessionClosedError = class extends Error {
1800
+ constructor() {
1801
+ super("register_session_closed");
1802
+ this.name = "RegisterSessionClosedError";
1803
+ }
1804
+ };
1805
+ var RegisterMovementStrandedError = class extends Error {
1806
+ id;
1807
+ session_id;
1808
+ constructor(movement) {
1809
+ super("Recorded, but the session closed while saving. It may not be on this session's Z. Do not enter it again.");
1810
+ this.name = "RegisterMovementStrandedError";
1811
+ this.id = movement.id;
1812
+ this.session_id = movement.session_id;
1813
+ }
1814
+ };
1815
+ var TRANSITIONS = {
1816
+ open: ["counting", "closed"],
1817
+ counting: ["open", "closed"],
1818
+ closed: []
1819
+ };
1820
+ async function readSession(sessions, id2) {
1821
+ const [stored] = await sessions.storageInstance.findDocumentsById([id2], false);
1822
+ return stored;
1823
+ }
1824
+ async function readClosure(closures, id2) {
1825
+ const [stored] = await closures?.storageInstance.findDocumentsById([id2], false) ?? [];
1826
+ return stored;
1827
+ }
1828
+ async function requireLiveSession(sessions, id2) {
1829
+ const session = await readSession(sessions, id2);
1830
+ if (!session) throw new RegisterSessionRequiredError();
1831
+ if (session.status === "closed") throw new RegisterSessionClosedError();
1832
+ return session;
1833
+ }
1834
+ var openSessionSelector = { status: "open" };
1835
+ async function requireOpenSession(sessions, registerId, enabled) {
1836
+ if (!enabled) return null;
1837
+ const session = await sessions?.findOne({ selector: { register_id: registerId ?? "", ...openSessionSelector } }).exec();
1838
+ if (!session) throw new RegisterSessionRequiredError();
1839
+ await session.incrementalPatch({ server_expected: null, server_sales_count: null });
1840
+ return session.id;
1841
+ }
1842
+ async function stampSession(order, sessionId, sessions) {
1843
+ if (order.sessionId !== void 0 && order.sessionId !== sessionId) throw new Error("session_already_stamped");
1844
+ const session = await requireLiveSession(sessions, sessionId);
1845
+ return { ...order, sessionId: session.id };
1846
+ }
1847
+ var SWEEP_GRACE_MS = 10 * 6e4;
1848
+ async function sweepOrphanStamps({ sessions, closures, orders, registerId, register, storeKey, full = false }) {
1849
+ const closed = new Set((await readFresh(sessions, { selector: { register_id: registerId, status: "closed" } })).map(({ id: id2 }) => id2));
1850
+ const swept = new Set((await readRegister(register))?.stores[storeKey]?.registers?.[registerId]?.swept_closure_ids);
1851
+ const unswept = (await readFresh(closures, { selector: { register_id: registerId } })).filter((closure) => closed.has(closure.session_id) && (full || !swept.has(closure.id)));
1852
+ const frozen = new Map(unswept.map((closure) => [closure.session_id, new Set(closure.order_ids)]));
1853
+ const patched = [];
1854
+ if (!frozen.size) return patched;
1855
+ for (const { id: id2, sessionId, cashierRef } of await readFresh(orders, { selector: { sessionId: { $in: [...frozen.keys()] } } })) {
1856
+ if (!sessionId || frozen.get(sessionId)?.has(id2) !== false) continue;
1857
+ let late = false;
1858
+ await (await orders.findOne(id2).exec())?.incrementalModify((doc) => {
1859
+ late = doc.sessionId === sessionId && doc.lateSessionId === void 0;
1860
+ if (!late) return doc;
1861
+ delete doc.sessionId;
1862
+ return { ...doc, lateSessionId: sessionId };
1863
+ });
1864
+ if (!late) continue;
1865
+ patched.push(id2);
1866
+ try {
1867
+ recordRegisterFact({ kind: "late-sale", orderId: id2, sessionId, registerId, actor: { id: cashierRef ?? "", name: "" } });
1868
+ } catch {
1869
+ }
1870
+ }
1871
+ const now = Date.now();
1872
+ const markSwept = unswept.filter((closure) => now - new Date(closure.closed_at).getTime() >= SWEEP_GRACE_MS).map(({ id: id2 }) => id2);
1873
+ if (markSwept.length) await markClosuresSwept(register, storeKey, registerId, markSwept);
1874
+ return patched;
1875
+ }
1876
+ function businessDayOf(atGmt, timezone) {
1877
+ const at2 = new Date(atGmt.endsWith("Z") ? atGmt : `${atGmt}Z`);
1878
+ const format2 = new Intl.DateTimeFormat("en-US", {
1879
+ timeZone: timezone === "device" ? void 0 : timezone,
1880
+ year: "numeric",
1881
+ month: "2-digit",
1882
+ day: "2-digit"
1883
+ });
1884
+ const part = Object.fromEntries(format2.formatToParts(at2).map(({ type, value }) => [type, value]));
1885
+ return `${part.year}-${part.month}-${part.day}`;
1886
+ }
1887
+ function openSession(sessions, input) {
1888
+ const { year, month, day } = input.businessDay;
1889
+ return sessions.insert({
1890
+ id: mintUuid(),
1891
+ register_id: input.registerId,
1892
+ store_key: input.storeKey ?? null,
1893
+ status: "open",
1894
+ opened_at_gmt: (/* @__PURE__ */ new Date()).toISOString(),
1895
+ opened_by: input.openedBy,
1896
+ business_day: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
1897
+ expected_float_minor: input.expectedFloatMinor,
1898
+ counted_float_minor: input.countedFloatMinor,
1899
+ opening_variance_minor: input.expectedFloatMinor === null ? null : countVariance(input.countedFloatMinor, input.expectedFloatMinor)
1900
+ });
1901
+ }
1902
+ async function transition(sessions, id2, status, extra = {}) {
1903
+ const row = await sessions.findOne(id2).exec();
1904
+ if (!row) throw new RegisterSessionRequiredError();
1905
+ if (row.status === "closed" && status === "closed") return row;
1906
+ const at2 = (/* @__PURE__ */ new Date()).toISOString();
1907
+ return row.incrementalModify((doc) => {
1908
+ if (doc.status === "closed" && status === "closed") return doc;
1909
+ if (doc.status === "closed") throw new RegisterSessionClosedError();
1910
+ if (!TRANSITIONS[doc.status].includes(status)) throw new Error(`invalid_session_transition:${doc.status}->${status}`);
1911
+ return {
1912
+ ...doc,
1913
+ ...extra,
1914
+ status,
1915
+ pending_status: status,
1916
+ status_at: at2,
1917
+ ...status === "counting" ? { counting_started_at_gmt: at2 } : {},
1918
+ ...status === "closed" ? { closed_at_gmt: at2 } : {}
1919
+ };
1920
+ });
1921
+ }
1922
+ var startCounting = (sessions, id2) => transition(sessions, id2, "counting");
1923
+ var backToSelling = (sessions, id2) => transition(sessions, id2, "open");
1924
+ async function closeSession(sessions, id2, input) {
1925
+ const session = await sessions.findOne(id2).exec();
1926
+ if (!session) throw new RegisterSessionRequiredError();
1927
+ return transition(sessions, id2, "closed", {
1928
+ business_day: session.business_day || businessDayOf(session.opened_at_gmt, input.timezone ?? "device"),
1929
+ counted: input.counted,
1930
+ closed_by: input.closedBy ?? null,
1931
+ ...input.approvedBy ? { approved_by: input.approvedBy } : {},
1932
+ closure_id: id2
1933
+ });
1934
+ }
1935
+ async function recordMovement(sessions, movements, closures, input) {
1936
+ await requireLiveSession(sessions, input.sessionId);
1937
+ const row = await movements.insert({
1938
+ id: mintUuid(),
1939
+ session_id: input.sessionId,
1940
+ type: input.type,
1941
+ amountMinor: input.amountMinor,
1942
+ reason: input.reason,
1943
+ created_by: input.actor,
1944
+ created_at_gmt: (/* @__PURE__ */ new Date()).toISOString()
1945
+ });
1946
+ let counted;
1947
+ try {
1948
+ const after = await readSession(sessions, input.sessionId);
1949
+ if (after?.status !== "closed") return row;
1950
+ counted = (await closures.findOne(input.sessionId).exec())?.movement_ids;
1951
+ } catch {
1952
+ return row;
1953
+ }
1954
+ if (!counted) throw new RegisterMovementStrandedError(row);
1955
+ if (counted.includes(row.id)) return row;
1956
+ try {
1957
+ await row.remove();
1958
+ } catch {
1959
+ throw new RegisterMovementStrandedError(row);
1960
+ }
1961
+ throw new RegisterSessionClosedError();
1962
+ }
1963
+ async function voidMovement(sessions, movements, movementId, actor, closures) {
1964
+ const row = await movements.findOne(movementId).exec();
1965
+ if (!row || row.type === "void") throw new Error("invalid_void_target");
1966
+ await requireLiveSession(sessions, row.session_id);
1967
+ const id2 = mintUuid();
1968
+ const claimed = await row.incrementalModify((doc) => {
1969
+ doc.voided_by ??= id2;
1970
+ return doc;
1971
+ });
1972
+ const reversalId = claimed.voided_by;
1973
+ const existing = await movements.findOne(reversalId).exec();
1974
+ const reversal = existing ?? await movements.incrementalUpsert({
1975
+ id: reversalId,
1976
+ session_id: row.session_id,
1977
+ type: "void",
1978
+ amountMinor: row.amountMinor,
1979
+ reason: row.reason,
1980
+ created_by: actor,
1981
+ created_at_gmt: (/* @__PURE__ */ new Date()).toISOString(),
1982
+ voids: row.id
1983
+ });
1984
+ let counted;
1985
+ try {
1986
+ const after = await readSession(sessions, row.session_id);
1987
+ if (after?.status !== "closed") return reversal;
1988
+ counted = (await readClosure(closures, row.session_id))?.movement_ids;
1989
+ } catch {
1990
+ return reversal;
1991
+ }
1992
+ if (counted?.includes(reversal.id)) return reversal;
1993
+ throw new RegisterMovementStrandedError(reversal);
1994
+ }
1995
+ async function writeClosure({
1996
+ closures,
1997
+ register,
1998
+ storeKey,
1999
+ session,
2000
+ counted,
2001
+ otherTenders,
2002
+ movements,
2003
+ orders,
2004
+ tillExpected,
2005
+ labels,
2006
+ resolveCashierName,
2007
+ softwareVersion,
2008
+ timezone = "device"
2009
+ }) {
2010
+ if (session.status !== "closed" || !session.closed_at_gmt) throw new Error("register_session_not_closed");
2011
+ const existing = await closures.findOne(session.id).exec();
2012
+ if (existing) {
2013
+ await advancePerpetual(register, storeKey, session.register_id, {
2014
+ salesMinor: existing.period_sales_total_minor,
2015
+ refundsMinor: existing.period_refunds_total_minor,
2016
+ closureId: existing.id
2017
+ });
2018
+ return existing;
2019
+ }
2020
+ const bound = orders.filter((order) => order.sessionId === session.id);
2021
+ const rows = bound.flatMap(
2022
+ (order) => order.payments.map((payment) => ({
2023
+ session_id: order.sessionId,
2024
+ kind: payment.method === "cash" ? "cash" : "other",
2025
+ method_id: payment.method,
2026
+ status: "captured",
2027
+ amountMinor: payment.amountMinor
2028
+ }))
2029
+ );
2030
+ const entries = movements.filter((row2) => row2.session_id === session.id);
2031
+ const till_expected = tillExpected ?? deriveExpected({
2032
+ session: { id: session.id, countedFloatMinor: session.counted_float_minor },
2033
+ movements: entries,
2034
+ ledgerRowsBySession: rows
2035
+ });
2036
+ const counts = { cash: counted, ...otherTenders };
2037
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
2038
+ const payment_methods = {};
2039
+ for (const row2 of rows) {
2040
+ const method = row2.kind === "cash" ? "cash" : row2.method_id;
2041
+ payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row2.amountMinor, refunds_minor: 0 };
2042
+ }
2043
+ const taxRates = /* @__PURE__ */ new Map();
2044
+ for (const order of bound) {
2045
+ const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
2046
+ for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor)) {
2047
+ const existing2 = taxRates.get(String(ratePpm));
2048
+ taxRates.set(String(ratePpm), {
2049
+ ratePpm,
2050
+ net_minor: (existing2?.net_minor ?? 0) + netMinor,
2051
+ tax_minor: (existing2?.tax_minor ?? 0) + amountMinor
2052
+ });
2053
+ }
2054
+ }
2055
+ const unsynced = bound.filter((order) => order.syncStatus === "pending");
2056
+ const draft = {
2057
+ id: session.id,
2058
+ session_id: session.id,
2059
+ register_id: session.register_id,
2060
+ store_key: session.store_key ?? null,
2061
+ number: 0,
2062
+ opened_at: session.opened_at_gmt,
2063
+ business_day: session.business_day || businessDayOf(session.opened_at_gmt, timezone),
2064
+ closed_by: session.closed_by ?? null,
2065
+ closed_at: session.closed_at_gmt,
2066
+ till_expected,
2067
+ expected: till_expected,
2068
+ counted: counts,
2069
+ variance: Object.fromEntries(
2070
+ Object.entries(counts).map(([method, value]) => [method, countVariance(value, till_expected[method] ?? 0)])
2071
+ ),
2072
+ period_sales_total_minor: sum(rows.map((row2) => row2.amountMinor)),
2073
+ period_refunds_total_minor: 0,
2074
+ perpetual_sales_total_minor: 0,
2075
+ perpetual_refunds_total_minor: 0,
2076
+ unsynced_count: unsynced.length,
2077
+ unsynced_total_minor: sum(unsynced.flatMap((order) => order.payments.map((payment) => payment.amountMinor))),
2078
+ software_version: softwareVersion,
2079
+ printed_at: null,
2080
+ print_count: 0,
2081
+ breakdowns: {
2082
+ ...labels,
2083
+ opened_by: session.opened_by ?? null,
2084
+ approved_by: session.approved_by ?? null,
2085
+ payment_methods,
2086
+ tax_rates: Object.fromEntries(
2087
+ [...taxRates.values()].map(({ ratePpm, net_minor, tax_minor }) => [
2088
+ ratePpm,
2089
+ { name: `Tax ${ratePpm / 1e4}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor }
2090
+ ])
2091
+ ),
2092
+ opening_float: {
2093
+ expected_minor: session.expected_float_minor ?? null,
2094
+ counted_minor: session.counted_float_minor,
2095
+ variance_minor: session.opening_variance_minor ?? null
2096
+ },
2097
+ movements: entries.map(({ id: id2, type, amountMinor, reason, voids, voided_by, created_at_gmt, created_by }) => ({
2098
+ id: id2,
2099
+ type,
2100
+ amountMinor,
2101
+ reason,
2102
+ voids: voids ?? null,
2103
+ created_at_gmt,
2104
+ created_by: created_by ?? null,
2105
+ voided_by: voided_by ?? null
2106
+ })),
2107
+ transaction_count: bound.length,
2108
+ refund_count: 0,
2109
+ cashiers: [...new Set(bound.map((order) => order.cashierRef ?? "").filter(Boolean))].map((id2) => ({
2110
+ id: id2,
2111
+ name: resolveCashierName?.(id2) || id2
2112
+ }))
2113
+ },
2114
+ order_ids: bound.map((order) => order.id),
2115
+ movement_ids: entries.map((row2) => row2.id)
2116
+ };
2117
+ await mintClosureNumber(register, storeKey, session.register_id, draft);
2118
+ const reserved = (await readRegister(register)).stores[storeKey].registers[session.register_id].closure_reservation.row;
2119
+ let row;
2120
+ try {
2121
+ row = await closures.insert({ ...reserved, order_ids: [...reserved.order_ids], movement_ids: [...reserved.movement_ids] });
2122
+ } catch (error) {
2123
+ const winner = await closures.findOne(reserved.id).exec();
2124
+ if (!winner) throw error;
2125
+ row = winner;
2126
+ }
2127
+ await advancePerpetual(register, storeKey, session.register_id, {
2128
+ salesMinor: row.period_sales_total_minor,
2129
+ refundsMinor: row.period_refunds_total_minor,
2130
+ closureId: row.id
2131
+ });
2132
+ return row;
2133
+ }
2134
+
2135
+ // src/register/settled-figures.ts
2136
+ function deriveSettled(recorded, corrections) {
2137
+ const settled = {
2138
+ ...recorded,
2139
+ expected: { ...recorded.expected },
2140
+ counted: { ...recorded.counted },
2141
+ variance: { ...recorded.variance }
2142
+ };
2143
+ const seen = /* @__PURE__ */ new Set();
2144
+ for (const correction of [...corrections].sort(
2145
+ (a, b) => a.created_at.localeCompare(b.created_at) || a.id - b.id
2146
+ )) {
2147
+ if (seen.has(correction.id)) continue;
2148
+ seen.add(correction.id);
2149
+ const f = correction.figures;
2150
+ if (correction.type === "recount" && f.counted) Object.assign(settled.counted, f.counted);
2151
+ const deltas = correction.type === "late_movement" ? { cash: f.cash_delta ?? 0 } : correction.type === "late_sale" ? f.expected_delta ?? {} : {};
2152
+ for (const [tender, delta] of Object.entries(deltas))
2153
+ settled.expected[tender] = (settled.expected[tender] ?? 0) + delta;
2154
+ if (correction.type === "late_sale") {
2155
+ settled.period_sales_total_minor += f.sales_delta ?? 0;
2156
+ settled.perpetual_sales_total_minor += f.sales_delta ?? 0;
2157
+ settled.period_refunds_total_minor += f.refunds_delta ?? 0;
2158
+ settled.perpetual_refunds_total_minor += f.refunds_delta ?? 0;
2159
+ }
2160
+ }
2161
+ if (corrections.length)
2162
+ for (const tender of /* @__PURE__ */ new Set([...Object.keys(settled.expected), ...Object.keys(settled.counted)]))
2163
+ settled.variance[tender] = (settled.counted[tender] ?? 0) - (settled.expected[tender] ?? 0);
2164
+ const touched = /* @__PURE__ */ new Set();
2165
+ for (const field of [
2166
+ "expected",
2167
+ "counted",
2168
+ "variance",
2169
+ "period_sales_total_minor",
2170
+ "period_refunds_total_minor",
2171
+ "perpetual_sales_total_minor",
2172
+ "perpetual_refunds_total_minor"
2173
+ ]) {
2174
+ const value = settled[field];
2175
+ if (typeof value === "number") {
2176
+ if (value !== recorded[field]) touched.add(field);
2177
+ } else
2178
+ for (const [tender, amount] of Object.entries(value))
2179
+ if (amount !== (recorded[field][tender] ?? 0))
2180
+ touched.add(`${field}.${tender}`);
2181
+ }
2182
+ return { settled, touched };
2183
+ }
2184
+
2185
+ // src/register/money.ts
2186
+ function minorToDecimal(minor2, exponent) {
2187
+ const sign = minor2 < 0 ? "-" : "";
2188
+ const abs = Math.abs(minor2);
2189
+ const scale = 10 ** exponent;
2190
+ const whole = Math.trunc(abs / scale);
2191
+ const fraction = abs % scale;
2192
+ return exponent === 0 ? `${sign}${whole}` : `${sign}${whole}.${String(fraction).padStart(exponent, "0")}`;
2193
+ }
2194
+
2195
+ // src/register/export-csv.ts
2196
+ var at = (map6, key) => map6[key];
2197
+ function exportCsv(rows, exponent, t, names = {}, storeName = "") {
2198
+ const tenders2 = [
2199
+ ...new Set(
2200
+ rows.flatMap((row) => [
2201
+ ...Object.keys(row.expected),
2202
+ ...Object.keys(row.counted),
2203
+ ...Object.keys(row.variance)
2204
+ ])
2205
+ )
2206
+ ];
2207
+ const cell = (value) => {
2208
+ const text2 = String(value ?? "");
2209
+ return `"${(/^(?:[\t\r\n]|[\s\x00-\x1f\x7f-\x9f]*[=+\-@])/.test(text2) ? "'" + text2 : text2).replace(/"/g, '""')}"`;
2210
+ };
2211
+ const header = ["business_day", "closure", "register", "store", "opened", "closed", "closer"].map(
2212
+ (key) => t(`reports.csv_${key}`)
2213
+ );
2214
+ header.push(
2215
+ ...tenders2.flatMap((method) => [
2216
+ `${t("reports.document.expected")} (${method})`,
2217
+ `${t("register.counted")} (${method})`,
2218
+ `${t("register.variance")} (${method})`
2219
+ ]),
2220
+ t("reports.csv_status")
2221
+ );
2222
+ return [
2223
+ header,
2224
+ ...rows.map((row) => [
2225
+ row.business_day,
2226
+ row.number,
2227
+ row.breakdowns.register_name || names[row.register_id] || row.register_id.slice(0, 8),
2228
+ row.breakdowns.store_name || storeName || row.store_key,
2229
+ row.opened_at,
2230
+ row.closed_at,
2231
+ row.breakdowns.closed_by_name || t("register.unknown_cashier"),
2232
+ ...tenders2.flatMap((method) => {
2233
+ const expected = at(row.expected, method);
2234
+ const counted = at(row.counted, method) ?? (expected === void 0 ? void 0 : 0);
2235
+ const value = at(row.variance, method) ?? (expected === void 0 ? void 0 : (counted ?? 0) - expected);
2236
+ return [
2237
+ expected === void 0 ? "" : minorToDecimal(expected, exponent),
2238
+ counted === void 0 ? "" : minorToDecimal(counted, exponent),
2239
+ value === void 0 ? "" : `${minorToDecimal(Math.abs(value), exponent)} ${t(value < 0 ? "register.short" : value > 0 ? "register.over" : "reports.exact")}`
2240
+ ];
2241
+ }),
2242
+ row.unsynced_count > 0 ? t("register.unsynced") : row.corrections_count ? t("reports.corrected") : ""
2243
+ ])
2244
+ ].map((row) => row.map(cell).join(",")).join("\r\n");
2245
+ }
2246
+
2247
+ // src/register/document-labels.ts
2248
+ var labelKeys = {
2249
+ x_report: "register.x_report",
2250
+ closure: "reports.document.closure",
2251
+ opened: "reports.document.opened",
2252
+ closed: "register.closed",
2253
+ approver: "reports.document.approver",
2254
+ opening_float: "reports.document.opening_float",
2255
+ expected: "reports.document.expected",
2256
+ counted: "register.counted",
2257
+ variance: "register.variance",
2258
+ tenders: "reports.document.tenders",
2259
+ tender: "reports.document.tender",
2260
+ cash_movements: "reports.document.cash_movements",
2261
+ time: "common.time",
2262
+ type: "common.type",
2263
+ amount: "register.amount",
2264
+ reason: "register.reason",
2265
+ sales: "reports.sales",
2266
+ period_sales: "register.period_sales",
2267
+ period_refunds: "register.period_refunds",
2268
+ transactions: "reports.document.transactions",
2269
+ refunds: "common.refunds",
2270
+ payment_method: "reports.document.payment_method",
2271
+ cashiers: "reports.document.cashiers",
2272
+ tax_rates: "common.tax_rates",
2273
+ tax_rate: "reports.document.tax_rate",
2274
+ net: "reports.document.net",
2275
+ tax: "common.tax",
2276
+ gross: "reports.document.gross",
2277
+ perpetual_totals: "reports.document.perpetual_totals",
2278
+ perpetual_sales: "register.perpetual_sales",
2279
+ perpetual_refunds: "register.perpetual_refunds",
2280
+ unsynced_sales: "reports.document.unsynced_sales",
2281
+ short: "reports.document.short",
2282
+ over: "reports.document.over",
2283
+ exact: "reports.exact",
2284
+ voided: "pos_checkout.status_voided",
2285
+ paid_in: "register.paid_in",
2286
+ paid_out: "register.paid_out",
2287
+ no_sale: "register.no_sale",
2288
+ void: "register.void",
2289
+ copy: "reports.document.copy"
2290
+ };
2291
+
2292
+ // src/register/closure-rows.ts
2293
+ var DEFAULT_HISTORY_DAYS = 92;
2294
+ function daysBefore(day, days) {
2295
+ const at2 = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
2296
+ at2.setUTCDate(at2.getUTCDate() - days);
2297
+ return at2.toISOString().slice(0, 10);
2298
+ }
2299
+ function clampClosureScope(scope, today, historyDays = DEFAULT_HISTORY_DAYS) {
2300
+ const min = daysBefore(today, historyDays);
2301
+ const clamp = (day) => day < min ? min : day > today ? today : day;
2302
+ return { ...scope, from: clamp(scope.from), to: clamp(scope.to) };
2303
+ }
2304
+ function selectClosureRows(rows, scope, timezone) {
2305
+ return rows.map((row) => ({
2306
+ ...row,
2307
+ business_day: row.business_day || businessDayOf(row.opened_at, timezone)
2308
+ })).filter(
2309
+ (row) => row.business_day >= scope.from && row.business_day <= scope.to && (!scope.registerId || row.register_id === scope.registerId) && (row.store_key ?? null) === (scope.storeKey ?? null) && (scope.cashier === void 0 || row.closed_by === scope.cashier)
2310
+ ).sort((a, b) => b.business_day.localeCompare(a.business_day) || b.closed_at.localeCompare(a.closed_at));
2311
+ }
2312
+
2313
+ // src/register/closure-document.ts
2314
+ var decimalMap = (minor2, exponent) => Object.fromEntries(Object.entries(minor2).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
2315
+ function decimalizeBreakdowns(breakdowns, exponent) {
2316
+ const dec = (m) => typeof m === "number" ? minorToDecimal(m, exponent) : m;
2317
+ const group = (key, pairs) => {
2318
+ const source = breakdowns[key];
2319
+ return source && Object.fromEntries(
2320
+ Object.entries(source).map(([id2, entry]) => [
2321
+ id2,
2322
+ { ...entry, ...Object.fromEntries(pairs.map(([from, to]) => [to, dec(entry[from])])) }
2323
+ ])
2324
+ );
2325
+ };
2326
+ const openingFloat = breakdowns.opening_float;
2327
+ return {
2328
+ ...breakdowns,
2329
+ ...breakdowns.payment_methods ? { payment_methods: group("payment_methods", [["sales_minor", "sales"], ["refunds_minor", "refunds"]]) } : {},
2330
+ ...breakdowns.tax_rates ? { tax_rates: group("tax_rates", [["net_minor", "net"], ["tax_minor", "tax"], ["gross_minor", "gross"]]) } : {},
2331
+ ...openingFloat ? {
2332
+ opening_float: {
2333
+ ...openingFloat,
2334
+ expected: dec(openingFloat.expected_minor),
2335
+ counted: dec(openingFloat.counted_minor),
2336
+ variance: dec(openingFloat.variance_minor)
2337
+ }
2338
+ } : {},
2339
+ ...breakdowns.movements ? { movements: breakdowns.movements.map((m) => ({ ...m, amount: dec(m.amountMinor) })) } : {}
2340
+ };
2341
+ }
2342
+ function formatClosureDate(value, context) {
2343
+ const date = value ? new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`) : null;
2344
+ const timeZone = context.timezone === "device" ? void 0 : context.timezone;
2345
+ const display = (settings) => date ? new Intl.DateTimeFormat(context.locale, { timeZone, ...settings, hour12: false }).format(date) : "";
2346
+ const result = {
2347
+ datetime: display({ dateStyle: "medium", timeStyle: "short" }),
2348
+ date: display({ dateStyle: "medium" }),
2349
+ time: display({ timeStyle: "short" })
2350
+ };
2351
+ for (const style of ["short", "long", "full"]) {
2352
+ result[`datetime_${style}`] = display({ dateStyle: style, timeStyle: style === "short" ? "short" : style });
2353
+ result[`date_${style}`] = display({ dateStyle: style });
2354
+ }
2355
+ const parts = date ? Object.fromEntries(
2356
+ new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date).map(({ type, value: v }) => [type, v])
2357
+ ) : {};
2358
+ result.date_ymd = date ? `${parts.year}-${parts.month}-${parts.day}` : "";
2359
+ result.date_dmy = date ? `${parts.day}/${parts.month}/${parts.year}` : "";
2360
+ result.date_mdy = date ? `${parts.month}/${parts.day}/${parts.year}` : "";
2361
+ result.day = date ? parts.day : "";
2362
+ result.month = date ? parts.month : "";
2363
+ result.year = date ? parts.year : "";
2364
+ for (const style of ["short", "long"]) {
2365
+ result[`weekday_${style}`] = display({ weekday: style });
2366
+ result[`month_${style}`] = display({ month: style });
2367
+ }
2368
+ return result;
2369
+ }
2370
+ function envelope(row, context, xreport = false) {
2371
+ const money = (values, fields) => Object.assign(
2372
+ {},
2373
+ values,
2374
+ Object.fromEntries(fields.map((key) => [`${key}_display`, context.formatMoney(String(values[key] ?? ""))]))
2375
+ );
2376
+ const breakdowns = row.breakdowns ?? {};
2377
+ const labels = {
2378
+ opened_by_name: "",
2379
+ closed_by_name: "",
2380
+ approved_by_name: "",
2381
+ ...Object.fromEntries(Object.entries(breakdowns).filter(([key]) => key.endsWith("_name"))),
2382
+ ...breakdowns.labels
2383
+ };
2384
+ const rows = (key, fields) => Object.entries(breakdowns[key] ?? {}).map(
2385
+ ([entryKey, value]) => money({ ...value, name: value.name ?? value.method ?? value.rate ?? entryKey }, fields)
2386
+ );
2387
+ const methods = breakdowns.payment_methods ?? {};
2388
+ const counted = row.counted ?? {};
2389
+ const expected = row.expected ?? {};
2390
+ const variances = row.variance ?? {};
2391
+ const tenders2 = [.../* @__PURE__ */ new Set([...Object.keys(counted), ...Object.keys(expected)])].map((name) => {
2392
+ const variance = variances[name] ?? "";
2393
+ const method = Object.values(methods).find((m) => m.method === name) ?? methods[name];
2394
+ return money(
2395
+ {
2396
+ name,
2397
+ label: method?.name || name.charAt(0).toUpperCase() + name.slice(1),
2398
+ expected: expected[name] ?? "",
2399
+ counted: counted[name] ?? "",
2400
+ variance,
2401
+ has_variance: Number(variance || 0) !== 0,
2402
+ variance_label: variance === "" ? "" : context.i18n[Number(variance) > 0 ? "over" : Number(variance) < 0 ? "short" : "exact"],
2403
+ variance_absolute_display: context.formatMoney(variance.replace("-", ""))
2404
+ },
2405
+ ["expected", "counted", "variance"]
2406
+ );
2407
+ });
2408
+ return {
2409
+ closure: {
2410
+ has_sales: row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
2411
+ has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
2412
+ ...Object.fromEntries(
2413
+ ["payment_methods", "tax_rates", "movements"].map((key) => [`has_${key}`, Object.keys(breakdowns[key] ?? {}).length > 0])
2414
+ ),
2415
+ ...money(row, ["period_sales_total", "period_refunds_total", "perpetual_sales_total", "perpetual_refunds_total", "unsynced_total"]),
2416
+ opened_at_gmt: row.opened_at,
2417
+ opened_by: breakdowns.opened_by ?? null,
2418
+ closed_by: row.closed_by ?? null,
2419
+ approved_by: breakdowns.approved_by ?? null,
2420
+ closed_at_gmt: row.closed_at,
2421
+ opened_at: formatClosureDate(row.opened_at, context),
2422
+ closed_at: formatClosureDate(row.closed_at, context),
2423
+ tenders: tenders2,
2424
+ breakdowns: {
2425
+ ...breakdowns,
2426
+ labels,
2427
+ payment_methods: rows("payment_methods", ["sales", "refunds"]),
2428
+ tax_rates: rows("tax_rates", ["net", "tax", "gross"]),
2429
+ opening_float: money(breakdowns.opening_float ?? {}, ["expected", "counted", "variance"]),
2430
+ movements: (breakdowns.movements ?? []).map((m) => ({
2431
+ ...money(m, ["amount"]),
2432
+ created_at: formatClosureDate(m.created_at_gmt ?? m.created_at, context),
2433
+ type_label: context.i18n[String(m.type)] ?? m.type,
2434
+ voided: !!m.voided_by
2435
+ })),
2436
+ cashiers: (breakdowns.cashiers ?? []).map((c) => typeof c === "string" ? { id: c, name: c } : c)
2437
+ }
2438
+ },
2439
+ store: context.store,
2440
+ register: { id: row.register_id, name: labels.register_name ?? "" },
2441
+ software: { name: "WCPOS", plugin_version: row.software_version ?? "" },
2442
+ order: { currency: context.currency, printed: formatClosureDate(context.printedAt, context) },
2443
+ fiscal: {
2444
+ immutable_id: "",
2445
+ hash: "",
2446
+ qr_payload: "",
2447
+ tax_agency_code: "",
2448
+ signature_excerpt: "",
2449
+ document_label: "",
2450
+ sequence: null,
2451
+ signed_at: null,
2452
+ extra_fields: [],
2453
+ document_type: xreport ? "xreport" : "closure",
2454
+ receipt_number: xreport ? "" : String(row.number),
2455
+ is_sale_document: false,
2456
+ is_refund_document: false,
2457
+ is_closure_document: true,
2458
+ is_x_report: xreport,
2459
+ is_reprint: !xreport && !!row.print_count,
2460
+ reprint_count: xreport ? 0 : row.print_count ?? 0
2461
+ },
2462
+ i18n: context.i18n
2463
+ };
2464
+ }
2465
+ function buildClosureDocument(row, context) {
2466
+ const { exponent } = context;
2467
+ const dec = (m) => minorToDecimal(m, exponent);
2468
+ const {
2469
+ store_key,
2470
+ till_expected,
2471
+ expected,
2472
+ counted,
2473
+ variance,
2474
+ period_sales_total_minor,
2475
+ period_refunds_total_minor,
2476
+ perpetual_sales_total_minor,
2477
+ perpetual_refunds_total_minor,
2478
+ unsynced_total_minor,
2479
+ ...rest
2480
+ } = row;
2481
+ return envelope(
2482
+ {
2483
+ ...rest,
2484
+ breakdowns: decimalizeBreakdowns(rest.breakdowns, exponent),
2485
+ store_id: store_key,
2486
+ till_expected: decimalMap(till_expected, exponent),
2487
+ expected: decimalMap(expected, exponent),
2488
+ counted: decimalMap(counted, exponent),
2489
+ variance: decimalMap(variance, exponent),
2490
+ period_sales_total: dec(period_sales_total_minor),
2491
+ period_refunds_total: dec(period_refunds_total_minor),
2492
+ perpetual_sales_total: dec(perpetual_sales_total_minor),
2493
+ perpetual_refunds_total: dec(perpetual_refunds_total_minor),
2494
+ unsynced_total: dec(unsynced_total_minor)
2495
+ },
2496
+ context
2497
+ );
2498
+ }
2499
+ function buildXReportDocument(session, context) {
2500
+ const { exponent } = context;
2501
+ const dec = (m) => m == null ? void 0 : minorToDecimal(m, exponent);
2502
+ const expectedMinor = context.expected ?? session.server_expected ?? {};
2503
+ const countedMinor = session.counted ?? {};
2504
+ const varianceMinor = Object.fromEntries(
2505
+ Object.entries(countedMinor).map(([key, value]) => [key, value - (expectedMinor[key] ?? 0)])
2506
+ );
2507
+ return envelope(
2508
+ {
2509
+ id: session.id,
2510
+ register_id: session.register_id,
2511
+ store_id: session.store_key,
2512
+ opened_at: session.opened_at_gmt,
2513
+ expected: decimalMap(expectedMinor, exponent),
2514
+ counted: decimalMap(countedMinor, exponent),
2515
+ variance: decimalMap(varianceMinor, exponent),
2516
+ breakdowns: {
2517
+ ...context.breakdowns,
2518
+ opening_float: {
2519
+ expected: dec(session.expected_float_minor),
2520
+ counted: dec(session.counted_float_minor),
2521
+ variance: dec(session.opening_variance_minor)
2522
+ }
2523
+ }
2524
+ },
2525
+ context,
2526
+ true
2527
+ );
2528
+ }
2529
+
2530
+ // src/register/use-register-session.ts
2531
+ import { useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
2532
+ import { combineLatest, map as map5, of, switchMap } from "rxjs";
2533
+ var RegisterTenderInProgressError = class extends Error {
2534
+ constructor() {
2535
+ super("Finish or cancel the sale in progress first.");
2536
+ this.name = "RegisterTenderInProgressError";
2537
+ }
2538
+ };
2539
+ var RegisterSessionAlreadyOpenError = class extends Error {
2540
+ constructor() {
2541
+ super("This register already has an open session.");
2542
+ this.name = "RegisterSessionAlreadyOpenError";
2543
+ }
2544
+ };
2545
+ var RegisterCloseIncompleteError = class extends Error {
2546
+ constructor() {
2547
+ super("Finish closing the previous session first.");
2548
+ this.name = "RegisterCloseIncompleteError";
2549
+ }
2550
+ };
2551
+ var RegisterApprovalRequiredError = class extends Error {
2552
+ constructor() {
2553
+ super("Manager approval needed. Ask a manager to approve, or count again.");
2554
+ this.name = "RegisterApprovalRequiredError";
2555
+ }
2556
+ };
2557
+ var openingByRegister = /* @__PURE__ */ new Map();
2558
+ var closingByRegister = /* @__PURE__ */ new Map();
2559
+ var closingListeners = /* @__PURE__ */ new Set();
2560
+ var notifyClosing = () => closingListeners.forEach((listener) => listener());
2561
+ var subscribeClosing = (listener) => {
2562
+ closingListeners.add(listener);
2563
+ return () => {
2564
+ closingListeners.delete(listener);
2565
+ };
2566
+ };
2567
+ var closedFacts = /* @__PURE__ */ new Set();
2568
+ var reservationOf = (register, storeKey, registerId) => register?.stores[storeKey]?.registers?.[registerId]?.closure_reservation;
2569
+ var unwritten = (row, closureRows) => row.status === "closed" && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
2570
+ function currentSession(rows, closureRows, reservation) {
2571
+ return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : void 0) ?? rows.find((row) => row.status !== "closed") ?? rows.find((row) => unwritten(row, closureRows)) ?? null;
2572
+ }
2573
+ function ledgerRows(orders) {
2574
+ return orders.flatMap((order) => order.payments.map((payment) => ({
2575
+ session_id: order.sessionId,
2576
+ kind: payment.method === "cash" ? "cash" : "other",
2577
+ method_id: payment.method,
2578
+ status: "captured",
2579
+ amountMinor: payment.amountMinor
2580
+ })));
2581
+ }
2582
+ function useRegisterSession(options) {
2583
+ const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
2584
+ const source = useMemo3(() => {
2585
+ if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
2586
+ return combineLatest([
2587
+ watchFresh(sessions, { selector: { register_id: registerId } }),
2588
+ watchFresh(closures, { selector: { register_id: registerId } }),
2589
+ observeRegister$(register).pipe(map5((document) => reservationOf(document, storeKey, registerId)))
2590
+ ]).pipe(switchMap(([rows, closureRows, reservation]) => {
2591
+ const current2 = currentSession(rows, closureRows, reservation);
2592
+ if (!current2) return of({ rows, closureRows, reservation, entries: [], sales: [] });
2593
+ return combineLatest([
2594
+ watchFresh(movements, { selector: { session_id: current2.id } }),
2595
+ watchFresh(orders, { selector: { sessionId: current2.id } })
2596
+ ]).pipe(map5(([entries2, sales2]) => ({ rows, closureRows, reservation, entries: entries2, sales: sales2 })));
2597
+ }));
2598
+ }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
2599
+ const tender = useRef2(tenderInProgress);
2600
+ tender.current = tenderInProgress;
2601
+ const closing = useSyncExternalStore(subscribeClosing, () => closingByRegister.has(registerId ?? ""));
2602
+ const [observed, setObserved] = useState2(null);
2603
+ useEffect2(() => {
2604
+ if (!source) return;
2605
+ const subscription = source.subscribe((snapshot) => setObserved({ source, snapshot }));
2606
+ return () => subscription.unsubscribe();
2607
+ }, [source]);
2608
+ const [, setMinute] = useState2(0);
2609
+ useEffect2(() => {
2610
+ if (!enabled || !expectedCloseTime) return;
2611
+ const timer = setInterval(() => setMinute((value) => value + 1), 6e4);
2612
+ return () => clearInterval(timer);
2613
+ }, [enabled, expectedCloseTime]);
2614
+ const data = source && observed?.source === source ? observed.snapshot : null;
2615
+ const sweeper = useRef2({});
2616
+ sweeper.current.target = enabled && sessions && closures && orders && registerId && register ? { sessions, closures, orders, registerId, register, storeKey } : void 0;
2617
+ const sweep = (full = false) => {
2618
+ const state = sweeper.current;
2619
+ if (state.running) {
2620
+ state.queuedFull = state.queuedFull || full;
2621
+ return state.queued ??= state.running.then(() => {
2622
+ const runFull = !!state.queuedFull;
2623
+ state.queued = void 0;
2624
+ state.queuedFull = void 0;
2625
+ return sweep(runFull);
2626
+ });
2627
+ }
2628
+ const target = state.target;
2629
+ if (!target) return Promise.resolve();
2630
+ const run = sweepOrphanStamps({ ...target, full }).then(() => void 0, (error) => {
2631
+ try {
2632
+ registerFactsLogger.error("Register orphan-stamp sweep failed", { context: { type: "register.sweep-failed", registerId: target.registerId, error: String(error) } });
2633
+ } catch {
2634
+ }
2635
+ }).finally(() => {
2636
+ state.running = void 0;
2637
+ });
2638
+ return state.running = run;
2639
+ };
2640
+ const seen = useRef2(null);
2641
+ useEffect2(() => {
2642
+ if (!data) return;
2643
+ const ids = data.closureRows.map((row) => row.id);
2644
+ if (seen.current?.source !== source) {
2645
+ seen.current = { source, ids: new Set(ids) };
2646
+ void sweep(true);
2647
+ } else if (ids.some((id2) => !seen.current.ids.has(id2))) {
2648
+ ids.forEach((id2) => seen.current.ids.add(id2));
2649
+ void sweep();
2650
+ }
2651
+ }, [data, source]);
2652
+ const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
2653
+ const entries = data?.entries ?? [];
2654
+ const sales = data?.sales ?? [];
2655
+ const expected = session ? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) }) : {};
2656
+ const now = /* @__PURE__ */ new Date();
2657
+ const [hour, minute] = String(expectedCloseTime ?? "").split(":").map(Number);
2658
+ const overdue = session?.status === "open" && !!expectedCloseTime && now.getTime() > new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute).getTime();
2659
+ const requireOpen = () => requireOpenSession(sessions ?? void 0, registerId, enabled);
2660
+ const requireSaleSession = async () => {
2661
+ const id2 = await requireOpen();
2662
+ return id2 === null ? null : { id: id2, sessions };
2663
+ };
2664
+ const live = () => {
2665
+ if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) throw new RegisterSessionRequiredError();
2666
+ return { sessions, movements, closures, orders, registerId, register };
2667
+ };
2668
+ const current = () => {
2669
+ if (!session) throw new RegisterSessionRequiredError();
2670
+ return session;
2671
+ };
2672
+ const refuseDuringTender = () => {
2673
+ if (tender.current) throw new RegisterTenderInProgressError();
2674
+ };
2675
+ const nameOf = (id2) => !id2 ? "" : id2 === actor.id ? actor.name : labels?.resolveCashierName?.(id2) ?? "";
2676
+ return {
2677
+ session,
2678
+ movements: entries,
2679
+ expected,
2680
+ salesCount: sales.length,
2681
+ overdue,
2682
+ /** A `closeSession` for this register is in flight, in any hook instance: the session can already be stored `closed` while its closure isn't written yet. */
2683
+ closing,
2684
+ lastClosure: [...data?.closureRows ?? []].sort((a, b) => b.closed_at.localeCompare(a.closed_at))[0] ?? null,
2685
+ lastClosed: (data?.rows ?? []).filter((row) => row.status === "closed").sort((a, b) => (b.closed_at_gmt ?? "").localeCompare(a.closed_at_gmt ?? ""))[0] ?? null,
2686
+ varianceThreshold: options.varianceThreshold,
2687
+ enabled,
2688
+ blind: options.blind ?? false,
2689
+ /** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
2690
+ saleSession: session && sessions && session.status !== "closed" ? { id: session.id, sessions } : void 0,
2691
+ /** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
2692
+ requireOpen,
2693
+ /** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
2694
+ requireSaleSession,
2695
+ actions: {
2696
+ /**
2697
+ * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
2698
+ * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
2699
+ * while an earlier close hasn't finished: a new session would lock the till behind it.
2700
+ */
2701
+ openSession: async (input) => {
2702
+ const key = registerId ?? "";
2703
+ if (openingByRegister.has(key)) throw new RegisterSessionAlreadyOpenError();
2704
+ const run = (async () => {
2705
+ const { sessions: sessions2, closures: closures2, registerId: registerId2, register: register2 } = live();
2706
+ const rows = await readFresh(sessions2, { selector: { register_id: registerId2 } });
2707
+ if (rows.some((row2) => row2.status !== "closed")) throw new RegisterSessionAlreadyOpenError();
2708
+ const closureRows = await readFresh(closures2, { selector: { register_id: registerId2 } });
2709
+ const reservation = reservationOf(await readRegister(register2), storeKey, registerId2);
2710
+ if (reservation && !reservation.applied || rows.some((row2) => unwritten(row2, closureRows))) {
2711
+ throw new RegisterCloseIncompleteError();
2712
+ }
2713
+ const [year, month, day] = businessDayOf((/* @__PURE__ */ new Date()).toISOString(), timezone).split("-").map(Number);
2714
+ const row = await openSession(sessions2, {
2715
+ ...input,
2716
+ registerId: registerId2,
2717
+ openedBy: actor.id,
2718
+ businessDay: { year, month, day },
2719
+ storeKey: options.storeKey
2720
+ });
2721
+ recordRegisterFact({
2722
+ kind: "session-opened",
2723
+ actor,
2724
+ sessionId: row.id,
2725
+ registerId: row.register_id,
2726
+ amount: row.counted_float_minor,
2727
+ variance: row.opening_variance_minor
2728
+ });
2729
+ return row;
2730
+ })();
2731
+ openingByRegister.set(key, run);
2732
+ try {
2733
+ return await run;
2734
+ } finally {
2735
+ openingByRegister.delete(key);
2736
+ }
2737
+ },
2738
+ 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;
2743
+ },
2744
+ 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;
2748
+ },
2749
+ /**
2750
+ * `counted` maps each tender to its counted minor units. An interrupted close resumes with
2751
+ * the count persisted on the session, not the one passed to the retry (WCPOS uses the retry's).
2752
+ * Over `varianceThreshold` (`closeNeedsApproval`, as `RegisterCount`), a close without
2753
+ * `approvedBy` throws `RegisterApprovalRequiredError` before any write, blind or not; a
2754
+ * resumed close (the stored session already closed) isn't gated again. `approvedBy` reaches the Z
2755
+ * (`breakdowns.approved_by`), and `approvedByName` its `approved_by_name`.
2756
+ * One close per register runs at a time, in any hook instance: a call while one is in flight joins
2757
+ * it, getting its closure or error, and its own `counted`, `approvedBy` and `approvedByName` are ignored.
2758
+ */
2759
+ closeSession: async (input) => {
2760
+ refuseDuringTender();
2761
+ const key = registerId ?? "";
2762
+ if (closingByRegister.has(key)) return closingByRegister.get(key);
2763
+ const run = (async () => {
2764
+ const { sessions: sessions2, movements: movements2, closures: closures2, orders: orders2, register: register2 } = live();
2765
+ const open = current();
2766
+ const stored = await readSession(sessions2, open.id);
2767
+ if (!stored) throw new RegisterSessionRequiredError();
2768
+ if (stored.status !== "closed" && !input.approvedBy) {
2769
+ const fresh = deriveExpected({
2770
+ session: { id: stored.id, countedFloatMinor: stored.counted_float_minor },
2771
+ movements: await readFresh(movements2, { selector: { session_id: stored.id } }),
2772
+ ledgerRowsBySession: ledgerRows(await readFresh(orders2, { selector: { sessionId: stored.id } }))
2773
+ });
2774
+ if (closeNeedsApproval(input.counted.cash ?? 0, fresh.cash ?? 0, options.varianceThreshold)) throw new RegisterApprovalRequiredError();
2775
+ }
2776
+ const closed = await closeSession(sessions2, open.id, { counted: input.counted, closedBy: actor.id, approvedBy: input.approvedBy, timezone });
2777
+ const approvedByName = input.approvedBy && closed.approved_by === input.approvedBy && input.approvedByName || nameOf(closed.approved_by);
2778
+ const { cash = 0, ...otherTenders } = closed.counted ?? input.counted;
2779
+ const closure = await writeClosure({
2780
+ closures: closures2,
2781
+ register: register2,
2782
+ storeKey,
2783
+ session: closed,
2784
+ counted: cash,
2785
+ otherTenders,
2786
+ timezone,
2787
+ softwareVersion: options.softwareVersion,
2788
+ movements: await readFresh(movements2, { selector: { session_id: closed.id } }),
2789
+ orders: await readFresh(orders2, { selector: { sessionId: closed.id } }),
2790
+ resolveCashierName: labels?.resolveCashierName,
2791
+ labels: {
2792
+ register_name: labels?.registerName ?? "",
2793
+ closed_by_name: nameOf(closed.closed_by),
2794
+ opened_by_name: nameOf(closed.opened_by),
2795
+ approved_by_name: approvedByName
2796
+ }
2797
+ });
2798
+ await sweep();
2799
+ if (!closedFacts.has(closure.id)) {
2800
+ closedFacts.add(closure.id);
2801
+ if (closed.approved_by) {
2802
+ recordRegisterFact({ kind: "approval-granted", actor, sessionId: closed.id, registerId: closed.register_id, approvedBy: closed.approved_by });
2803
+ }
2804
+ recordRegisterFact({
2805
+ kind: "session-closed",
2806
+ actor,
2807
+ sessionId: closed.id,
2808
+ registerId: closed.register_id,
2809
+ closureId: closure.id,
2810
+ number: closure.number,
2811
+ counted: closure.counted,
2812
+ variance: closure.variance
2813
+ });
2814
+ }
2815
+ return closure;
2816
+ })();
2817
+ closingByRegister.set(key, run);
2818
+ notifyClosing();
2819
+ try {
2820
+ return await run;
2821
+ } finally {
2822
+ closingByRegister.delete(key);
2823
+ notifyClosing();
2824
+ }
2825
+ },
2826
+ 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;
2840
+ },
2841
+ 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;
2856
+ }
2857
+ }
2858
+ };
2859
+ }
2860
+
2861
+ // src/sale/cart.ts
2862
+ import { resolvePrice as resolvePrice2 } from "@tallyui/core";
2863
+ var CartError = class extends Error {
2864
+ };
2865
+ function addEntryToCart(builder, entry, traits, currency) {
2866
+ const { product, variant } = entry;
2867
+ const existing = builder.getSnapshot().lineItems.find((line) => line.variantId === variant.id);
2868
+ if (existing) {
2869
+ builder.updateQuantity(existing.id, existing.quantity + 1);
2870
+ return existing.id;
2871
+ }
2872
+ const name = traits.getName(product) + (traits.getVariantCount(product) > 1 ? ` \xB7 ${variant.title}` : "");
2873
+ const unitPrice = resolvePrice2(variant.prices, currency)?.current;
2874
+ if (!unitPrice) throw new CartError(`No ${currency} price for ${name}`);
2875
+ return builder.addLine({
2876
+ productId: traits.getId(product),
2877
+ variantId: variant.id,
2878
+ name,
2879
+ sku: variant.sku ?? "",
2880
+ imageUrl: traits.getImageUrl?.(product),
2881
+ unitPrice
2882
+ });
2883
+ }
2884
+
2885
+ // src/sale/use-sale.ts
2886
+ var saleLogger = createLogger("sale");
2887
+ var DISCOUNTS_UNSUPPORTED = "finalize: discounts are not supported by the server yet (order.create v2)";
2888
+ var SALE_SAVING = "This sale is being saved. Retry to finish it.";
2889
+ var HUNG_SAVE_CHECK_MS = 5e3;
2890
+ function useSale(settings, opts) {
2891
+ const taxContext = useTax();
2892
+ const madeWith = useRef3({ taxContext, currency: settings.currency });
2893
+ const [builder, setBuilder] = useState3(() => createOrderBuilder({ currency: settings.currency, taxContext }));
2894
+ const [order, setOrder] = useState3(() => builder.getSnapshot());
2895
+ const [stage, setStage] = useState3({ kind: "cart" });
2896
+ const [error, setError] = useState3(null);
2897
+ const pending = useRef3(null);
2898
+ const [saving, setSaving] = useState3(false);
2899
+ const tenderSession = useRef3(null);
2900
+ const confirmed = useRef3(null);
2901
+ const [canContinue, setCanContinue] = useState3(false);
2902
+ const attempts = useRef3(0);
2903
+ const hungSaveTimer = useRef3(null);
2904
+ const checking = useRef3(null);
2905
+ function clearHungSaveTimer() {
2906
+ if (hungSaveTimer.current !== null) {
2907
+ clearInterval(hungSaveTimer.current);
2908
+ hungSaveTimer.current = null;
2909
+ }
2910
+ }
2911
+ function confirm(completion) {
2912
+ confirmed.current = completion;
2913
+ setCanContinue(!!completion);
2914
+ clearHungSaveTimer();
2915
+ }
2916
+ const inFlight = useRef3(null);
2917
+ const stageNow = useRef3(stage);
2918
+ stageNow.current = stage;
2919
+ function locked() {
2920
+ const active = !!(inFlight.current || pending.current);
2921
+ if (active) setError(SALE_SAVING);
2922
+ return active;
2923
+ }
2924
+ useEffect3(() => {
2925
+ const subscription = builder.order$.subscribe(setOrder);
2926
+ return () => subscription.unsubscribe();
2927
+ }, [builder]);
2928
+ const idle2 = stage.kind === "cart" && !order.lineItems.length;
2929
+ useEffect3(() => {
2930
+ if (idle2 && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) result.newSale();
2931
+ });
2932
+ useEffect3(() => () => {
2933
+ clearHungSaveTimer();
2934
+ if (pending.current || inFlight.current) {
2935
+ saleLogger.error(
2936
+ "useSale unmounted with a save pending or in flight",
2937
+ { orderId: pending.current?.posOrder.id, stage: stageNow.current.kind }
2938
+ );
2939
+ }
2940
+ }, []);
2941
+ function setTender(tender) {
2942
+ if (locked()) return;
2943
+ const previous = builder.getSnapshot().payments[0];
2944
+ if (previous) builder.removePayment(previous.id);
2945
+ if (tender) builder.addPayment(tender);
2946
+ setError(null);
2947
+ }
2948
+ function checkStored(completion) {
2949
+ const attempt2 = attempts.current;
2950
+ const isStored = opts.isStored;
2951
+ if (!isStored) return;
2952
+ checking.current = completion;
2953
+ void Promise.resolve(completion.posOrder).then(isStored).catch(() => false).then((stored) => {
2954
+ if (stored === true && attempts.current === attempt2 && pending.current === completion) confirm(completion);
2955
+ }).finally(() => {
2956
+ if (checking.current === completion) checking.current = null;
2957
+ });
2958
+ }
2959
+ async function deliver(completion) {
2960
+ clearHungSaveTimer();
2961
+ const timer = opts.isStored ? setInterval(() => {
2962
+ if (checking.current === completion) return;
2963
+ if (pending.current === completion && inFlight.current && confirmed.current !== completion) checkStored(completion);
2964
+ else if (hungSaveTimer.current === timer) clearHungSaveTimer();
2965
+ }, opts.hungSaveCheckMs ?? HUNG_SAVE_CHECK_MS) : null;
2966
+ hungSaveTimer.current = timer;
2967
+ try {
2968
+ await opts.onSaleCompleted?.(completion.posOrder);
2969
+ } catch (error2) {
2970
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
2971
+ const message = error2 instanceof Error ? error2.message : String(error2);
2972
+ if (confirmed.current !== completion) {
2973
+ saleLogger.error("onSaleCompleted failed", { orderId: completion.posOrder.id, error: message });
2974
+ }
2975
+ if (pending.current === completion) {
2976
+ setError(`The sale could not be saved: ${message}`);
2977
+ checkStored(completion);
2978
+ }
2979
+ return;
2980
+ }
2981
+ if (hungSaveTimer.current === timer) clearHungSaveTimer();
2982
+ if (pending.current !== completion) return;
2983
+ pending.current = null;
2984
+ confirm(null);
2985
+ setSaving(false);
2986
+ setError(null);
2987
+ stageNow.current = { kind: "receipt", order: completion.order, posOrder: completion.posOrder };
2988
+ setStage(stageNow.current);
2989
+ }
2990
+ function once(attempt2) {
2991
+ if (!inFlight.current && stageNow.current.kind !== "receipt") {
2992
+ const mine = attempt2().finally(() => {
2993
+ if (inFlight.current === mine) inFlight.current = null;
2994
+ });
2995
+ inFlight.current = mine;
2996
+ }
2997
+ return inFlight.current ?? Promise.resolve();
2998
+ }
2999
+ const result = {
3000
+ order,
3001
+ stage,
3002
+ error,
3003
+ idle: idle2,
3004
+ /** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
3005
+ saving,
3006
+ /** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
3007
+ canContinue,
3008
+ add(entry, traits) {
3009
+ if (locked()) return;
3010
+ try {
3011
+ addEntryToCart(builder, entry, traits, madeWith.current.currency);
3012
+ setError(null);
3013
+ } catch (error2) {
3014
+ if (!(error2 instanceof CartError)) throw error2;
3015
+ setError(error2.message);
3016
+ }
3017
+ },
3018
+ setQuantity(lineId, quantity) {
3019
+ if (!locked()) builder.updateQuantity(lineId, quantity);
3020
+ },
3021
+ remove(lineId) {
3022
+ if (!locked()) builder.removeItem(lineId);
3023
+ },
3024
+ /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
3025
+ applyDiscount(lineId, discount) {
3026
+ if (locked()) return SALE_SAVING;
3027
+ if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
3028
+ const applied = (snapshot) => lineId === null ? snapshot.discounts : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
3029
+ const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
3030
+ if (lineId === null) builder.applyOrderDiscount(discount);
3031
+ else builder.applyLineDiscount(lineId, discount);
3032
+ const added = applied(builder.getSnapshot()).find((entry) => !before.has(entry.id));
3033
+ if (added?.type === "fixed" && added.amountMinor < added.value) {
3034
+ builder.removeDiscount(added.id);
3035
+ return `The discount is more than the ${lineId === null ? "order" : "line"}`;
3036
+ }
3037
+ return null;
3038
+ },
3039
+ removeDiscount(id2) {
3040
+ if (!locked()) builder.removeDiscount(id2);
3041
+ },
3042
+ /**
3043
+ * Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
3044
+ * session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
3045
+ * opened just before (#170 race). A repeat call mid-tender keeps the first pin.
3046
+ */
3047
+ startTender(method, options) {
3048
+ if (locked()) return;
3049
+ const current = builder.getSnapshot();
3050
+ if (!current.lineItems.length) return;
3051
+ tenderSession.current ??= { session: options?.session ?? opts.session };
3052
+ setTender(method === "external" ? { method, amountMinor: current.totalMinor } : null);
3053
+ setStage({ kind: "tender", method });
3054
+ },
3055
+ setTender,
3056
+ cancelTender() {
3057
+ if (locked()) return;
3058
+ setTender(null);
3059
+ tenderSession.current = null;
3060
+ setStage({ kind: "cart" });
3061
+ },
3062
+ /**
3063
+ * Finalizes the tender, stamps the session (see `session`), hands the order to `onSaleCompleted`,
3064
+ * then shows the receipt. `saving` turns true and the sale locks (every change sets SALE_SAVING)
3065
+ * from this call's entry, not only once the order is built; a refused finalize unlocks it again,
3066
+ * with the refusal's error. Idempotent for one tender attempt (DECISIONS, ADR-052): once the order
3067
+ * is built, that order is the sale, kept as the pending completion, because `onSaleCompleted` may
3068
+ * already have stored it before failing. If `onSaleCompleted` throws, the error is set and the tender stays;
3069
+ * calling `complete()` again reuses the pending completion exactly (the same `id`, `commandId`
3070
+ * and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
3071
+ * again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
3072
+ * 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.
3074
+ */
3075
+ complete: () => once(async () => {
3076
+ attempts.current++;
3077
+ confirm(null);
3078
+ if (pending.current) return deliver(pending.current);
3079
+ setSaving(true);
3080
+ const current = builder.getSnapshot();
3081
+ let posOrder;
3082
+ try {
3083
+ posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities });
3084
+ } catch (error2) {
3085
+ setSaving(false);
3086
+ setError(error2.message);
3087
+ return;
3088
+ }
3089
+ const session = tenderSession.current?.session ?? opts.session;
3090
+ if (tenderSession.current && !tenderSession.current.session && session) {
3091
+ try {
3092
+ saleLogger.warn(
3093
+ "the tender started before its session rendered; pass the confirmed session to startTender",
3094
+ { orderId: posOrder.id, sessionId: session.id }
3095
+ );
3096
+ } catch {
3097
+ }
3098
+ }
3099
+ if (session) {
3100
+ try {
3101
+ posOrder = await stampSession(posOrder, session.id, session.sessions);
3102
+ } catch {
3103
+ const { sessionId: _unstamped, ...unstamped } = posOrder;
3104
+ posOrder = { ...unstamped, lateSessionId: session.id };
3105
+ try {
3106
+ recordRegisterFact({
3107
+ kind: "late-sale",
3108
+ orderId: posOrder.id,
3109
+ sessionId: session.id,
3110
+ registerId: opts.registerId,
3111
+ actor: { id: opts.cashierRef, name: "" }
3112
+ });
3113
+ } catch {
3114
+ }
3115
+ }
3116
+ }
3117
+ pending.current = { order: current, posOrder };
3118
+ return deliver(pending.current);
3119
+ }),
3120
+ /**
3121
+ * Starts a new, empty sale. Refused with SALE_SAVING, changing nothing, while a save is still
3122
+ * building or stamping (nothing yet to ask `isStored` about), and while a pending completion
3123
+ * exists and isn't confirmed stored — the ways out are Retry (`complete()`) or Continue, once
3124
+ * `canContinue` (the Front desk, 2026-09-27; #149 review). So an app that doesn't pass `isStored`
3125
+ * gets Retry only after a failed save. A refusal while a save is still delivering its order asks
3126
+ * `isStored` afresh, so a hung save whose order is stored can still offer Continue. Past those, it
3127
+ * abandons a confirmed pending completion (never handed to `onSaleCompleted` again: it's stored; a
3128
+ * save still in flight carries on in the background, a throw logged at error) and starts the new
3129
+ * sale outright — from the receipt, or an idle cart, there's nothing pending to abandon. Abandoning
3130
+ * clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
3131
+ * updates or requeues `pos_orders` itself.
3132
+ */
3133
+ 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);
3150
+ },
3151
+ /**
3152
+ * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
3153
+ * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
3154
+ * its receipt is not shown. Otherwise does nothing.
3155
+ */
3156
+ continueSale() {
3157
+ if (pending.current && confirmed.current === pending.current) result.newSale();
3158
+ }
3159
+ };
3160
+ return result;
3161
+ }
3162
+
3163
+ // src/sale/catalogue.ts
3164
+ import { findVariantByCode, formatMoney as formatMoney2, resolvePrice as resolvePrice3 } from "@tallyui/core";
3165
+ function catalogueEntries(products, traits) {
3166
+ return products.flatMap((product) => traits.getVariants(product).map((variant) => ({ product, variant })));
3167
+ }
3168
+ function findEntryByCode(entries, code) {
3169
+ const variant = findVariantByCode(entries.map((entry) => entry.variant), code);
3170
+ return variant ? entries.find((entry) => entry.variant === variant) : void 0;
3171
+ }
3172
+ function variantPriceLabel(variant, currency, locale) {
3173
+ const price = resolvePrice3(variant.prices, currency);
3174
+ return price ? formatMoney2(price.current, locale) : void 0;
3175
+ }
3176
+
3177
+ // src/outbox/http-transport.ts
3178
+ import { COMMANDS_PATH, PROTOCOL_HEADER, PROTOCOL_VERSION, isCommandBatchResponse } from "@tallyui/core";
3179
+ var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 403, 413, 415, 422]);
3180
+ function createHttpCommandTransport(options) {
3181
+ const fetch = options.fetch ?? globalThis.fetch;
3182
+ let end = options.baseUrl.length;
3183
+ while (end > 0 && options.baseUrl[end - 1] === "/") {
3184
+ end--;
3185
+ }
3186
+ const baseUrl = options.baseUrl.slice(0, end);
3187
+ return {
3188
+ async send(batch) {
3189
+ const controller = new AbortController();
3190
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
3191
+ try {
3192
+ const response = await fetch(`${baseUrl}${COMMANDS_PATH}`, {
3193
+ method: "POST",
3194
+ headers: {
3195
+ "Content-Type": "application/json",
3196
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
3197
+ ...await options.getHeaders()
3198
+ },
3199
+ body: JSON.stringify({ commands: batch }),
3200
+ signal: controller.signal
3201
+ });
3202
+ const retryAfter = response.headers.get("Retry-After");
3203
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
3204
+ const retry = (reason) => ({
3205
+ kind: "retry",
3206
+ reason,
3207
+ ...Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1e3 } : {}
3208
+ });
3209
+ if (response.status === 401) return { kind: "unauthorized" };
3210
+ if (PERMANENT_STATUSES.has(response.status)) {
3211
+ let reason = `status_${response.status}`;
3212
+ try {
3213
+ const body2 = await response.json();
3214
+ if (typeof body2?.message === "string") reason = body2.message;
3215
+ else if (typeof body2?.code === "string") reason = body2.code;
3216
+ } catch {
3217
+ }
3218
+ return { kind: "refused", status: response.status, reason };
3219
+ }
3220
+ if (response.status !== 200) return retry(`status_${response.status}`);
3221
+ let body;
3222
+ try {
3223
+ body = await response.json();
3224
+ } catch {
3225
+ return retry(controller.signal.aborted ? "network" : "bad_body");
3226
+ }
3227
+ return isCommandBatchResponse(body) ? { kind: "results", results: body.results } : retry("bad_body");
3228
+ } catch {
3229
+ return { kind: "retry", reason: "network" };
3230
+ } finally {
3231
+ clearTimeout(timeout);
3232
+ }
3233
+ }
3234
+ };
3235
+ }
3236
+
3237
+ // src/outbox/order-outbox.ts
3238
+ import { BehaviorSubject as BehaviorSubject3 } from "rxjs";
3239
+ var AUTH_FAILURES_BEFORE_PROMPT = 3;
3240
+ var NOT_REQUEUEABLE = /* @__PURE__ */ new Set(["idempotency_mismatch"]);
3241
+ function createOrderOutbox(options) {
3242
+ const { collection, transport, deviceId } = options;
3243
+ const batchSize = Math.min(options.batchSize ?? 10, 10);
3244
+ const initialBackoff = options.initialBackoffMs ?? 1e3;
3245
+ const maxBackoff = options.maxBackoffMs ?? 6e4;
3246
+ const random = options.random ?? Math.random;
3247
+ const now = options.now ?? Date.now;
3248
+ const state$ = new BehaviorSubject3({ pending: 0, sending: false });
3249
+ const attempts = /* @__PURE__ */ new Map();
3250
+ let backoff = initialBackoff;
3251
+ let unauthorizedSinceAccepted = 0;
3252
+ let running;
3253
+ let timer;
3254
+ let subscription;
3255
+ let stopped = false;
3256
+ let insertedDuringRun = false;
3257
+ let stoppedDuringRun = false;
3258
+ async function updateState(patch = {}) {
3259
+ const pending = await countFresh(collection, { syncStatus: "pending" });
3260
+ state$.next({ ...state$.value, ...patch, pending });
3261
+ }
3262
+ function scheduleRetry(reason, retryAfterMs = 0) {
3263
+ state$.next({ ...state$.value, lastRetryReason: reason });
3264
+ if (stopped) {
3265
+ stoppedDuringRun = true;
3266
+ return;
3267
+ }
3268
+ const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
3269
+ backoff = Math.min(backoff * 2, maxBackoff);
3270
+ timer = setTimeout(() => {
3271
+ timer = void 0;
3272
+ flush().catch(() => {
3273
+ });
3274
+ }, delay);
3275
+ state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
3276
+ }
3277
+ async function run() {
3278
+ stoppedDuringRun = false;
3279
+ while (!stopped) try {
3280
+ await updateState({ sending: true, nextAttemptAt: void 0 });
3281
+ insertedDuringRun = false;
3282
+ const orders = await readFresh(collection, {
3283
+ selector: { syncStatus: "pending" },
3284
+ sort: [{ createdAt: "asc" }],
3285
+ limit: batchSize
3286
+ });
3287
+ if (!orders.length || stopped) {
3288
+ stoppedDuringRun = stopped;
3289
+ return;
3290
+ }
3291
+ const batch = orders.map((order) => {
3292
+ const attempt2 = (attempts.get(order.commandId) ?? 0) + 1;
3293
+ attempts.set(order.commandId, attempt2);
3294
+ return toOrderCreateEnvelope(order, deviceId, attempt2);
3295
+ });
3296
+ const outcome = await transport.send(batch);
3297
+ if (outcome.kind === "retry") {
3298
+ return scheduleRetry(outcome.reason, outcome.retryAfterMs);
3299
+ }
3300
+ if (outcome.kind === "unauthorized") {
3301
+ unauthorizedSinceAccepted++;
3302
+ if (unauthorizedSinceAccepted < AUTH_FAILURES_BEFORE_PROMPT) return scheduleRetry("unauthorized");
3303
+ state$.next({
3304
+ ...state$.value,
3305
+ authRequired: true,
3306
+ lastRetryReason: "unauthorized",
3307
+ sending: false,
3308
+ nextAttemptAt: void 0
3309
+ });
3310
+ return;
3311
+ }
3312
+ if (outcome.kind === "refused") {
3313
+ unauthorizedSinceAccepted = 0;
3314
+ state$.next({
3315
+ ...state$.value,
3316
+ refused: { status: outcome.status, reason: outcome.reason },
3317
+ authRequired: false,
3318
+ lastRetryReason: "refused",
3319
+ sending: false,
3320
+ nextAttemptAt: void 0
3321
+ });
3322
+ return;
3323
+ }
3324
+ unauthorizedSinceAccepted = 0;
3325
+ state$.next({ ...state$.value, authRequired: false, refused: void 0 });
3326
+ let progressed = false;
3327
+ for (const order of orders) {
3328
+ const result = outcome.results.find((entry) => entry.id === order.commandId);
3329
+ if (!result) continue;
3330
+ const [stored] = await collection.storageInstance.findDocumentsById([order.id], false);
3331
+ if (stored?.syncStatus !== "pending" || stored.commandId !== order.commandId) continue;
3332
+ const current = await collection.findOne(order.id).exec();
3333
+ if (!current) continue;
3334
+ 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
3340
+ });
3341
+ attempts.delete(order.commandId);
3342
+ progressed = true;
3343
+ await updateState();
3344
+ }
3345
+ if (!progressed) return scheduleRetry("no_progress");
3346
+ backoff = initialBackoff;
3347
+ state$.next({ ...state$.value, lastRetryReason: void 0 });
3348
+ } catch (error) {
3349
+ scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
3350
+ return;
3351
+ }
3352
+ stoppedDuringRun = true;
3353
+ }
3354
+ function flush() {
3355
+ if (running) return running;
3356
+ stopped = false;
3357
+ clearTimeout(timer);
3358
+ timer = void 0;
3359
+ running = Promise.resolve().then(run).finally(async () => {
3360
+ try {
3361
+ await updateState({ sending: false });
3362
+ } catch {
3363
+ }
3364
+ running = void 0;
3365
+ if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === void 0) flush().catch(() => {
3366
+ });
3367
+ });
3368
+ return running;
3369
+ }
3370
+ updateState().catch(() => {
3371
+ });
3372
+ return {
3373
+ state$,
3374
+ flush,
3375
+ async requeue(orderIds) {
3376
+ const orders = (await readFresh(collection, { selector: {
3377
+ syncStatus: "rejected",
3378
+ ...orderIds ? { id: { $in: orderIds } } : {}
3379
+ } })).filter((order) => !NOT_REQUEUEABLE.has(order.error?.code ?? ""));
3380
+ let count = 0;
3381
+ for (const order of orders) {
3382
+ const oldCommandId = order.commandId;
3383
+ let changed = false;
3384
+ await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
3385
+ changed = data.syncStatus === "rejected" && !NOT_REQUEUEABLE.has(data.error?.code ?? "");
3386
+ if (!changed) return data;
3387
+ data.syncStatus = "pending";
3388
+ delete data.error;
3389
+ data.commandId = uuidv7();
3390
+ data.updatedAt = new Date(now()).toISOString();
3391
+ return data;
3392
+ });
3393
+ if (changed) {
3394
+ count++;
3395
+ attempts.delete(oldCommandId);
3396
+ }
3397
+ }
3398
+ await updateState();
3399
+ if (count && !stopped) flush().catch(() => {
3400
+ });
3401
+ return count;
3402
+ },
3403
+ start() {
3404
+ stopped = false;
3405
+ if (!subscription) subscription = collection.$.subscribe((event) => {
3406
+ if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE")) {
3407
+ insertedDuringRun = true;
3408
+ flush().catch(() => {
3409
+ });
3410
+ }
3411
+ });
3412
+ if (!stopped) flush().catch(() => {
3413
+ });
3414
+ },
3415
+ stop() {
3416
+ stopped = true;
3417
+ clearTimeout(timer);
3418
+ timer = void 0;
3419
+ subscription?.unsubscribe();
3420
+ subscription = void 0;
3421
+ state$.next({ ...state$.value, nextAttemptAt: void 0 });
3422
+ }
3423
+ };
3424
+ }
3425
+
3426
+ // src/outbox/use-order-outbox.ts
3427
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
3428
+ var idle = { pending: 0, sending: false };
3429
+ var outboxLogger = createLogger("outbox");
3430
+ function useOrderOutbox(options) {
3431
+ const { storeKey, deviceId } = options;
3432
+ const latest = useRef4(options);
3433
+ latest.current = options;
3434
+ const current = useRef4(null);
3435
+ const openingError = useRef4(null);
3436
+ const loggedMismatches = useRef4(/* @__PURE__ */ new Set());
3437
+ const [orders, setOrders] = useState4(null);
3438
+ const [state, setState] = useState4(idle);
3439
+ const [recent, setRecent] = useState4([]);
3440
+ const savesInFlight = useRef4(0);
3441
+ const [savesInFlightCount, setSavesInFlightCount] = useState4(0);
3442
+ useEffect4(() => {
3443
+ let active = true;
3444
+ let dispose;
3445
+ openingError.current = null;
3446
+ setOrders(null);
3447
+ setState(idle);
3448
+ setRecent([]);
3449
+ if (!storeKey) return;
3450
+ void latest.current.open(storeKey).then(async (store) => {
3451
+ if (!active) {
3452
+ await store.close();
3453
+ return;
3454
+ }
3455
+ const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey) });
3456
+ current.current = { storeKey, orders: store.orders, outbox };
3457
+ const status = outbox.state$.subscribe(setState);
3458
+ const history = watchFresh(store.orders, { sort: [{ createdAt: "desc" }], limit: 50 }).subscribe(setRecent);
3459
+ dispose = () => {
3460
+ outbox.stop();
3461
+ status.unsubscribe();
3462
+ history.unsubscribe();
3463
+ void store.close();
3464
+ };
3465
+ setOrders(store.orders);
3466
+ outbox.start();
3467
+ }).catch((error) => {
3468
+ if (active) openingError.current = error;
3469
+ latest.current.onOpenError?.(error);
3470
+ });
3471
+ return () => {
3472
+ active = false;
3473
+ current.current = null;
3474
+ dispose?.();
3475
+ };
3476
+ }, [storeKey, deviceId]);
3477
+ useEffect4(() => {
3478
+ latest.current.onBusy?.(state.sending);
3479
+ return () => latest.current.onBusy?.(false);
3480
+ }, [state.sending]);
3481
+ const ready = current.current?.storeKey === storeKey && !!storeKey;
3482
+ return {
3483
+ orders: ready ? orders : null,
3484
+ state: ready ? state : idle,
3485
+ recent: ready ? recent : [],
3486
+ async flush() {
3487
+ const opened = current.current;
3488
+ if (opened && opened.storeKey === storeKey) await opened.outbox.flush();
3489
+ },
3490
+ async requeue(orderIds) {
3491
+ const opened = current.current;
3492
+ return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
3493
+ },
3494
+ savesInFlight: savesInFlightCount,
3495
+ async record(posOrder) {
3496
+ savesInFlight.current++;
3497
+ setSavesInFlightCount(savesInFlight.current);
3498
+ try {
3499
+ const opened = current.current;
3500
+ if (!opened || opened.storeKey !== storeKey) throw openingError.current ?? new Error("Orders are not ready.");
3501
+ try {
3502
+ await opened.orders.insert(posOrder);
3503
+ } catch (error) {
3504
+ const stored = error?.code === "CONFLICT" ? error.parameters.writeError : void 0;
3505
+ const inDb = stored?.status === 409 ? stored.documentInDb : void 0;
3506
+ if (!inDb || inDb._deleted) throw error;
3507
+ if (!sameSale(inDb, posOrder)) throw new OrderContentMismatchError(posOrder.id);
3508
+ if (inDb.commandId !== posOrder.commandId) {
3509
+ outboxLogger.warn("Recorded an order stored under another commandId", {
3510
+ orderId: posOrder.id,
3511
+ storedCommandId: inDb.commandId,
3512
+ recordedCommandId: posOrder.commandId
3513
+ });
3514
+ }
3515
+ }
3516
+ if (current.current === opened) void opened.outbox.flush();
3517
+ } finally {
3518
+ savesInFlight.current--;
3519
+ setSavesInFlightCount(savesInFlight.current);
3520
+ }
3521
+ },
3522
+ async isStored(order) {
3523
+ const opened = current.current;
3524
+ if (!opened || opened.storeKey !== storeKey) return false;
3525
+ const [stored] = await opened.orders.storageInstance.findDocumentsById([order.id], false);
3526
+ if (!stored || stored._deleted) return false;
3527
+ if (sameSale(stored, order)) return true;
3528
+ if (!loggedMismatches.current.has(order.id)) {
3529
+ loggedMismatches.current.add(order.id);
3530
+ outboxLogger.error("A stored order has this id with different content", { orderId: order.id });
3531
+ }
3532
+ return false;
3533
+ }
3534
+ };
3535
+ }
3536
+
3537
+ // src/tender/tender-state.ts
3538
+ var MAX_TENDER_MINOR = 999999999;
3539
+ var initialTenderState = {
3540
+ view: "select",
3541
+ methodId: null,
3542
+ readerId: null,
3543
+ transport: null,
3544
+ entryMinor: 0,
3545
+ entryDirty: false,
3546
+ plan: null,
3547
+ splitView: false,
3548
+ splitTab: "even",
3549
+ pickedLineIds: [],
3550
+ linesPaidBy: {}
3551
+ };
3552
+ function initTenderState({
3553
+ methodId,
3554
+ balanceMinor
3555
+ }) {
3556
+ if (!methodId) return initialTenderState;
3557
+ return { ...initialTenderState, view: "amount", methodId, entryMinor: balanceMinor };
3558
+ }
3559
+ function tenderReducer(state, action) {
3560
+ switch (action.type) {
3561
+ case "pick-method":
3562
+ return {
3563
+ ...state,
3564
+ view: "amount",
3565
+ methodId: action.methodId,
3566
+ readerId: action.readerId,
3567
+ transport: action.transport ?? null,
3568
+ entryMinor: state.entryDirty ? state.entryMinor : action.prefillMinor,
3569
+ entryDirty: state.entryDirty
3570
+ };
3571
+ case "pick-transport":
3572
+ return { ...state, transport: action.transport };
3573
+ case "pick-reader":
3574
+ return { ...state, readerId: action.readerId };
3575
+ case "key": {
3576
+ if (state.view !== "amount") {
3577
+ return state;
3578
+ }
3579
+ if (action.key === "backspace") {
3580
+ return { ...state, entryMinor: Math.floor(state.entryMinor / 10), entryDirty: true };
3581
+ }
3582
+ if (action.key === "clear") {
3583
+ return { ...state, entryMinor: 0, entryDirty: true };
3584
+ }
3585
+ const digit = Number(action.key);
3586
+ if (!state.entryDirty) {
3587
+ return { ...state, entryMinor: digit, entryDirty: true };
3588
+ }
3589
+ const entryMinor = state.entryMinor * 10 + digit;
3590
+ return entryMinor > MAX_TENDER_MINOR ? state : { ...state, entryMinor };
3591
+ }
3592
+ case "set-entry":
3593
+ return {
3594
+ ...state,
3595
+ entryMinor: Math.max(0, Math.min(action.minor, MAX_TENDER_MINOR)),
3596
+ entryDirty: true
3597
+ };
3598
+ case "tender-started":
3599
+ return { ...state, entryMinor: 0, entryDirty: false, splitView: false };
3600
+ case "back":
3601
+ return {
3602
+ ...state,
3603
+ view: "select",
3604
+ methodId: null,
3605
+ readerId: null,
3606
+ transport: null,
3607
+ entryMinor: 0,
3608
+ entryDirty: false
3609
+ };
3610
+ case "tender-recorded": {
3611
+ const linesPaidBy = { ...state.linesPaidBy };
3612
+ if (state.plan?.kind === "items" && action.rowsSinceFrom.reduce((sum, row) => sum + row.amountMinor, 0) >= state.plan.firstMinor) {
3613
+ for (const id2 of state.plan.lineIds) {
3614
+ linesPaidBy[id2] ??= [...new Set(action.rowsSinceFrom.map((row) => row.title))];
3615
+ }
3616
+ }
3617
+ const rows = action.rowsSinceFrom.map((row) => ({
3618
+ minor: row.amountMinor,
3619
+ title: row.title
3620
+ }));
3621
+ const plan = activePlan(state.plan, rows.length, action.balanceMinor);
3622
+ const entryMinor = plan ? planLegs(plan, rows, action.balanceMinor).thisPaymentMinor : action.balanceMinor;
3623
+ return {
3624
+ ...state,
3625
+ view: state.methodId ? "amount" : "select",
3626
+ entryMinor,
3627
+ entryDirty: false,
3628
+ splitView: false,
3629
+ pickedLineIds: [],
3630
+ linesPaidBy
3631
+ };
3632
+ }
3633
+ case "open-split":
3634
+ return {
3635
+ ...state,
3636
+ splitView: true,
3637
+ pickedLineIds: state.plan?.kind === "items" ? state.plan.lineIds.filter((id2) => !state.linesPaidBy[id2]) : state.pickedLineIds
3638
+ };
3639
+ case "close-split":
3640
+ return { ...state, splitView: false };
3641
+ case "set-split-tab":
3642
+ return { ...state, splitTab: action.tab };
3643
+ case "toggle-split-line":
3644
+ if (state.linesPaidBy[action.lineId]) return state;
3645
+ return {
3646
+ ...state,
3647
+ pickedLineIds: state.pickedLineIds.includes(action.lineId) ? state.pickedLineIds.filter((id2) => id2 !== action.lineId) : [...state.pickedLineIds, action.lineId]
3648
+ };
3649
+ case "set-plan":
3650
+ return {
3651
+ ...state,
3652
+ plan: action.plan,
3653
+ splitView: false,
3654
+ pickedLineIds: [],
3655
+ entryMinor: state.view === "amount" ? planLegs(action.plan, [], action.balanceMinor).thisPaymentMinor : state.entryMinor,
3656
+ entryDirty: state.view !== "amount" && state.entryDirty
3657
+ };
3658
+ case "clear-plan":
3659
+ case "arm-custom":
3660
+ return {
3661
+ ...state,
3662
+ plan: null,
3663
+ splitView: false,
3664
+ view: action.type === "arm-custom" ? "amount" : state.view,
3665
+ entryMinor: action.type === "clear-plan" ? action.balanceMinor : 0,
3666
+ entryDirty: false
3667
+ };
3668
+ case "request-cancel":
3669
+ return { ...state, view: "cancel", splitView: false };
3670
+ case "reset":
3671
+ return initialTenderState;
3672
+ }
3673
+ }
3674
+ function appliedMinor(entryMinor, balanceMinor) {
3675
+ return Math.max(0, Math.min(entryMinor, balanceMinor));
3676
+ }
3677
+ function changeMinor(entryMinor, appliedAmountMinor, givesChange) {
3678
+ return givesChange ? Math.max(0, entryMinor - appliedAmountMinor) : 0;
3679
+ }
3680
+ function quickTenderedAmounts(balanceMinor, stepsMinor) {
3681
+ if (balanceMinor === 0) {
3682
+ return [];
3683
+ }
3684
+ const amounts = /* @__PURE__ */ new Set([balanceMinor]);
3685
+ for (const stepMinor of stepsMinor) {
3686
+ if (stepMinor <= 0) continue;
3687
+ const remainder = balanceMinor % stepMinor;
3688
+ amounts.add(remainder === 0 ? balanceMinor : balanceMinor + stepMinor - remainder);
3689
+ }
3690
+ return [...amounts].sort((left, right) => left - right);
3691
+ }
3692
+ function evenSplitShareMinor(balanceMinor, ways) {
3693
+ if (ways < 2) {
3694
+ return balanceMinor;
3695
+ }
3696
+ const wholeShare = Math.floor(balanceMinor / ways);
3697
+ const remainder = balanceMinor % ways;
3698
+ return wholeShare + (remainder * 2 >= ways ? 1 : 0);
3699
+ }
3700
+ function activePlan(plan, taken, balanceMinor) {
3701
+ if (!plan || balanceMinor === 0) return null;
3702
+ if (plan.kind !== "items" && taken >= (plan.kind === "even" ? plan.ways : 2)) return null;
3703
+ return plan;
3704
+ }
3705
+ function planLegs(plan, rowsSinceFrom, balanceMinor) {
3706
+ const taken = rowsSinceFrom.length;
3707
+ const legs = rowsSinceFrom.map((row) => ({ ...row, state: "done" }));
3708
+ const groupPaid = rowsSinceFrom.reduce((sum, row) => sum + row.minor, 0);
3709
+ const groupLeft = plan.kind === "items" ? Math.min(balanceMinor, Math.max(0, plan.firstMinor - groupPaid)) : balanceMinor;
3710
+ const rest = plan.kind === "items" && groupLeft === 0;
3711
+ const ways = plan.kind === "fixed" ? 2 : Math.max(plan.ways, taken + 1);
3712
+ const left = plan.kind === "fixed" || rest ? 1 : Math.max(1, ways - taken);
3713
+ const amount = rest ? balanceMinor : groupLeft;
3714
+ const each = plan.kind === "fixed" && taken === 0 ? Math.min(plan.firstMinor, balanceMinor) : evenSplitShareMinor(amount, left);
3715
+ let remaining = amount;
3716
+ for (let i = 0; i < left; i++) {
3717
+ const minor2 = Math.min(remaining, i === left - 1 && plan.kind !== "fixed" ? remaining : each);
3718
+ legs.push({ minor: minor2, state: i === 0 ? "now" : "todo" });
3719
+ remaining -= minor2;
3720
+ }
3721
+ if (plan.kind === "fixed" && remaining > 0) legs.push({ minor: remaining, state: "todo" });
3722
+ if (plan.kind === "items" && !rest && balanceMinor > groupLeft)
3723
+ legs.push({ minor: balanceMinor - groupLeft, state: "rest" });
3724
+ const title = plan.kind === "fixed" && taken === 0 ? plan.title : null;
3725
+ return { legs, thisPaymentMinor: each, label: { n: taken + 1, ways, rest, title } };
3726
+ }
616
3727
  export {
3728
+ CartError,
617
3729
  CurrencyProvider,
3730
+ DISCOUNTS_UNSUPPORTED,
3731
+ MAX_TENDER_MINOR,
3732
+ MICROS_PER_MINOR,
3733
+ OrderContentMismatchError,
3734
+ PosOrderOpenClosedError,
3735
+ RegisterApprovalRequiredError,
3736
+ RegisterCloseIncompleteError,
3737
+ RegisterMovementStrandedError,
3738
+ RegisterSessionAlreadyOpenError,
3739
+ RegisterSessionClosedError,
3740
+ RegisterSessionRequiredError,
3741
+ RegisterTenderInProgressError,
3742
+ SALE_SAVING,
618
3743
  TaxProvider,
619
- addTax,
3744
+ activePlan,
3745
+ addEntryToCart,
3746
+ addPosOrderCollection,
3747
+ advancePerpetual,
3748
+ allocateOrderDiscount,
3749
+ appliedMinor,
3750
+ backToSelling,
3751
+ bindRegister,
3752
+ buildClosureDocument,
620
3753
  buildReceiptData,
621
- calculateTax,
3754
+ buildXReportDocument,
622
3755
  callbackSink,
3756
+ cashMovementSchema,
3757
+ catalogueEntries,
3758
+ changeMinor,
3759
+ clampClosureScope,
3760
+ closeNeedsApproval,
3761
+ closeSession,
3762
+ closureSchema,
3763
+ computeOrderTax,
623
3764
  consoleSink,
3765
+ countFresh,
3766
+ countVariance,
3767
+ createHttpCommandTransport,
624
3768
  createLogger,
625
3769
  createOrderBuilder,
626
3770
  createOrderManager,
3771
+ createOrderOutbox,
627
3772
  createRepository,
628
- extractTax,
629
- formatCurrency,
3773
+ denominationTotal,
3774
+ denominations,
3775
+ deriveExpected,
3776
+ deriveSettled,
3777
+ ensureRegister,
3778
+ evenSplitShareMinor,
3779
+ exportCsv,
3780
+ finalizeOrder,
3781
+ findEntryByCode,
3782
+ formatClosureDate,
3783
+ getBoundRegisterId,
3784
+ getDeviceId,
3785
+ getProductStock,
3786
+ initTenderState,
3787
+ initialTenderState,
3788
+ isServerDecimal,
3789
+ labelKeys,
3790
+ minorToDecimal,
3791
+ mintClosureNumber,
3792
+ mintUuid,
3793
+ movementFieldError,
3794
+ needsAttention,
3795
+ nextSaleCounter,
3796
+ normalizeAmount,
3797
+ observeRegister$,
3798
+ openSession,
3799
+ openSessionSelector,
3800
+ outboxLogger,
3801
+ overThreshold,
3802
+ parseMinor,
3803
+ planLegs,
3804
+ posOrderCollection,
3805
+ posOrderSchema,
3806
+ posOrdersLogger,
3807
+ quickTenderedAmounts,
3808
+ ratePpmFromPercent,
3809
+ readBoundRegister,
3810
+ readFresh,
3811
+ readRegister,
3812
+ recordMovement,
3813
+ recordRegisterFact,
3814
+ registerFactsLogger,
3815
+ registerSessionCollection,
3816
+ registerSessionSchema,
3817
+ requireOpenSession,
3818
+ resolveStoreSettings,
3819
+ roundMicrosToMinor,
3820
+ saleLogger,
3821
+ sameSale,
3822
+ searchProducts,
3823
+ selectClosureRows,
3824
+ stampSession,
3825
+ startCounting,
3826
+ stockOverlay$,
3827
+ stockOverlayAsOf$,
3828
+ taxLinesByRate,
3829
+ taxMicros,
3830
+ taxProviderProps,
3831
+ tenderReducer,
3832
+ toOrderCreateEnvelope,
3833
+ unbindRegister,
3834
+ useCurrencyCode,
630
3835
  useCurrencyFormatter,
631
- useTax
3836
+ useOrderOutbox,
3837
+ useRegisterSession,
3838
+ useSale,
3839
+ useStoreSettings,
3840
+ useTax,
3841
+ uuidv7,
3842
+ validAmount,
3843
+ varianceText,
3844
+ variantPriceLabel,
3845
+ voidMovement,
3846
+ watchFresh,
3847
+ withPricingContext,
3848
+ withStockOverlay,
3849
+ writeClosure
632
3850
  };