@tallyui/pos 0.1.0 → 3.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2031 -43
  3. package/dist/index.js +4629 -205
  4. package/package.json +23 -12
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +53 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +8 -0
  10. package/src/order/order-builder.ts +256 -95
  11. package/src/order/order-manager.ts +26 -43
  12. package/src/order/tax-figures.ts +23 -0
  13. package/src/order/types.ts +73 -15
  14. package/src/outbox/backend-not-found.ts +27 -0
  15. package/src/outbox/http-transport.ts +67 -0
  16. package/src/outbox/index.ts +9 -0
  17. package/src/outbox/logger.ts +3 -0
  18. package/src/outbox/order-outbox.ts +502 -0
  19. package/src/outbox/register-outbox.ts +250 -0
  20. package/src/outbox/types.test-d.ts +22 -0
  21. package/src/outbox/types.ts +36 -0
  22. package/src/outbox/use-order-outbox.ts +175 -0
  23. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  24. package/src/pos-order/command.ts +123 -0
  25. package/src/pos-order/device-id.ts +23 -0
  26. package/src/pos-order/finalize.ts +219 -0
  27. package/src/pos-order/index.ts +10 -0
  28. package/src/pos-order/needs-attention.ts +16 -0
  29. package/src/pos-order/open.ts +269 -0
  30. package/src/pos-order/same-sale.ts +26 -0
  31. package/src/pos-order/schema.ts +130 -0
  32. package/src/pos-order/types.ts +93 -0
  33. package/src/pos-order/uuidv7.ts +54 -0
  34. package/src/product/index.ts +2 -0
  35. package/src/product/search-products.ts +32 -0
  36. package/src/product/stock.ts +25 -0
  37. package/src/receipt/build-receipt-data.ts +39 -23
  38. package/src/receipt/types.ts +17 -10
  39. package/src/register/__fixtures__/closure-local-row.json +61 -0
  40. package/src/register/__fixtures__/closure.json +197 -0
  41. package/src/register/__fixtures__/corrections-dst.json +63 -0
  42. package/src/register/closure-document.ts +277 -0
  43. package/src/register/closure-rows.ts +65 -0
  44. package/src/register/document-labels.ts +46 -0
  45. package/src/register/expected.ts +66 -0
  46. package/src/register/export-csv.ts +74 -0
  47. package/src/register/facts.ts +109 -0
  48. package/src/register/index.ts +32 -0
  49. package/src/register/money.ts +18 -0
  50. package/src/register/movement-input.ts +59 -0
  51. package/src/register/register-commands.ts +147 -0
  52. package/src/register/register-count.denominations.ts +11 -0
  53. package/src/register/register-count.helpers.ts +64 -0
  54. package/src/register/register-document.ts +268 -0
  55. package/src/register/schemas.ts +175 -0
  56. package/src/register/session-store.ts +570 -0
  57. package/src/register/settled-figures.ts +98 -0
  58. package/src/register/use-register-session.ts +469 -0
  59. package/src/rxdb/index.ts +2 -0
  60. package/src/sale/cart.ts +23 -0
  61. package/src/sale/catalogue.ts +21 -0
  62. package/src/sale/index.ts +5 -0
  63. package/src/sale/use-sale.ts +394 -0
  64. package/src/store-settings/index.ts +5 -0
  65. package/src/store-settings/map-store-settings.ts +18 -0
  66. package/src/store-settings/resolve-store-settings.ts +65 -0
  67. package/src/store-settings/use-store-settings.ts +84 -0
  68. package/src/tax/exact.ts +218 -0
  69. package/src/tax/index.ts +4 -3
  70. package/src/tax/tax-provider.tsx +35 -12
  71. package/src/tax/types.ts +8 -6
  72. package/src/tender/index.ts +2 -0
  73. package/src/tender/tender-state.ts +326 -0
  74. package/src/currency/currency-provider.test.tsx +0 -36
  75. package/src/currency/format-currency.test.ts +0 -36
  76. package/src/currency/format-currency.ts +0 -18
  77. package/src/logging/logger.test.ts +0 -154
  78. package/src/logging/sinks.test.ts +0 -60
  79. package/src/order/discount-engine.test.ts +0 -152
  80. package/src/order/order-builder.test.ts +0 -246
  81. package/src/order/order-manager.test.ts +0 -209
  82. package/src/order/payment.test.ts +0 -91
  83. package/src/receipt/build-receipt-data.test.ts +0 -166
  84. package/src/repository/create-repository.test.ts +0 -166
  85. package/src/tax/calculate.test.ts +0 -59
  86. package/src/tax/calculate.ts +0 -32
  87. package/src/tax/tax-provider.test.tsx +0 -51
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
- import { MangoQuery, RxCollection } from 'rxdb';
3
+ import { Money, TaxRounding, TallyConnector, SyncContext, StoreSettingsChoice, StoreSettings, StoreSettingsChoices, ProductTraits, PaymentMethodKind, CommandServerRefs, CommandWarning, CommandError, ServerCapabilities, OrderCreateEnvelope, RegisterCommandType, RegisterCommandResult, VariantSummary, CommandResult, AnyCommandEnvelope, CommandEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
4
+ export { getProductStock, withStockOverlay } from '@tallyui/core';
5
+ export { countFresh, readFresh, watchFresh } from '@tallyui/core/rxdb';
6
+ import * as rxdb from 'rxdb';
7
+ import { MangoQuery, RxCollection, RxJsonSchema, MigrationStrategies, RxDatabase, RxLocalDocumentMutation } from 'rxdb';
8
+ import * as rxjs from 'rxjs';
4
9
  import { Observable } from 'rxjs';
5
- import { ProductTraits } from '@tallyui/core';
6
10
 
7
11
  type LogLevel = 'debug' | 'info' | 'warn' | 'error';
8
12
  interface LogEntry {
@@ -44,42 +48,178 @@ interface CallbackSinkOptions {
44
48
  }
45
49
  declare function callbackSink(options: CallbackSinkOptions): LogSink;
46
50
 
47
- declare function formatCurrency(amount: number, currencyCode: string, locale?: string): string;
48
-
49
51
  interface CurrencyProviderProps {
50
52
  currencyCode: string;
51
53
  locale?: string;
52
54
  children: ReactNode;
53
55
  }
54
56
  declare function CurrencyProvider({ currencyCode, locale, children }: CurrencyProviderProps): react_jsx_runtime.JSX.Element;
55
- declare function useCurrencyFormatter(): (amount: number) => string;
57
+ declare function useCurrencyFormatter(): (money: Money) => string;
58
+ declare function useCurrencyCode(): string;
56
59
 
57
- interface TaxResult {
58
- priceExclTax: number;
59
- priceInclTax: number;
60
- taxAmount: number;
60
+ /** Micro-minor-units per minor unit. */
61
+ declare const MICROS_PER_MINOR = 1000000n;
62
+ /** Converts a plain decimal percentage with at most four fractional digits to safe integer ppm. */
63
+ declare function ratePpmFromPercent(percent: number | string): number;
64
+ /**
65
+ * Exact exclusive tax, or inclusive tax rounded half away from zero to a micro-minor-unit.
66
+ * Throws RangeError unless amount and rate are safe integers and rate is non-negative.
67
+ */
68
+ declare function taxMicros(amountMinor: number, ratePpm: number, pricesIncludeTax: boolean): bigint;
69
+ /** #287: `half_up` is Math.round's rule, Vendure's DefaultMoneyStrategy (default-money-strategy.js:19-21). */
70
+ type RoundingMode = 'half_away_from_zero' | 'half_up';
71
+ /** Rounds micro-minor-units to integer minor units, half away from zero unless `mode` says otherwise. */
72
+ declare function roundMicrosToMinor(micros: bigint, mode?: RoundingMode): number;
73
+ interface TaxLineInput {
74
+ unitPriceMinor: number;
75
+ quantity: number;
76
+ ratePpm: number;
61
77
  }
78
+ interface OrderTaxTotals {
79
+ subtotalMinor: number;
80
+ taxMinor: number;
81
+ totalMinor: number;
82
+ lineTaxMicros: bigint[];
83
+ }
84
+ /**
85
+ * Sums tax on each unit price × quantity and rounds once for the order.
86
+ * Exclusive: total = subtotal + tax. Inclusive: subtotal = total − tax.
87
+ * Empty orders return zeros and an empty lineTaxMicros.
88
+ * Throws RangeError for unsafe integer inputs or totals, negative rates, or quantity < 1.
89
+ */
90
+ declare function computeOrderTax(lines: TaxLineInput[], pricesIncludeTax: boolean): OrderTaxTotals;
91
+ interface RateTaxLine {
92
+ label: string;
93
+ code?: string;
94
+ ratePpm: number;
95
+ netMinor: number;
96
+ amountMinor: number;
97
+ }
98
+ type TaxedLine = {
99
+ netMinor: number;
100
+ discountMinor?: number;
101
+ taxInclusive: boolean;
102
+ taxLines: readonly {
103
+ code?: string;
104
+ ratePpm: number;
105
+ taxMicros: string;
106
+ }[];
107
+ };
108
+ /**
109
+ * Groups each line's stacked tax rates (ADR-040: each independently taxes the line's full
110
+ * tax-free base) by `code`+`ratePpm`, floors each group's exact tax to minor units, then
111
+ * distributes `orderTaxMinor` minus that floor sum by largest remainder (ties to the higher rate)
112
+ * so the rates sum to exactly `orderTaxMinor`. A line's `netMinor` is in its OWN tax mode
113
+ * (`taxInclusive`): an inclusive line's net already contains its tax, so its tax-free base is
114
+ * `netMinor − roundMicrosToMinor(Σ its taxMicros)`; an exclusive line's base is `netMinor` as is.
115
+ * Shared by a receipt's tax summary and a Z report's per-rate breakdown. That is `per_order`'s split; a `per_line_items` or
116
+ * `per_rate_group_items` order's rows are `roundedTaxByRate`'s (#287).
117
+ */
118
+ declare function taxLinesByRate(lines: readonly TaxedLine[], orderTaxMinor: number, taxLabels?: Record<number, string>, rounding?: TaxRounding): RateTaxLine[];
119
+
120
+ /** Tax rates as integer parts per million (19% = 190000). */
62
121
  type TaxRateMap = {
63
122
  default: number;
64
123
  [taxClass: string]: number;
65
124
  };
66
125
  interface TaxContext {
67
- getTaxRate(taxClass?: string): number;
126
+ /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
127
+ getTaxRatePpm(taxClass?: string): number;
128
+ /** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */
129
+ getTaxRateCode?(taxClass?: string): string | undefined;
68
130
  pricesIncludeTax: boolean;
131
+ /** The store's tax rounding strategy (#287); absent means `per_order`, half away from zero. */
132
+ rounding?: TaxRounding;
69
133
  }
70
134
 
71
- declare function addTax(priceExclTax: number, rate: number): number;
72
- declare function extractTax(priceInclTax: number, rate: number): number;
73
- declare function calculateTax(price: number, rate: number, pricesIncludeTax: boolean): TaxResult;
74
-
135
+ declare const taxLogger: Logger;
75
136
  interface TaxProviderProps {
76
- rates: Record<string, number>;
137
+ ratesPpm: Record<string, number>;
77
138
  pricesIncludeTax: boolean;
139
+ /** `ServerCapabilities.taxRounding` (#287); a change restarts an idle sale under it. */
140
+ rounding?: TaxRounding;
141
+ /** Tax class → the backend's rate name, for `per_rate_group_items`'s grouping (#287). */
142
+ rateCodes?: Record<string, string>;
78
143
  children: ReactNode;
79
144
  }
80
- declare function TaxProvider({ rates, pricesIncludeTax, children }: TaxProviderProps): react_jsx_runtime.JSX.Element;
145
+ declare function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }: TaxProviderProps): react_jsx_runtime.JSX.Element;
81
146
  declare function useTax(): TaxContext;
82
147
 
148
+ type StoreSettingsResolution = {
149
+ status: 'ready';
150
+ settings: StoreSettings;
151
+ choice?: StoreSettingsChoice;
152
+ } | {
153
+ status: 'choose';
154
+ choices: StoreSettingsChoices;
155
+ initial?: StoreSettingsChoice;
156
+ } | {
157
+ status: 'unsupported';
158
+ };
159
+ interface ResolveStoreSettingsOptions {
160
+ connector: TallyConnector;
161
+ context: SyncContext;
162
+ /** The app's stored choice for this store (its own settings; TallyUI never stores it). */
163
+ loadChoice: () => StoreSettingsChoice | undefined | Promise<StoreSettingsChoice | undefined>;
164
+ saveChoice: (choice: StoreSettingsChoice) => void | Promise<void>;
165
+ /** A save that throws after a successful resolve: the pick is asked again next launch. */
166
+ onSaveError?: (error: unknown) => void;
167
+ }
168
+ /**
169
+ * Reads the store settings with the app's choice (TV4). A `choice` argument (a new pick from
170
+ * the choice screen) wins over the stored one and is saved only once it resolves; a stored
171
+ * choice is never re-saved. `choice_required` (including a stale stored choice) gives
172
+ * `choose`, with the tried choice narrowed to what's offered as `initial`, so the screen
173
+ * pre-selects what still matches. Any other error, `failed` included, is rethrown unchanged.
174
+ */
175
+ declare function resolveStoreSettings(options: ResolveStoreSettingsOptions, choice?: StoreSettingsChoice): Promise<StoreSettingsResolution>;
176
+
177
+ /** The replication's context with the settings' opaque pricing context; the key is left out when the settings have none. */
178
+ declare function withPricingContext(context: SyncContext, settings: StoreSettings): SyncContext;
179
+ /**
180
+ * `<TaxProvider>`'s props from the same settings, so its tax inclusivity and rounding match the store's. `custom`
181
+ * rounding passes none, the default (#287). An explicit `rounding` prop after the spread still wins, since JSX
182
+ * applies props in order; so `<TaxProvider {...taxProviderProps(s)} rounding={undefined}>` wipes the store's rounding.
183
+ */
184
+ declare function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'>;
185
+
186
+ type StoreSettingsState = {
187
+ state: 'loading';
188
+ } | {
189
+ state: 'ready';
190
+ settings: StoreSettings;
191
+ choice?: StoreSettingsChoice;
192
+ } | {
193
+ state: 'choose';
194
+ choices: StoreSettingsChoices;
195
+ initial?: StoreSettingsChoice;
196
+ choose: (choice: StoreSettingsChoice) => void;
197
+ } | {
198
+ state: 'error';
199
+ error: unknown;
200
+ retry: () => void;
201
+ } | {
202
+ state: 'unsupported';
203
+ };
204
+ /**
205
+ * Resolves the store settings on mount, and again when `connector` or `context` changes by
206
+ * identity (a store switch), so memoise both; the last request wins. `loadChoice` and
207
+ * `saveChoice` are read when a request starts, so inline functions don't trigger a re-resolve.
208
+ * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
209
+ * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
210
+ * rounding already known: no later change of `settings` holds a sale.
211
+ *
212
+ * ```tsx
213
+ * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
214
+ * if (store.state === 'choose')
215
+ * return <StoreSettingsChoiceScreen choices={store.choices} initial={store.initial} onSubmit={store.choose} />;
216
+ * if (store.state !== 'ready') return <Loading />; // or an error with store.retry, or the app's own config when unsupported
217
+ * const syncContext = withPricingContext(context, store.settings); // for the replication
218
+ * return <TaxProvider {...taxProviderProps(store.settings)}>{children}</TaxProvider>;
219
+ * ```
220
+ */
221
+ declare function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSettingsState;
222
+
83
223
  interface Repository<T> {
84
224
  findById$(id: string): Observable<T | null>;
85
225
  findAll$(query?: MangoQuery<T>): Observable<T[]>;
@@ -104,16 +244,50 @@ interface Order {
104
244
  payments: Payment[];
105
245
  customer: CustomerSummary | null;
106
246
  note: string;
107
- subtotal: number;
108
- discountTotal: number;
109
- taxTotal: number;
110
- total: number;
111
- balanceDue: number;
112
- changeDue: number;
113
247
  currency: string;
248
+ pricesIncludeTax: boolean;
249
+ /** The store's tax rounding strategy the figures were computed with (#287), from the sale's tax context; absent is per_order. */
250
+ taxRounding?: TaxRounding;
251
+ subtotalMinor: number;
252
+ discountMinor: number;
253
+ taxMinor: number;
254
+ totalMinor: number;
255
+ display: DisplayTotals;
256
+ paidMinor: number;
257
+ balanceDueMinor: number;
258
+ changeDueMinor: number;
114
259
  createdAt: string;
115
260
  updatedAt: string;
116
261
  }
262
+ /** An Order as it was stored and sent, whose customer id may have been left out. */
263
+ type SentOrder = Omit<Order, 'customer'> & {
264
+ customer: (Omit<CustomerSummary, 'id'> & {
265
+ id?: string;
266
+ }) | null;
267
+ };
268
+ /**
269
+ * Figures for showing the cart or a receipt in the store's display mode (ADR-063); never sent to the server.
270
+ * A parked order's saved draft may carry them; they are recomputed from the lines on resume, so never authoritative.
271
+ */
272
+ interface DisplayTotals {
273
+ taxInclusive: boolean;
274
+ subtotalMinor: number;
275
+ discountMinor: number;
276
+ taxMinor: number;
277
+ totalMinor: number;
278
+ lines: DisplayLine[];
279
+ orderDiscountMinor: number;
280
+ }
281
+ /** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
282
+ interface DisplayLine {
283
+ lineId: string;
284
+ amountMinor: number;
285
+ discounts: {
286
+ discountId: string;
287
+ label?: string;
288
+ amountMinor: number;
289
+ }[];
290
+ }
117
291
  interface LineItem {
118
292
  id: string;
119
293
  productId: string;
@@ -121,13 +295,22 @@ interface LineItem {
121
295
  name: string;
122
296
  sku: string;
123
297
  imageUrl?: string;
124
- price: number;
298
+ unitPriceMinor: number;
125
299
  quantity: number;
126
- taxRate: number;
127
- taxAmount: number;
300
+ taxLines: LineTaxLine[];
128
301
  discounts: AppliedDiscount[];
129
- discountAmount: number;
130
- lineTotal: number;
302
+ discountMinor: number;
303
+ orderDiscountMinor: number;
304
+ netMinor: number;
305
+ taxMicros: string;
306
+ taxInclusive: boolean;
307
+ /** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
308
+ priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
309
+ }
310
+ interface LineTaxLine {
311
+ code?: string;
312
+ ratePpm: number;
313
+ taxMicros: string;
131
314
  }
132
315
  interface Discount {
133
316
  type: 'percentage' | 'fixed';
@@ -137,12 +320,14 @@ interface Discount {
137
320
  }
138
321
  interface AppliedDiscount extends Discount {
139
322
  id: string;
140
- amount: number;
323
+ amountMinor: number;
141
324
  }
142
325
  interface Payment {
143
326
  id: string;
144
327
  method: string;
145
- amount: number;
328
+ amountMinor: number;
329
+ tenderedMinor?: number;
330
+ changeMinor?: number;
146
331
  reference?: string;
147
332
  }
148
333
  interface CustomerSummary {
@@ -156,6 +341,22 @@ interface PaymentMethod {
156
341
  icon?: string;
157
342
  requiresReference?: boolean;
158
343
  }
344
+ interface AddLineInput {
345
+ productId: string;
346
+ variantId?: string;
347
+ name: string;
348
+ sku?: string;
349
+ imageUrl?: string;
350
+ unitPrice: Money & {
351
+ taxInclusive?: boolean;
352
+ };
353
+ quantity?: number;
354
+ taxRates?: Array<{
355
+ code?: string;
356
+ ratePpm: number;
357
+ }>;
358
+ taxClass?: string;
359
+ }
159
360
 
160
361
  interface OrderBuilderOptions {
161
362
  currency: string;
@@ -168,6 +369,7 @@ interface OrderBuilder {
168
369
  variantId?: string;
169
370
  quantity?: number;
170
371
  }): string;
372
+ addLine(input: AddLineInput): string;
171
373
  updateQuantity(lineId: string, quantity: number): void;
172
374
  removeItem(lineId: string): void;
173
375
  applyLineDiscount(lineId: string, discount: Discount): void;
@@ -182,11 +384,46 @@ interface OrderBuilder {
182
384
  }
183
385
  declare function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder;
184
386
 
387
+ /** A sent line; `discountMinor` is its `lines[].discountMinor` (line discounts + its order share), in its own mode. */
388
+ type BasketLine = {
389
+ unitPriceMinor: number;
390
+ quantity: number;
391
+ discountMinor?: number;
392
+ taxInclusive: boolean;
393
+ taxLines: Array<{
394
+ code?: string;
395
+ ratePpm: number;
396
+ }>;
397
+ };
398
+ /**
399
+ * The till's settlement figures for a sent basket under a store's strategy (#287), computed exactly as a sale computes
400
+ * them, so a backend can score its own orders against the till (vendurepos/app#38). Each discountMinor is applied as a
401
+ * fixed line discount in the line's own mode.
402
+ */
403
+ declare function taxFiguresForBasket(currency: string, pricesIncludeTax: boolean, lines: readonly BasketLine[], rounding?: TaxRounding): {
404
+ subtotalMinor: number;
405
+ taxMinor: number;
406
+ totalMinor: number;
407
+ taxByRate: RateTaxLine[];
408
+ };
409
+
410
+ /**
411
+ * Splits an order discount across lines in proportion to each line's pre-order-discount amount,
412
+ * in its own tax mode (ADR-062), with largest-remainder rounding to the minor unit.
413
+ *
414
+ * - The shares sum exactly to `totalMinor`, and no share exceeds its line's amount.
415
+ * - Leftover units go to the largest remainders; ties go to the earlier line, so the result is deterministic.
416
+ * - A line with a zero or negative amount gets 0.
417
+ *
418
+ * Throws RangeError unless every input is a safe integer and 0 ≤ totalMinor ≤ Σ positive amounts.
419
+ */
420
+ declare function allocateOrderDiscount(lineAmountsMinor: readonly number[], totalMinor: number): number[];
421
+
185
422
  interface ParkedOrderSummary {
186
423
  id: string;
187
424
  customerName?: string;
188
425
  itemCount: number;
189
- total: number;
426
+ totalMinor: number;
190
427
  parkedAt: string;
191
428
  source: 'local' | 'server';
192
429
  }
@@ -209,8 +446,15 @@ interface ReceiptLineItem {
209
446
  name: string;
210
447
  sku: string;
211
448
  quantity: number;
212
- unitPrice: number;
213
- lineTotal: number;
449
+ unitPriceMinor: number;
450
+ /** After every discount, including the line's order share, in the order's mode. Not the figure to print above the subtotal: use displayAmountMinor. */
451
+ lineTotalMinor: number;
452
+ discountMinor?: number;
453
+ displayAmountMinor: number;
454
+ displayDiscounts: {
455
+ label: string;
456
+ amountMinor: number;
457
+ }[];
214
458
  }
215
459
  interface ReceiptData {
216
460
  header: {
@@ -219,30 +463,34 @@ interface ReceiptData {
219
463
  orderNumber: string;
220
464
  date: string;
221
465
  cashier?: string;
466
+ customer?: string;
222
467
  register?: string;
223
468
  };
224
469
  lineItems: ReceiptLineItem[];
225
470
  discounts: {
226
471
  label: string;
227
- amount: number;
472
+ amountMinor: number;
228
473
  }[];
474
+ orderDiscountMinor: number;
229
475
  totals: {
230
- subtotal: number;
231
- discountTotal: number;
476
+ taxInclusive: boolean;
477
+ subtotalMinor: number;
478
+ discountMinor: number;
232
479
  taxLines: {
233
480
  label: string;
234
- rate: number;
235
- amount: number;
481
+ code?: string;
482
+ ratePpm: number;
483
+ amountMinor: number;
236
484
  }[];
237
- taxTotal: number;
238
- total: number;
485
+ taxMinor: number;
486
+ totalMinor: number;
239
487
  };
240
488
  payments: {
241
489
  method: string;
242
- amount: number;
490
+ amountMinor: number;
243
491
  reference?: string;
244
492
  }[];
245
- changeDue: number;
493
+ changeDueMinor: number;
246
494
  footer: {
247
495
  note?: string;
248
496
  barcode?: string;
@@ -257,6 +505,1746 @@ interface ReceiptConfig {
257
505
  taxLabels?: Record<number, string>;
258
506
  }
259
507
 
260
- declare function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptData;
508
+ declare function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData;
509
+
510
+ /**
511
+ * Filters product documents by a cashier's search term, through traits, so
512
+ * it works the same on every backend.
513
+ *
514
+ * Every word of the term must appear in the product name, SKU or barcode
515
+ * (case-insensitive, any order). A term that exactly equals a barcode or SKU
516
+ * returns just those products, so a scanner hit is never buried among name
517
+ * matches. An empty term returns the input unchanged.
518
+ */
519
+ declare function searchProducts<Doc>(docs: Doc[], term: string, traits: ProductTraits<Doc>): Doc[];
520
+
521
+ /** The `stock_levels` collection as a live map of key to stock value. */
522
+ declare function stockOverlay$(collection: RxCollection): Observable<Map<string, unknown>>;
523
+ /**
524
+ * When the overlay was last confirmed by a successful pass (ISO 8601), or
525
+ * undefined before any pass. Read from the collection's local document, so a
526
+ * tab or screen that does not hold the runner can show it too.
527
+ */
528
+ declare function stockOverlayAsOf$(collection: RxCollection): Observable<string | undefined>;
529
+
530
+ type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
531
+ type PosOrderLocalWarning = {
532
+ code: 'customer_omitted';
533
+ field: 'email' | 'id';
534
+ } | {
535
+ code: 'payment_reference_dropped';
536
+ paymentId: string;
537
+ };
538
+ /** The outbox's server-failure state for a pending order, kept so a restart restores it. */
539
+ interface PosOrderServerFailures {
540
+ /** The stuck clock's start in ms: the outbox's virtual start, which leaves out offline gaps. */
541
+ since: number;
542
+ /** The latest server-answered failure reason. */
543
+ reason: string;
544
+ /** The order failed when sent alone (as a probe or while isolated), so it is retried alone. */
545
+ isolated: boolean;
546
+ }
547
+ interface PosOrderLine {
548
+ id: string;
549
+ productId: string;
550
+ variantId?: string;
551
+ name: string;
552
+ sku: string;
553
+ quantity: number;
554
+ unitPriceMinor: number;
555
+ /** Line discounts plus the allocated order-discount share, in the line's own tax mode (ADR-062). */
556
+ discountMinor: number;
557
+ netMinor: number;
558
+ taxLines: Array<{
559
+ code?: string;
560
+ ratePpm: number;
561
+ taxMicros: string;
562
+ }>;
563
+ /** This line's own tax mode; set only when it was converted from the store's (ADR-038 amendment). */
564
+ taxInclusive?: boolean;
565
+ }
566
+ interface PosOrderPayment {
567
+ id: string;
568
+ method: PaymentMethodKind;
569
+ amountMinor: number;
570
+ tenderedMinor?: number;
571
+ changeMinor?: number;
572
+ reference?: string;
573
+ }
574
+ interface PosOrder {
575
+ id: string;
576
+ createdAt: string;
577
+ currency: string;
578
+ pricesIncludeTax: boolean;
579
+ lines: PosOrderLine[];
580
+ subtotalMinor: number;
581
+ discountMinor: number;
582
+ taxMinor: number;
583
+ totalMinor: number;
584
+ payments: PosOrderPayment[];
585
+ customer: {
586
+ id?: string;
587
+ name?: string;
588
+ email?: string;
589
+ } | null;
590
+ note?: string;
591
+ registerId?: string;
592
+ /**
593
+ * The register session the sale was taken in (ADR-032): its closure counts this order.
594
+ * Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
595
+ */
596
+ sessionId?: string;
597
+ /**
598
+ * Set only by `useSale`'s late-sale path (ADR-032): the session the sale was taken for, which
599
+ * refused the stamp after the money was taken. Such an order has no `sessionId`, so no closure
600
+ * counts it. Sent at `order.create` version 3 as the payload's `sessionId` (the stamped or late session, one field).
601
+ */
602
+ lateSessionId?: string;
603
+ /**
604
+ * The order.create version every attempt under this `commandId` goes out at: the outbox records it before the first
605
+ * send, and lowers it only on a downgrade (ADR-065 amendment). Absent means not sent yet (or requeued).
606
+ */
607
+ sentVersion?: 1 | 2 | 3 | 4;
608
+ /** The version first tried, before the downgrade (the order's audit). */
609
+ downgradedFrom?: 1 | 2 | 3 | 4;
610
+ /** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. */
611
+ display?: DisplayTotals & {
612
+ currency: string;
613
+ exponent: number;
614
+ };
615
+ /** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). */
616
+ taxByRate?: Array<{
617
+ ratePpm: number;
618
+ code?: string;
619
+ label?: string;
620
+ netMinor: number;
621
+ amountMinor: number;
622
+ grossMinor: number;
623
+ }>;
624
+ /**
625
+ * The tax rounding the figures were computed with (#287), frozen at finalize; the till's own record, never sent.
626
+ * Version 6's migration sets the default on every older row.
627
+ */
628
+ taxRounding: TaxRounding;
629
+ cashierRef?: string;
630
+ syncStatus: PosOrderSyncStatus;
631
+ commandId: string;
632
+ serverRefs?: CommandServerRefs;
633
+ warnings?: CommandWarning[];
634
+ localWarnings?: PosOrderLocalWarning[];
635
+ serverFailures?: PosOrderServerFailures;
636
+ error?: CommandError;
637
+ updatedAt: string;
638
+ }
639
+
640
+ /** RFC 9562 UUIDv7: monotonic with no arguments; stateless with an explicit timestamp or random source. */
641
+ declare function uuidv7(now?: number, randomBytes?: (n: number) => Uint8Array): string;
642
+
643
+ interface FinalizeOptions {
644
+ localWarnings?: PosOrderLocalWarning[];
645
+ registerId?: string;
646
+ cashierRef?: string;
647
+ now?: Date;
648
+ /** Tests only: replaces uuidv7. Every id it returns must be at most 255 characters with no NUL (order.create's bound). */
649
+ newId?: () => string;
650
+ /** The store's `order.create` capability (ADR-062); `undefined` is treated as 1. */
651
+ capabilities?: ServerCapabilities;
652
+ }
653
+ /**
654
+ * Turns a fully paid builder Order into a pending PosOrder without mutating it. The PosOrder holds the sent form,
655
+ * frozen: line names and v3 discount labels are cut to PAYLOAD_STRING_MAX with NUL stripped (`cutText`), and a
656
+ * customer email or id the shape check would refuse is left out, so `toOrderCreateEnvelope` sends it unchanged.
657
+ */
658
+ declare function finalizeOrder(order: Order, options?: FinalizeOptions): PosOrder;
659
+
660
+ declare class UnsupportedOrderVersionError extends Error {
661
+ readonly needed: number;
662
+ readonly supported: number;
663
+ readonly code = "UNSUPPORTED_ORDER_VERSION";
664
+ constructor(needed: number, supported: number);
665
+ }
666
+ /**
667
+ * Builds the ADR-038 order.create envelope for a PosOrder. It sends the stored order unchanged, so every resend of
668
+ * an order is byte-identical, and an order stored by an older till goes out exactly as that till sent it:
669
+ * `finalizeOrder` freezes the sent form (names and labels cut, an unsendable customer email or id left out).
670
+ * ADR-065's figures make version 3; otherwise a discounted order is version 2 (ADR-062), else version 1, byte-identical.
671
+ * A version-3 order goes as version 4 (#286) only when `maxVersion` is at least 4 and its `sentVersion` doesn't cap it.
672
+ */
673
+ declare function toOrderCreateEnvelope(order: PosOrder, deviceId: string, attempt?: number, options?: {
674
+ maxVersion?: number;
675
+ }): OrderCreateEnvelope;
676
+
677
+ /**
678
+ * Version 1 adds the optional `sessionId` (ADR-032); nothing else changed from version 0.
679
+ * Version 2 adds three optional fields and changes nothing else: `lateSessionId` (ADR-032, late
680
+ * sale), and ADR-065's `display` and `taxByRate`, declared ahead of the job that writes them so a
681
+ * till migrates once. Create the collection with `posOrderCollection()`, never with this schema
682
+ * alone: RxDB refuses a version above 0 without its migration strategies.
683
+ * Version 3 adds an index on `sessionId` (with its `maxLength`), and the optional `sentVersion` and `downgradedFrom` (the outbox's version fallback), and changes nothing else.
684
+ * Version 4 adds the optional `localWarnings` and `serverFailures` (the outbox's stuck clock start, latest reason and
685
+ * isolation, restored when an outbox starts), and changes nothing else.
686
+ * Version 5 lets `sentVersion` and `downgradedFrom` be 4 (order.create version 4, #286), and changes nothing else. Its
687
+ * migration sets a row's missing `sentVersion` to its content version, the version any earlier attempt went out at.
688
+ * Like version 4 (ADR-069), it is one-way: an older build shows no orders.
689
+ * Version 6 adds `taxRounding`, the strategy the sale's figures were computed with (#287; never sent), and changes
690
+ * nothing else. Its migration sets it on every older row. It is one-way like version 5.
691
+ */
692
+ declare const posOrderSchema: RxJsonSchema<PosOrder>;
693
+ /**
694
+ * The `pos_orders` collection config, with its migration strategies. Open the collection with
695
+ * `addPosOrderCollection(db)`, which uses this and settles the migration safely; adding this config
696
+ * directly leaves RxDB's own open path. `pos_orders` holds sales not yet sent, so no step may drop a
697
+ * document: versions 1 and 2 only add optional fields, so every order from version 0 or 1 passes
698
+ * unchanged.
699
+ *
700
+ * Within one run, RxDB 16.21 keeps an order that fails the new schema's validation: with a
701
+ * validating storage the migration stops with DM4 and the order stays in the older version's
702
+ * storage; without one it is copied as is. **Across runs, RxDB's own open path can lose it**: a
703
+ * failed run can leave its checkpoint past that order, and the next run then removes the older
704
+ * version's storage without copying it. `addPosOrderCollection` resets that checkpoint, so use it
705
+ * (`migration.test.ts`).
706
+ */
707
+ declare function posOrderCollection(): {
708
+ schema: RxJsonSchema<PosOrder>;
709
+ migrationStrategies: MigrationStrategies;
710
+ };
711
+
712
+ /** `addPosOrderCollection`'s logger: attach a sink to see, for example, the status writes a closing open dropped. */
713
+ declare const posOrdersLogger: Logger;
714
+ /**
715
+ * `addPosOrderCollection` stopped, before any further write, because its database is closing: it
716
+ * was called once `close()` had begun, the close cancelled its migration (RxDB 17.5), or the close
717
+ * stopped waiting for it (after `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`). No order is lost: reopen the
718
+ * database and call it again.
719
+ */
720
+ declare class PosOrderOpenClosedError extends Error {
721
+ readonly databaseName: string;
722
+ readonly code = "POS_ORDER_OPEN_CLOSED";
723
+ constructor(databaseName: string);
724
+ }
725
+ /**
726
+ * Adds `pos_orders` to `db` and resolves once no order of an older version is left to migrate. The one
727
+ * sanctioned way to open it (ADR-032 amendment 2). On DM4 it rejects after the migration has
728
+ * stopped and closes the collection, so the app can surface the error and open again; it never
729
+ * deletes an order.
730
+ *
731
+ * RxDB 17.5.0 trusts the status its last migration stored: a leftover `ERROR` rejects
732
+ * `migratePromise` at once while the migration keeps running (a close then interrupts it), and a
733
+ * `DONE` left before a rollback resolves it before the older version's new orders have moved. So the
734
+ * collection is added without `autoMigrate` and the migration is started and awaited directly: RxDB
735
+ * 17.5's `startMigration()` ignores a leftover status (only `migratePromise()` trusts a leftover
736
+ * `DONE`), which lets a rolled-back or interrupted run migrate again and recovers its orders. The
737
+ * status is reset first so its record describes the new run, and a failed run's checkpoint is
738
+ * removed (RxDB bug 1); never an order or its storage.
739
+ *
740
+ * A close waits for the open, up to `POS_ORDER_MIGRATION_CLOSE_WAIT_MS`, and cancels a running
741
+ * migration (RxDB 17.5). Called once the database's close has begun, when the close cancels its
742
+ * migration, or when the close stops waiting, it rejects with `PosOrderOpenClosedError`
743
+ * (`code: 'POS_ORDER_OPEN_CLOSED'`) before any further write: reopen and call it again. Any other
744
+ * rejection (DM4, a storage error) is a real failure.
745
+ *
746
+ * `closeWaitMs` is for tests only; the first open on a database sets it for that database's close.
747
+ */
748
+ declare function addPosOrderCollection(db: RxDatabase, closeWaitMs?: number): Promise<RxCollection<PosOrder>>;
749
+
750
+ /**
751
+ * Returns this device's id (a UUIDv7), stored under `key` in `storage` (web storage, or an app's
752
+ * equivalent) and minted on first use; medusapos passes `'medusapos.register_id'`. With no storage,
753
+ * or one that throws on read or write, it falls back to one id per process.
754
+ */
755
+ declare function getDeviceId(storage: {
756
+ getItem(key: string): string | null;
757
+ setItem(key: string, value: string): void;
758
+ } | null, key: string): string;
759
+
760
+ /**
761
+ * The orders a cashier must look at: rejected, applied with a known warning, taken after their
762
+ * session closed (`lateSessionId`) or with local warnings (whatever the sync status), or pending with a `commandId` in
763
+ * `stuckCommandIds` (the outbox's `OutboxState.stuck`: the store keeps failing it), newest first.
764
+ * A warning code this till version doesn't know is not shown here (`knownWarnings`). Never changes `orders`.
765
+ */
766
+ declare function needsAttention(orders: PosOrder[], options?: {
767
+ stuckCommandIds?: readonly string[];
768
+ }): PosOrder[];
769
+
770
+ type SaleContent = Pick<PosOrder, 'totalMinor' | 'currency' | 'lines' | 'payments'>;
771
+ /**
772
+ * Whether two orders carry the same money-bearing content: `totalMinor` and `currency`; each line's
773
+ * `id`, `quantity` and `netMinor`, in order; each payment's `method` and `amountMinor`, in order.
774
+ * An order `id` is minted once per sale (`finalizeOrder`), so the same `id` with other content is a defect.
775
+ */
776
+ declare function sameSale(stored: SaleContent, retried: SaleContent): boolean;
777
+ /** A stored order has this order's `id` but other money-bearing content (see `sameSale`): never treated as stored. */
778
+ declare class OrderContentMismatchError extends Error {
779
+ readonly orderId: string;
780
+ constructor(orderId: string);
781
+ }
782
+
783
+ type MovementType = 'paid_in' | 'paid_out' | 'no_sale';
784
+ declare function normalizeAmount(raw: string): string;
785
+ /** Whether the server's `decimal()` would accept this string. */
786
+ declare function isServerDecimal(value: string): boolean;
787
+ /**
788
+ * The field the server would name in its 400, or null when it would accept the movement.
789
+ * A no sale carries no amount — the sheet sends '0' — but still needs a reason.
790
+ */
791
+ declare function movementFieldError(input: {
792
+ type: MovementType;
793
+ amount: string;
794
+ reason: string;
795
+ }): 'amount' | 'reason' | null;
796
+
797
+ /**
798
+ * What the register should hold at close, per tender method: the session's cash float plus
799
+ * captured payments and cash movements — never what the cashier counts. Port provenance
800
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
801
+ *
802
+ * **Refund attribution is deferred.** WCPOS's `attributeRefunds` debits a session's drawer for
803
+ * refunds processed against its captured payments (by stamped session, then by a legacy
804
+ * `refunded_amount` fallback). TallyUI has no refund model yet (ADR-032 amendment 1), so
805
+ * `deriveExpected` covers only the float, the session's captured payment rows, and its
806
+ * paid-in/paid-out cash movements under WCPOS's void rules.
807
+ */
808
+ /** `session_id`, `kind`, `method_id`, `status` kept as WCPOS names them; money is TallyUI's integer-minor-units convention. */
809
+ type LedgerRow = {
810
+ session_id?: string | null;
811
+ kind: string;
812
+ method_id: string;
813
+ status: string;
814
+ amountMinor: number;
815
+ };
816
+ /** `type`, `voids` and `voided_by` are kept as WCPOS names them: neutral across backends. */
817
+ type Movement$1 = {
818
+ id: string;
819
+ session_id: string;
820
+ type: string;
821
+ amountMinor: number;
822
+ voided_by?: string | null;
823
+ voids?: string | null;
824
+ };
825
+ /**
826
+ * Expected totals per tender method: the counted float, plus every captured ledger row for the
827
+ * session (grouped under `cash` for cash rows, else `method_id`), plus its non-voided
828
+ * paid-in/paid-out movements.
829
+ *
830
+ * A movement is excluded when its own `voided_by` is set, or when a `type: 'void'` row's
831
+ * `voids` names its id; the `void` row itself carries no amount of its own.
832
+ */
833
+ declare function deriveExpected({ session, movements, ledgerRowsBySession, }: {
834
+ session: {
835
+ id: string;
836
+ countedFloatMinor: number;
837
+ };
838
+ movements: readonly Movement$1[];
839
+ ledgerRowsBySession: readonly LedgerRow[];
840
+ }): Record<string, number>;
841
+
842
+ /**
843
+ * The pure maths behind a register count: variance against the expected float, a display
844
+ * string for it, and the denomination arithmetic the counting keypad uses. Port provenance
845
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
846
+ *
847
+ * Money here is TallyUI's convention: integer minor units. Everywhere WCPOS assumed two
848
+ * decimals, these take the currency's `exponent` instead — 0 for JPY, 3 for KWD — except
849
+ * `validAmount`, which like WCPOS still takes the cashier's typed text.
850
+ */
851
+ /**
852
+ * Parses a decimal string the cashier typed into integer minor units at `exponent` decimal
853
+ * places, without rounding. Returns `NaN` when the string carries more precision than the
854
+ * currency allows, so a safe-integer check on the result also rejects it.
855
+ */
856
+ declare function parseMinor(text: string, exponent: number): number;
857
+ declare const validAmount: (value: string, exponent: number) => boolean;
858
+ /** Counted minus expected, both already in minor units. */
859
+ declare const countVariance: (countedMinor: number, expectedMinor: number) => number;
860
+ /** Whether the variance exceeds the store's configured threshold (minor units; none means never). */
861
+ declare const overThreshold: (variance: number, thresholdMinor?: number | null) => boolean;
862
+ /**
863
+ * Whether a close's cash count needs a manager's approval: its cash variance against the expected
864
+ * cash is over the threshold. The one definition `RegisterCount` and `useRegisterSession`'s
865
+ * `closeSession` gate share (ADR-032).
866
+ */
867
+ declare const closeNeedsApproval: (countedCashMinor: number, expectedCashMinor: number, thresholdMinor?: number | null) => boolean;
868
+ /** Sums denomination face values (already minor units) by piece count. */
869
+ declare const denominationTotal: (pieces: Record<number, number>) => number;
870
+ /**
871
+ * The variance line under the count: exact, short or over. `format` takes a major-unit
872
+ * amount, as an app's currency formatter expects, so `exponent` converts `variance` back for
873
+ * it; `t` supplies the translated words for the `register.exact`/`register.short`/
874
+ * `register.over` keys. Both are the app's own — neutral here.
875
+ *
876
+ * The direction lives in the word, not a sign (the Front desk, 2026-09-28): a signed amount
877
+ * next to "short" read as a double negative, so the amount here is always its absolute value.
878
+ */
879
+ declare function varianceText(variance: number, exponent: number, format: (value: number) => string, t: (key: string) => string): string;
880
+
881
+ declare const denominations: Record<string, number[]>;
882
+
883
+ /**
884
+ * The register's local collections (ADR-032): sessions, cash movements and closures. Port
885
+ * provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`, where they are at versions 1, 0
886
+ * and 1; each starts at version 0 here.
887
+ *
888
+ * All three are local only, like `pos_orders`: the app creates them and never replicates them
889
+ * (a replicated collection must never take local writes, #53). WCPOS's outbox fields
890
+ * (`sync_status`, attempts, next time, error) and closures' server and receipt fields are gone;
891
+ * sending sessions to a server is registers job c.
892
+ *
893
+ * Money is integer minor units: WCPOS's decimal strings become `*_minor` integers (`amountMinor`
894
+ * on a movement, matching `Movement`), and a tender map (`counted`, `expected`, `variance`, keyed
895
+ * by method) holds integers. People (`opened_by`, `created_by`, ...) are the backend's user id
896
+ * as a string, since not every backend's is a number.
897
+ */
898
+
899
+ type TenderMap = Record<string, number>;
900
+ interface RegisterSession {
901
+ id: string;
902
+ register_id: string;
903
+ /** The app's neutral store key (see `bindRegister`). */
904
+ store_key?: string | null;
905
+ status: 'open' | 'counting' | 'closed';
906
+ /** The store's day the session opened, `yyyy-MM-dd`. */
907
+ business_day?: string;
908
+ opened_at_gmt: string;
909
+ opened_by?: string | null;
910
+ expected_float_minor?: number | null;
911
+ counted_float_minor: number;
912
+ opening_variance_minor?: number | null;
913
+ counting_started_at_gmt?: string | null;
914
+ closed_at_gmt?: string | null;
915
+ closed_by?: string | null;
916
+ approval_required?: boolean;
917
+ approved_by?: string | null;
918
+ counted?: TenderMap | null;
919
+ closure_id?: string | null;
920
+ /** The last local transition, kept for a server to acknowledge (job c). */
921
+ pending_status?: string | null;
922
+ server_status?: string | null;
923
+ status_at?: string | null;
924
+ approver_token?: string | null;
925
+ /** A server's expected figures; cleared before every money action (`requireOpenSession`). */
926
+ server_expected?: TenderMap | null;
927
+ server_sales_count?: number | null;
928
+ }
929
+ interface CashMovement {
930
+ id: string;
931
+ session_id: string;
932
+ type: 'paid_in' | 'paid_out' | 'no_sale' | 'void';
933
+ amountMinor: number;
934
+ reason: string;
935
+ created_at_gmt: string;
936
+ created_by?: string | null;
937
+ /** On a `void` row: the movement it reverses. */
938
+ voids?: string | null;
939
+ /** On a reversed movement: its `void` row. */
940
+ voided_by?: string | null;
941
+ }
942
+ interface Closure {
943
+ id: string;
944
+ session_id: string;
945
+ register_id: string;
946
+ store_key?: string | null;
947
+ business_day?: string;
948
+ closed_by?: string | null;
949
+ corrections_count?: number;
950
+ number: number;
951
+ opened_at: string;
952
+ closed_at: string;
953
+ till_expected: TenderMap;
954
+ expected: TenderMap;
955
+ counted: TenderMap;
956
+ variance: TenderMap;
957
+ period_sales_total_minor: number;
958
+ /** Always 0 until TallyUI has a refund model (ADR-032 amendment 1). */
959
+ period_refunds_total_minor: number;
960
+ perpetual_sales_total_minor: number;
961
+ perpetual_refunds_total_minor: number;
962
+ /** This session's orders still waiting for the outbox, and their payments' total. */
963
+ unsynced_count: number;
964
+ unsynced_total_minor: number;
965
+ software_version: string;
966
+ breakdowns: Record<string, unknown>;
967
+ order_ids: string[];
968
+ movement_ids: string[];
969
+ server_closure_id?: string | null;
970
+ printed_at?: string | null;
971
+ print_count: number;
972
+ number_retried?: boolean;
973
+ }
974
+ declare const registerSessionSchema: RxJsonSchema<RegisterSession>;
975
+ /**
976
+ * Create `register_sessions` with this: the register document (`register-document.ts`) is a
977
+ * local document on this collection, since a Tally database has no database-level ones.
978
+ */
979
+ declare function registerSessionCollection(): {
980
+ readonly schema: RxJsonSchema<RegisterSession>;
981
+ readonly localDocuments: true;
982
+ };
983
+ declare const cashMovementSchema: RxJsonSchema<CashMovement>;
984
+ declare const closureSchema: RxJsonSchema<Closure>;
985
+
986
+ type RegisterHost = RxLocalDocumentMutation<any>;
987
+ type RegisterCounters = {
988
+ last_closure_number: number;
989
+ perpetual_sales_total_minor: number;
990
+ perpetual_refunds_total_minor: number;
991
+ counters_started_at?: string | null;
992
+ };
993
+ /**
994
+ * One register's counters, plus the closure being written: the draft is reserved here, in the
995
+ * same atomic write as its number, and `applied` once its period is in the perpetual totals.
996
+ */
997
+ type RegisterBucket = Partial<RegisterCounters> & {
998
+ commands_since?: string;
999
+ closure_reservation?: {
1000
+ row: Closure;
1001
+ applied: boolean;
1002
+ };
1003
+ /** Closure ids the orphan-stamp sweep has already checked (ADR-032, the #158 follow-ups): a
1004
+ * local-document field, so bounding the sweep needs no schema bump. */
1005
+ swept_closure_ids?: string[];
1006
+ };
1007
+ interface RegisterStore {
1008
+ sale_counter: number;
1009
+ registers?: Record<string, RegisterBucket>;
1010
+ /** The register (drawer) this till is bound to in this store. */
1011
+ register_id?: string | null;
1012
+ register_name?: string | null;
1013
+ }
1014
+ interface RegisterDocument {
1015
+ id: string;
1016
+ name: string;
1017
+ /** The caller's platform, for example `'ios'` or `'web'`. */
1018
+ platform: string;
1019
+ created_at: string;
1020
+ /** Keyed by `storeKey`. */
1021
+ stores: Record<string, RegisterStore>;
1022
+ }
1023
+ declare function mintUuid(): string;
1024
+ declare function readRegister(host: RegisterHost): Promise<RegisterDocument | null>;
1025
+ /** Reads the register document, minting it on first use; concurrent callers get the same one. */
1026
+ declare function ensureRegister(host: RegisterHost, platform: string): Promise<RegisterDocument>;
1027
+ declare function observeRegister$(host: RegisterHost): rxjs.Observable<RegisterDocument | null>;
1028
+ declare function getBoundRegisterId(register: RegisterDocument | null, storeKey: string): string | null;
1029
+ declare function readBoundRegister(host: RegisterHost, storeKey: string): Promise<{
1030
+ id: string;
1031
+ name: string;
1032
+ } | null>;
1033
+ declare class RegisterIdInvalidError extends Error {
1034
+ constructor();
1035
+ }
1036
+ declare function bindRegister(host: RegisterHost, storeKey: string, register: {
1037
+ id: string | null;
1038
+ name: string | null;
1039
+ }): Promise<void>;
1040
+ declare function unbindRegister(host: RegisterHost, storeKey: string): Promise<void>;
1041
+ declare function nextSaleCounter(host: RegisterHost, storeKey: string): Promise<number>;
1042
+ /**
1043
+ * Mints the register's next closure number. Given the closure draft, it reserves the draft with
1044
+ * its number and perpetual totals in the same atomic write, and a retry of the same draft gets the
1045
+ * same number back. A different draft is refused while an earlier one is reserved but not yet
1046
+ * applied (`closure_write_incomplete`).
1047
+ */
1048
+ declare function mintClosureNumber(host: RegisterHost, storeKey: string, registerId: string, closure?: Closure): Promise<number>;
1049
+ /**
1050
+ * Adds a closed period to the register's perpetual totals. With `closureId`, it applies that
1051
+ * reserved closure once: the totals become the reservation's, and a repeat does nothing.
1052
+ */
1053
+ declare function advancePerpetual(host: RegisterHost, storeKey: string, registerId: string, period: {
1054
+ salesMinor: number;
1055
+ refundsMinor: number;
1056
+ closureId?: string;
1057
+ }): Promise<void>;
1058
+
1059
+ type RegisterSessionCollection = RxCollection<RegisterSession>;
1060
+ type CashMovementCollection = RxCollection<CashMovement>;
1061
+ type ClosureCollection = RxCollection<Closure>;
1062
+ declare class RegisterSessionRequiredError extends Error {
1063
+ constructor();
1064
+ }
1065
+ /** The session is closed, and a closed session is final: there's no server yet to refuse writes to it. */
1066
+ declare class RegisterSessionClosedError extends Error {
1067
+ constructor();
1068
+ }
1069
+ /** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
1070
+ declare class RegisterMovementAmountError extends Error {
1071
+ constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number);
1072
+ }
1073
+ declare class RegisterMovementReasonError extends Error {
1074
+ constructor();
1075
+ }
1076
+ /**
1077
+ * The movement is recorded, on a session that closed while it was saved, and no closure is known
1078
+ * to count it, so it stays local on the till. The caller must not record it again; the server
1079
+ * refuses it once the closure exists (ADR-068 4). The message can be shown to a cashier as it is.
1080
+ */
1081
+ declare class RegisterMovementStrandedError extends Error {
1082
+ readonly id: string;
1083
+ readonly session_id: string;
1084
+ constructor(movement: {
1085
+ id: string;
1086
+ session_id: string;
1087
+ });
1088
+ }
1089
+ declare const openSessionSelector: {
1090
+ readonly status: "open";
1091
+ };
1092
+ /** The open session's id before a money action, or `RegisterSessionRequiredError`; `null` when sessions are off. */
1093
+ declare function requireOpenSession(sessions: RegisterSessionCollection | undefined, registerId: string | null, enabled: boolean): Promise<string | null>;
1094
+ declare function stampSession(order: PosOrder, sessionId: string, sessions: RegisterSessionCollection): Promise<PosOrder>;
1095
+ declare function openSession(sessions: RegisterSessionCollection, input: {
1096
+ registerId: string;
1097
+ expectedFloatMinor: number | null;
1098
+ countedFloatMinor: number;
1099
+ openedBy: string;
1100
+ /** The store's day, not the device's UTC day. */
1101
+ businessDay: {
1102
+ year: number;
1103
+ month: number;
1104
+ day: number;
1105
+ };
1106
+ storeKey?: string | null;
1107
+ }): Promise<rxdb.RxDocument<RegisterSession, {}, unknown>>;
1108
+ declare const startCounting: (sessions: RegisterSessionCollection, id: string) => Promise<rxdb.RxDocument<RegisterSession, {}, unknown>>;
1109
+ declare const backToSelling: (sessions: RegisterSessionCollection, id: string) => Promise<rxdb.RxDocument<RegisterSession, {}, unknown>>;
1110
+ /**
1111
+ * Closes the session with its counted tenders (minor units); `timezone` dates a session opened
1112
+ * without a business day. `approvedBy`, the manager who approved the count, is written in the same
1113
+ * write as the close, and a repeat close keeps the first close's approver as it keeps its count.
1114
+ */
1115
+ declare function closeSession(sessions: RegisterSessionCollection, id: string, input: {
1116
+ counted: Record<string, number>;
1117
+ closedBy?: string;
1118
+ approvedBy?: string;
1119
+ timezone?: string;
1120
+ }): Promise<rxdb.RxDocument<RegisterSession, {}, unknown>>;
1121
+ /**
1122
+ * Records a movement on a session that is `open` or `counting`; a closed or missing one is refused.
1123
+ * A close that races the insert ends in `RegisterSessionClosedError` (removed) or
1124
+ * `RegisterMovementStrandedError` (kept; don't record it again).
1125
+ */
1126
+ declare function recordMovement(sessions: RegisterSessionCollection, movements: CashMovementCollection, closures: ClosureCollection, input: {
1127
+ sessionId: string;
1128
+ type: 'paid_in' | 'paid_out' | 'no_sale';
1129
+ amountMinor: number;
1130
+ reason: string;
1131
+ actor: string;
1132
+ }): Promise<rxdb.RxDocument<CashMovement, {}, unknown>>;
1133
+ /**
1134
+ * Reverses a movement with a `void` row while its session is live; repeated or concurrent calls
1135
+ * share one reversal. A close that races the writes ends in `RegisterMovementStrandedError` (kept;
1136
+ * don't void it again). Without `closures`, nothing can prove the reversal counted, so a re-read
1137
+ * that finds the session closed always ends in `RegisterMovementStrandedError`.
1138
+ */
1139
+ declare function voidMovement(sessions: RegisterSessionCollection, movements: CashMovementCollection, movementId: string, actor: string, closures?: ClosureCollection): Promise<rxdb.RxDocument<CashMovement, {}, unknown>>;
1140
+ /**
1141
+ * Freezes a closed session's figures into its closure, once. The draft is reserved on the register
1142
+ * document with its number (`mintClosureNumber`), so a failed insert or a restarted till reuses the
1143
+ * same snapshot and number, and its period reaches the perpetual totals exactly once.
1144
+ * Only a closed session (`closed` with `closed_at_gmt`) has a closure, and a closed session is
1145
+ * final, so its existing closure is returned as it was frozen. Orders the server rejected still
1146
+ * count in the drawer, because the cash was taken.
1147
+ */
1148
+ declare function writeClosure({ closures, register, storeKey, session, counted, otherTenders, movements, orders, tillExpected, labels, resolveCashierName, softwareVersion, timezone, }: {
1149
+ closures: ClosureCollection;
1150
+ /** The register document's host (`register_sessions`). */
1151
+ register: RegisterHost;
1152
+ storeKey: string;
1153
+ session: RegisterSession;
1154
+ /** Counted cash, minor units. */
1155
+ counted: number;
1156
+ otherTenders: Record<string, number>;
1157
+ movements: readonly CashMovement[];
1158
+ /** Any `pos_orders`; only those whose `sessionId` is this session's are counted. */
1159
+ orders: readonly PosOrder[];
1160
+ tillExpected?: Record<string, number>;
1161
+ labels?: {
1162
+ register_name: string;
1163
+ closed_by_name: string;
1164
+ opened_by_name?: string;
1165
+ approved_by_name?: string;
1166
+ };
1167
+ resolveCashierName?: (id: string) => string;
1168
+ /** The app's version, stamped on the closure. */
1169
+ softwareVersion: string;
1170
+ timezone?: string;
1171
+ }): Promise<rxdb.RxDocument<Closure, {}, unknown>>;
1172
+
1173
+ /**
1174
+ * Corrections layered onto a closure's recorded figures (ADR-032): late sales, late cash
1175
+ * movements and recounts, applied in order and deduplicated by id. Port provenance (ADR-032
1176
+ * amendment 1): WCPOS `next` `3b5331b5c` `settled-figures.ts`. Money is TallyUI's integer
1177
+ * minor-units convention: WCPOS's four-decimal-string arithmetic becomes direct integer
1178
+ * arithmetic on a2's `*_minor` fields and tender maps.
1179
+ */
1180
+
1181
+ type Correction = {
1182
+ id: number;
1183
+ type: 'late_sale' | 'late_movement' | 'recount';
1184
+ created_at: string;
1185
+ actor: {
1186
+ id: string;
1187
+ name: string;
1188
+ };
1189
+ approver: {
1190
+ id: string;
1191
+ name: string;
1192
+ } | null;
1193
+ reason: string;
1194
+ figures: {
1195
+ expected_delta?: Record<string, number>;
1196
+ cash_delta?: number;
1197
+ sales_delta?: number;
1198
+ refunds_delta?: number;
1199
+ counted?: Record<string, number>;
1200
+ variance?: Record<string, number>;
1201
+ };
1202
+ };
1203
+ type RecordedFigures = Pick<Closure, 'expected' | 'counted' | 'variance' | 'period_sales_total_minor' | 'period_refunds_total_minor' | 'perpetual_sales_total_minor' | 'perpetual_refunds_total_minor'>;
1204
+ /**
1205
+ * Folds `corrections` onto `recorded`, ordered by `created_at` then `id`, deduplicated by `id`.
1206
+ * A `recount` replaces the supplied tenders in `counted`; a `late_movement` adds its signed
1207
+ * `cash_delta` to expected cash; a `late_sale` adds its `expected_delta` tenders to expected and
1208
+ * its `sales_delta`/`refunds_delta` to both the period and perpetual totals. Variance is
1209
+ * recomputed for every tender named by expected or counted, once any correction applies.
1210
+ * `touched` names every field, or `field.tender`, whose settled value differs from `recorded`.
1211
+ */
1212
+ declare function deriveSettled(recorded: RecordedFigures, corrections: readonly Correction[]): {
1213
+ settled: {
1214
+ expected: {
1215
+ [x: string]: number;
1216
+ };
1217
+ counted: {
1218
+ [x: string]: number;
1219
+ };
1220
+ variance: {
1221
+ [x: string]: number;
1222
+ };
1223
+ period_sales_total_minor: number;
1224
+ period_refunds_total_minor: number;
1225
+ perpetual_sales_total_minor: number;
1226
+ perpetual_refunds_total_minor: number;
1227
+ };
1228
+ touched: Set<string>;
1229
+ };
1230
+
1231
+ declare function exportCsv(rows: readonly Closure[], exponent: number, t: (key: string) => string, names?: Record<string, string>, storeName?: string): string;
1232
+
1233
+ declare const labelKeys: {
1234
+ x_report: string;
1235
+ closure: string;
1236
+ opened: string;
1237
+ closed: string;
1238
+ approver: string;
1239
+ opening_float: string;
1240
+ expected: string;
1241
+ counted: string;
1242
+ variance: string;
1243
+ tenders: string;
1244
+ tender: string;
1245
+ cash_movements: string;
1246
+ time: string;
1247
+ type: string;
1248
+ amount: string;
1249
+ reason: string;
1250
+ sales: string;
1251
+ period_sales: string;
1252
+ period_refunds: string;
1253
+ transactions: string;
1254
+ refunds: string;
1255
+ payment_method: string;
1256
+ cashiers: string;
1257
+ tax_rates: string;
1258
+ tax_rate: string;
1259
+ net: string;
1260
+ tax: string;
1261
+ gross: string;
1262
+ perpetual_totals: string;
1263
+ perpetual_sales: string;
1264
+ perpetual_refunds: string;
1265
+ unsynced_sales: string;
1266
+ short: string;
1267
+ over: string;
1268
+ exact: string;
1269
+ voided: string;
1270
+ paid_in: string;
1271
+ paid_out: string;
1272
+ no_sale: string;
1273
+ void: string;
1274
+ copy: string;
1275
+ };
1276
+
1277
+ type ClosureScope = {
1278
+ from: string;
1279
+ to: string;
1280
+ registerId: string;
1281
+ storeKey?: string;
1282
+ cashier?: string;
1283
+ };
1284
+ /** Clamps a requested scope's `from`/`to` to `today` and to `historyDays` before it. */
1285
+ declare function clampClosureScope(scope: ClosureScope, today: string, historyDays?: number): ClosureScope;
1286
+ /**
1287
+ * Rows within `scope`, newest business day first, then newest `closed_at`. A row with no
1288
+ * `business_day` (a legacy or remote row) gets one from `opened_at` in `timezone`, reusing a2's
1289
+ * `businessDayOf` rather than duplicating it. Register and cashier are optional filters; the
1290
+ * store is not — a row's `store_key` must equal `scope.storeKey` (both `null` when neither is
1291
+ * set), as WCPOS always compared `store_id`.
1292
+ */
1293
+ declare function selectClosureRows(rows: readonly Closure[], scope: ClosureScope, timezone: string): {
1294
+ business_day: string;
1295
+ id: string;
1296
+ session_id: string;
1297
+ register_id: string;
1298
+ store_key?: string | null;
1299
+ closed_by?: string | null;
1300
+ corrections_count?: number;
1301
+ number: number;
1302
+ opened_at: string;
1303
+ closed_at: string;
1304
+ till_expected: {
1305
+ [x: string]: number;
1306
+ };
1307
+ expected: {
1308
+ [x: string]: number;
1309
+ };
1310
+ counted: {
1311
+ [x: string]: number;
1312
+ };
1313
+ variance: {
1314
+ [x: string]: number;
1315
+ };
1316
+ period_sales_total_minor: number;
1317
+ period_refunds_total_minor: number;
1318
+ perpetual_sales_total_minor: number;
1319
+ perpetual_refunds_total_minor: number;
1320
+ unsynced_count: number;
1321
+ unsynced_total_minor: number;
1322
+ software_version: string;
1323
+ breakdowns: Record<string, unknown>;
1324
+ order_ids: string[];
1325
+ movement_ids: string[];
1326
+ server_closure_id?: string | null;
1327
+ printed_at?: string | null;
1328
+ print_count: number;
1329
+ number_retried?: boolean;
1330
+ }[];
1331
+
1332
+ /**
1333
+ * Minor-unit money formatting shared across the register package (moved out of `export-csv.ts` so
1334
+ * `closure-document.ts` reuses the same implementation instead of duplicating it).
1335
+ */
1336
+ /**
1337
+ * `minor` as a fixed-point decimal string with exactly `exponent` places: `-50` at exponent 2 is
1338
+ * `'-0.50'`, `1250` is `'12.50'`, and at exponent 0, `1500` is `'1500'`. Integer maths only —
1339
+ * sign, `Math.trunc`, and the remainder padded to `exponent` digits — no `toFixed` on floats.
1340
+ */
1341
+ declare function minorToDecimal(minor: number, exponent: number): string;
1342
+
1343
+ type Values = Record<string, unknown>;
1344
+ /**
1345
+ * The Z report's line when a session's sales used more than one tax rounding (#287; wording approved by the Front
1346
+ * desk, 2026-09-30). The closure template lives in the apps, so the document carries it ready to print.
1347
+ */
1348
+ declare const TAX_ROUNDING_MIXED_NOTE = "This register's sales used more than one tax rounding method. Each sale's tax is as its receipt showed.";
1349
+ type ClosureContext = {
1350
+ store: Values;
1351
+ currency: string;
1352
+ timezone: string;
1353
+ locale: string;
1354
+ printedAt: string;
1355
+ /** Minor-unit decimal places for every money field the envelope carries. */
1356
+ exponent: number;
1357
+ formatMoney: (value: string) => string;
1358
+ i18n: Record<string, string>;
1359
+ /** Overrides an X-report's `server_expected` tender map, in minor units. */
1360
+ expected?: Record<string, number>;
1361
+ /**
1362
+ * `buildXReportDocument`'s own breakdowns merge only (unlike `buildClosureDocument`'s frozen
1363
+ * `Closure.breakdowns`): its money fields are still expected as decimal strings, not minor
1364
+ * units. Job c's X-report caller, once it exists, converts them before passing this in.
1365
+ */
1366
+ breakdowns?: Values;
1367
+ };
1368
+ declare function formatClosureDate(value: string | null | undefined, context: Pick<ClosureContext, 'timezone' | 'locale'>): Record<string, string>;
1369
+ declare function buildClosureDocument(row: Closure, context: ClosureContext): {
1370
+ closure: {
1371
+ tax_rounding_note?: string | undefined;
1372
+ has_sales: boolean;
1373
+ has_perpetual: boolean;
1374
+ } & {
1375
+ breakdowns: Values;
1376
+ store_id: string | null | undefined;
1377
+ till_expected: Record<string, string>;
1378
+ expected: Record<string, string>;
1379
+ counted: Record<string, string>;
1380
+ variance: Record<string, string>;
1381
+ period_sales_total: string;
1382
+ period_refunds_total: string;
1383
+ perpetual_sales_total: string;
1384
+ perpetual_refunds_total: string;
1385
+ unsynced_total: string;
1386
+ id: string;
1387
+ session_id: string;
1388
+ register_id: string;
1389
+ business_day?: string;
1390
+ closed_by?: string | null;
1391
+ corrections_count?: number;
1392
+ number: number;
1393
+ opened_at: string;
1394
+ closed_at: string;
1395
+ unsynced_count: number;
1396
+ software_version: string;
1397
+ order_ids: string[];
1398
+ movement_ids: string[];
1399
+ server_closure_id?: string | null;
1400
+ printed_at?: string | null;
1401
+ print_count: number;
1402
+ number_retried?: boolean;
1403
+ } & {
1404
+ opened_at_gmt: unknown;
1405
+ opened_by: {} | null;
1406
+ closed_by: {} | null;
1407
+ approved_by: {} | null;
1408
+ closed_at_gmt: unknown;
1409
+ opened_at: Record<string, string>;
1410
+ closed_at: Record<string, string>;
1411
+ tenders: ({
1412
+ name: string;
1413
+ label: {};
1414
+ expected: string;
1415
+ counted: string;
1416
+ variance: string;
1417
+ has_variance: boolean;
1418
+ variance_label: string;
1419
+ variance_absolute_display: string;
1420
+ } & Record<"counted_display" | "expected_display" | "variance_display", string>)[];
1421
+ breakdowns: {
1422
+ labels: Values;
1423
+ payment_methods: ({
1424
+ name: {};
1425
+ } & Record<`${string}_display`, string>)[];
1426
+ tax_rates: ({
1427
+ name: {};
1428
+ } & Record<`${string}_display`, string>)[];
1429
+ opening_float: Values & Record<"counted_display" | "expected_display" | "variance_display", string>;
1430
+ movements: {
1431
+ created_at: Record<string, string>;
1432
+ type_label: string;
1433
+ voided: boolean;
1434
+ id: string;
1435
+ reason: string;
1436
+ amount_display: string;
1437
+ }[];
1438
+ cashiers: Values[];
1439
+ };
1440
+ period_sales_total_display: string;
1441
+ period_refunds_total_display: string;
1442
+ perpetual_sales_total_display: string;
1443
+ perpetual_refunds_total_display: string;
1444
+ unsynced_total_display: string;
1445
+ };
1446
+ store: Values;
1447
+ register: {
1448
+ id: unknown;
1449
+ name: {};
1450
+ };
1451
+ software: {
1452
+ name: string;
1453
+ plugin_version: {};
1454
+ };
1455
+ order: {
1456
+ currency: string;
1457
+ printed: Record<string, string>;
1458
+ };
1459
+ fiscal: {
1460
+ immutable_id: string;
1461
+ hash: string;
1462
+ qr_payload: string;
1463
+ tax_agency_code: string;
1464
+ signature_excerpt: string;
1465
+ document_label: string;
1466
+ sequence: null;
1467
+ signed_at: null;
1468
+ extra_fields: never[];
1469
+ document_type: string;
1470
+ receipt_number: string;
1471
+ is_sale_document: boolean;
1472
+ is_refund_document: boolean;
1473
+ is_closure_document: boolean;
1474
+ is_x_report: boolean;
1475
+ is_reprint: boolean;
1476
+ reprint_count: number;
1477
+ };
1478
+ i18n: Record<string, string>;
1479
+ };
1480
+ declare function buildXReportDocument(session: RegisterSession, context: ClosureContext): {
1481
+ closure: {
1482
+ tax_rounding_note?: string | undefined;
1483
+ has_sales: boolean;
1484
+ has_perpetual: boolean;
1485
+ } & {
1486
+ id: string;
1487
+ register_id: string;
1488
+ store_id: string | null | undefined;
1489
+ opened_at: string;
1490
+ expected: Record<string, string>;
1491
+ counted: Record<string, string>;
1492
+ variance: Record<string, string>;
1493
+ breakdowns: {
1494
+ opening_float: {
1495
+ expected: string | undefined;
1496
+ counted: string | undefined;
1497
+ variance: string | undefined;
1498
+ };
1499
+ };
1500
+ } & {
1501
+ opened_at_gmt: unknown;
1502
+ opened_by: {} | null;
1503
+ closed_by: {} | null;
1504
+ approved_by: {} | null;
1505
+ closed_at_gmt: unknown;
1506
+ opened_at: Record<string, string>;
1507
+ closed_at: Record<string, string>;
1508
+ tenders: ({
1509
+ name: string;
1510
+ label: {};
1511
+ expected: string;
1512
+ counted: string;
1513
+ variance: string;
1514
+ has_variance: boolean;
1515
+ variance_label: string;
1516
+ variance_absolute_display: string;
1517
+ } & Record<"counted_display" | "expected_display" | "variance_display", string>)[];
1518
+ breakdowns: {
1519
+ labels: Values;
1520
+ payment_methods: ({
1521
+ name: {};
1522
+ } & Record<`${string}_display`, string>)[];
1523
+ tax_rates: ({
1524
+ name: {};
1525
+ } & Record<`${string}_display`, string>)[];
1526
+ opening_float: Values & Record<"counted_display" | "expected_display" | "variance_display", string>;
1527
+ movements: {
1528
+ created_at: Record<string, string>;
1529
+ type_label: string;
1530
+ voided: boolean;
1531
+ id: string;
1532
+ reason: string;
1533
+ amount_display: string;
1534
+ }[];
1535
+ cashiers: Values[];
1536
+ };
1537
+ period_sales_total_display: string;
1538
+ period_refunds_total_display: string;
1539
+ perpetual_sales_total_display: string;
1540
+ perpetual_refunds_total_display: string;
1541
+ unsynced_total_display: string;
1542
+ };
1543
+ store: Values;
1544
+ register: {
1545
+ id: unknown;
1546
+ name: {};
1547
+ };
1548
+ software: {
1549
+ name: string;
1550
+ plugin_version: {};
1551
+ };
1552
+ order: {
1553
+ currency: string;
1554
+ printed: Record<string, string>;
1555
+ };
1556
+ fiscal: {
1557
+ immutable_id: string;
1558
+ hash: string;
1559
+ qr_payload: string;
1560
+ tax_agency_code: string;
1561
+ signature_excerpt: string;
1562
+ document_label: string;
1563
+ sequence: null;
1564
+ signed_at: null;
1565
+ extra_fields: never[];
1566
+ document_type: string;
1567
+ receipt_number: string;
1568
+ is_sale_document: boolean;
1569
+ is_refund_document: boolean;
1570
+ is_closure_document: boolean;
1571
+ is_x_report: boolean;
1572
+ is_reprint: boolean;
1573
+ reprint_count: number;
1574
+ };
1575
+ i18n: Record<string, string>;
1576
+ };
1577
+
1578
+ /** The register-session logging scope; the app attaches its own sinks (console, remote, toast). */
1579
+ declare const registerFactsLogger: Logger;
1580
+ type Actor = {
1581
+ id: string;
1582
+ name: string;
1583
+ };
1584
+ type Pair = {
1585
+ sessionId: string | undefined;
1586
+ registerId: string | null | undefined;
1587
+ };
1588
+ type Human = Pair & {
1589
+ actor: Actor;
1590
+ };
1591
+ type Movement = Pair & {
1592
+ movementId: string;
1593
+ movementType: CashMovement['type'];
1594
+ amount: number;
1595
+ };
1596
+ type SimpleKind = 'counting-started' | 'counting-abandoned' | 'approval-refused' | 'x-report-dispatched' | 'drawer-dispatched';
1597
+ type RegisterFact = (Human & {
1598
+ kind: 'session-opened';
1599
+ amount: number;
1600
+ variance: RegisterSession['opening_variance_minor'];
1601
+ }) | (Human & {
1602
+ kind: SimpleKind;
1603
+ }) | (Human & {
1604
+ kind: 'session-closed';
1605
+ closureId: string;
1606
+ } & Pick<Closure, 'number' | 'counted' | 'variance'>) | (Human & Movement & {
1607
+ kind: 'movement-recorded';
1608
+ }) | (Human & Movement & {
1609
+ kind: 'movement-voided';
1610
+ voids: string;
1611
+ }) | (Human & {
1612
+ kind: 'approval-granted';
1613
+ approvedBy: string | null;
1614
+ }) | (Human & {
1615
+ kind: 'variance-over-threshold';
1616
+ variance: number;
1617
+ threshold: number | undefined;
1618
+ }) | (Pair & {
1619
+ kind: 'late-sale';
1620
+ orderId: string;
1621
+ actor?: Actor;
1622
+ });
1623
+ declare function recordRegisterFact(fact: RegisterFact): void;
1624
+
1625
+ declare const registerCommandsLogger: Logger;
1626
+ interface RegisterCommand {
1627
+ key: string;
1628
+ registerId: string;
1629
+ seq: number;
1630
+ commandId: string;
1631
+ type: RegisterCommandType;
1632
+ version: number;
1633
+ payload: Record<string, unknown>;
1634
+ createdAt: string;
1635
+ syncStatus: 'pending' | 'applied' | 'rejected';
1636
+ error?: CommandError;
1637
+ result?: RegisterCommandResult;
1638
+ updatedAt: string;
1639
+ }
1640
+ type RegisterCommandCollection = RxCollection<RegisterCommand>;
1641
+ declare const registerCommandSchema: RxJsonSchema<RegisterCommand>;
1642
+ declare const registerCommandCollection: () => {
1643
+ schema: RxJsonSchema<RegisterCommand>;
1644
+ };
1645
+ type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & {
1646
+ version: 1;
1647
+ };
1648
+ declare function sessionOpenCommand(s: RegisterSession): BuiltCommand;
1649
+ declare function sessionTransitionCommand(s: RegisterSession & {
1650
+ status_at: string;
1651
+ }): BuiltCommand;
1652
+ declare function movementCommand(m: CashMovement): BuiltCommand;
1653
+ declare function closureCommand(c: Closure): BuiltCommand;
1654
+ /** Appends missing facts in dependency order; the bytes of an existing key never change. */
1655
+ declare function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
1656
+ commands: RegisterCommandCollection;
1657
+ sessions: RegisterSessionCollection;
1658
+ movements: CashMovementCollection;
1659
+ closures: ClosureCollection;
1660
+ host: RegisterHost;
1661
+ storeKey: string;
1662
+ registerId: string;
1663
+ now?: string;
1664
+ observed?: RegisterSession[];
1665
+ }): Promise<string[]>;
1666
+
1667
+ /** A sale is at tender, so the till can't count or close under it. Its message can be shown to a cashier as it is. */
1668
+ declare class RegisterTenderInProgressError extends Error {
1669
+ constructor();
1670
+ }
1671
+ /** The register already has an `open` or `counting` session, or an open is in flight. Its message can be shown to a cashier as it is. */
1672
+ declare class RegisterSessionAlreadyOpenError extends Error {
1673
+ constructor();
1674
+ }
1675
+ /** An earlier session's close didn't finish (its closure is unwritten or unapplied); close it first. Its message can be shown to a cashier as it is. */
1676
+ declare class RegisterCloseIncompleteError extends Error {
1677
+ constructor();
1678
+ }
1679
+ /** The count is over `varianceThreshold` and the close carries no `approvedBy`. Its message is `RegisterCount`'s refusal copy, so it can be shown to a cashier as it is. */
1680
+ declare class RegisterApprovalRequiredError extends Error {
1681
+ constructor();
1682
+ }
1683
+ interface UseRegisterSessionOptions {
1684
+ /** `register_sessions`, created with `registerSessionCollection()`; `null` while it opens. */
1685
+ sessions: RegisterSessionCollection | null;
1686
+ /** `cash_movements`; `null` while it opens. */
1687
+ movements: CashMovementCollection | null;
1688
+ /** `closures`; `null` while it opens. */
1689
+ closures: ClosureCollection | null;
1690
+ /** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
1691
+ commands?: RegisterCommandCollection | null;
1692
+ capabilities?: ServerCapabilities;
1693
+ /** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
1694
+ orders: RxCollection<PosOrder> | null;
1695
+ /** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
1696
+ register: RegisterHost | null;
1697
+ /** The app's neutral store key (see `bindRegister`). */
1698
+ storeKey: string;
1699
+ /** The register (drawer) this till is bound to, or `null` when it isn't bound. */
1700
+ registerId: string | null;
1701
+ /** The store uses register sessions; `false` turns everything off, as WCPOS's `sessionsOn`. */
1702
+ enabled: boolean;
1703
+ /** The signed-in cashier, for the facts and `opened_by`/`closed_by`. */
1704
+ actor: Actor;
1705
+ /** The store's IANA zone, or `'device'`: the business day a session opens on. */
1706
+ timezone: string;
1707
+ /** The app's version, stamped on the closure. */
1708
+ softwareVersion: string;
1709
+ /** A sale is at tender (`sale.stage.kind === 'tender'`): counting and closing refuse. */
1710
+ tenderInProgress: boolean;
1711
+ /** The count variance, in minor units, over which the app asks for approval. */
1712
+ varianceThreshold?: number;
1713
+ /** `'HH:mm'` on the device's clock; an open session after it is `overdue`. */
1714
+ expectedCloseTime?: string;
1715
+ /** The cashier may not see expected figures while counting. */
1716
+ blind?: boolean;
1717
+ /** The closure's register name, and a person id's display name. */
1718
+ labels?: {
1719
+ registerName?: string;
1720
+ resolveCashierName?: (id: string) => string;
1721
+ };
1722
+ }
1723
+ declare function useRegisterSession(options: UseRegisterSessionOptions): {
1724
+ session: RegisterSession | null;
1725
+ movements: CashMovement[];
1726
+ expected: Record<string, number>;
1727
+ salesCount: number;
1728
+ overdue: boolean;
1729
+ /** 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. */
1730
+ closing: boolean;
1731
+ lastClosure: Closure;
1732
+ lastClosed: RegisterSession;
1733
+ varianceThreshold: number | undefined;
1734
+ enabled: boolean;
1735
+ blind: boolean;
1736
+ /** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
1737
+ saleSession: {
1738
+ id: string;
1739
+ sessions: RegisterSessionCollection;
1740
+ } | undefined;
1741
+ /** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
1742
+ requireOpen: () => Promise<string | null>;
1743
+ /** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
1744
+ requireSaleSession: () => Promise<{
1745
+ id: string;
1746
+ sessions: RegisterSessionCollection;
1747
+ } | null>;
1748
+ actions: {
1749
+ /**
1750
+ * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
1751
+ * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
1752
+ * while an earlier close hasn't finished: a new session would lock the till behind it.
1753
+ */
1754
+ openSession: (input: {
1755
+ expectedFloatMinor: number | null;
1756
+ countedFloatMinor: number;
1757
+ }) => Promise<rxdb.RxDocument<RegisterSession, {}, unknown>>;
1758
+ startCounting: () => Promise<rxdb.RxDocumentBase<RegisterSession, {}, unknown> & RegisterSession & rxdb.ExtendObservables<RegisterSession> & rxdb.ExtendReactivity<RegisterSession, unknown>>;
1759
+ backToSelling: () => Promise<rxdb.RxDocumentBase<RegisterSession, {}, unknown> & RegisterSession & rxdb.ExtendObservables<RegisterSession> & rxdb.ExtendReactivity<RegisterSession, unknown>>;
1760
+ /**
1761
+ * `counted` maps each tender to its counted minor units. An interrupted close resumes with
1762
+ * the count persisted on the session, not the one passed to the retry (WCPOS uses the retry's).
1763
+ * Over `varianceThreshold` (`closeNeedsApproval`, as `RegisterCount`), a close without
1764
+ * `approvedBy` throws `RegisterApprovalRequiredError` before any write, blind or not; a
1765
+ * resumed close (the stored session already closed) isn't gated again. `approvedBy` reaches the Z
1766
+ * (`breakdowns.approved_by`), and `approvedByName` its `approved_by_name`.
1767
+ * One close per register runs at a time, in any hook instance: a call while one is in flight joins
1768
+ * it, getting its closure or error, and its own `counted`, `approvedBy` and `approvedByName` are ignored.
1769
+ */
1770
+ closeSession: (input: {
1771
+ counted: Record<string, number>;
1772
+ approvedBy?: string;
1773
+ approvedByName?: string;
1774
+ }) => Promise<rxdb.RxDocument<Closure, {}, unknown>>;
1775
+ recordMovement: (input: {
1776
+ type: "paid_in" | "paid_out" | "no_sale";
1777
+ amountMinor: number;
1778
+ reason: string;
1779
+ }) => Promise<rxdb.RxDocument<CashMovement, {}, unknown>>;
1780
+ voidMovement: (id: string) => Promise<rxdb.RxDocument<CashMovement, {}, unknown>>;
1781
+ };
1782
+ };
1783
+
1784
+ type CatalogueEntry<Doc> = {
1785
+ product: Doc;
1786
+ variant: VariantSummary;
1787
+ };
1788
+ /** Every variant of every product, in product order then variant order. */
1789
+ declare function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>): CatalogueEntry<Doc>[];
1790
+ /** Barcode-then-SKU lookup across all products. */
1791
+ declare function findEntryByCode<Doc>(entries: CatalogueEntry<Doc>[], code: string): CatalogueEntry<Doc> | undefined;
1792
+ /** Display the resolved price in the requested currency. */
1793
+ declare function variantPriceLabel(variant: VariantSummary, currency: string, locale?: string): string | undefined;
1794
+
1795
+ /** Logs a throw from onSaleCompleted for a confirmed pending completion newSale() has abandoned (Continue): nothing else would ever surface it. */
1796
+ declare const saleLogger: Logger;
1797
+ type SaleStage = {
1798
+ kind: 'cart';
1799
+ } | {
1800
+ kind: 'tender';
1801
+ method: 'cash' | 'external';
1802
+ } | {
1803
+ kind: 'receipt';
1804
+ order: SentOrder;
1805
+ posOrder: PosOrder;
1806
+ };
1807
+ /** TallyUI finalizeOrder's refusal below order.create v2 (c19a203), shown when the discount is applied; finalize stays the backstop. */
1808
+ declare const DISCOUNTS_UNSUPPORTED = "finalize: discounts are not supported by the server yet (order.create v2)";
1809
+ /** Every sale change refuses with this while a completion is pending (see `complete()`). */
1810
+ declare const SALE_SAVING = "This sale is being saved. Retry to finish it.";
1811
+ /** Call under a `TaxProvider`: its tax context and the settings' currency price every sale. */
1812
+ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
1813
+ registerId: string;
1814
+ cashierRef: string;
1815
+ capabilities?: ServerCapabilities;
1816
+ /**
1817
+ * When set, `complete()` stamps the finalized order with this session before `onSaleCompleted`.
1818
+ * `complete()` runs after the money is taken, so a refused stamp (the session closed or went
1819
+ * missing) never stops the sale: it goes on to `onSaleCompleted` and the receipt with
1820
+ * `lateSessionId` instead of `sessionId`, and a `late-sale` register fact is recorded (ADR-032).
1821
+ * The session stamped is the one in force when the tender started (`startTender`), pinned for that
1822
+ * tender: this option going undefined mid-tender (the session closed) doesn't skip the stamp. A
1823
+ * tender that pinned none, with a session here at `complete()`, stamps it and logs a warning.
1824
+ */
1825
+ session?: {
1826
+ id: string;
1827
+ sessions: RegisterSessionCollection;
1828
+ };
1829
+ onSaleCompleted?: (posOrder: PosOrder) => Promise<void> | void;
1830
+ /**
1831
+ * Asked after `onSaleCompleted` throws, or on a `newSale()` refused mid-save: whether that order is confirmed stored
1832
+ * (medusapos: `useOrderOutbox`'s `isStored`). Only a true answer sets `canContinue`; without it, a failed save offers Retry only.
1833
+ */
1834
+ isStored?: (posOrder: PosOrder) => Promise<boolean>;
1835
+ /** Tests only: overrides HUNG_SAVE_CHECK_MS, so a test can shrink the hung-save poll's interval. */
1836
+ hungSaveCheckMs?: number;
1837
+ }): {
1838
+ order: Order;
1839
+ stage: SaleStage;
1840
+ error: string | null;
1841
+ idle: boolean;
1842
+ /** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
1843
+ saving: boolean;
1844
+ /** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
1845
+ canContinue: boolean;
1846
+ add(entry: CatalogueEntry<any>, traits: ProductTraits<any>): void;
1847
+ setQuantity(lineId: string, quantity: number): void;
1848
+ remove(lineId: string): void;
1849
+ /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
1850
+ applyDiscount(lineId: string | null, discount: Discount): string | null;
1851
+ removeDiscount(id: string): void;
1852
+ /** the picked customer reaches the server as order.create v3's customer.customerId */
1853
+ setCustomer(customer: CustomerSummary | null): void;
1854
+ /**
1855
+ * Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
1856
+ * session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
1857
+ * opened just before (#170 race). A repeat call mid-tender keeps the first pin.
1858
+ */
1859
+ startTender(method: "cash" | "external", options?: {
1860
+ session?: {
1861
+ id: string;
1862
+ sessions: RegisterSessionCollection;
1863
+ };
1864
+ }): void;
1865
+ setTender: (tender: {
1866
+ method: "cash" | "external";
1867
+ amountMinor: number;
1868
+ reference?: string;
1869
+ } | null) => void;
1870
+ cancelTender(): void;
1871
+ /**
1872
+ * Finalizes the tender, stamps the session (see `session`), hands the order to `onSaleCompleted`,
1873
+ * then shows the receipt. `saving` turns true and the sale locks (every change sets SALE_SAVING)
1874
+ * from this call's entry, not only once the order is built; a refused finalize unlocks it again,
1875
+ * with the refusal's error. Idempotent for one tender attempt (DECISIONS, ADR-052): once the order
1876
+ * is built, that order is the sale, kept as the pending completion, because `onSaleCompleted` may
1877
+ * already have stored it before failing. If `onSaleCompleted` throws, the error is set and the tender stays;
1878
+ * calling `complete()` again reuses the pending completion exactly (the same `id`, `commandId`
1879
+ * and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
1880
+ * again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
1881
+ * pending completion is cleared once `onSaleCompleted` resolves, or by `newSale()`. A call while
1882
+ * another is in flight returns that call's promise, and a call on the receipt does nothing. While
1883
+ * `cashierRef` or `registerId` is out of bounds (shown as `error`), a call does nothing either,
1884
+ * unless it's the Retry of a pending completion, which was built with the options as they were.
1885
+ */
1886
+ complete: () => Promise<void>;
1887
+ /**
1888
+ * Starts a new, empty sale. Refused with SALE_SAVING, changing nothing, while a save is still
1889
+ * building or stamping (nothing yet to ask `isStored` about), and while a pending completion
1890
+ * exists and isn't confirmed stored — the ways out are Retry (`complete()`) or Continue, once
1891
+ * `canContinue` (the Front desk, 2026-09-27; #149 review). So an app that doesn't pass `isStored`
1892
+ * gets Retry only after a failed save. A refusal while a save is still delivering its order asks
1893
+ * `isStored` afresh, so a hung save whose order is stored can still offer Continue. Past those, it
1894
+ * abandons a confirmed pending completion (never handed to `onSaleCompleted` again: it's stored; a
1895
+ * save still in flight carries on in the background, a throw logged at error) and starts the new
1896
+ * sale outright — from the receipt, or an idle cart, there's nothing pending to abandon. Abandoning
1897
+ * clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
1898
+ * updates or requeues `pos_orders` itself.
1899
+ */
1900
+ newSale(): void;
1901
+ /**
1902
+ * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
1903
+ * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
1904
+ * its receipt is not shown. Otherwise does nothing.
1905
+ */
1906
+ continueSale(): void;
1907
+ };
1908
+
1909
+ declare class CartError extends Error {
1910
+ }
1911
+ declare function addEntryToCart<Doc>(builder: OrderBuilder, entry: CatalogueEntry<Doc>, traits: ProductTraits<Doc>, currency: string): string;
1912
+
1913
+ type TransportOutcome = {
1914
+ kind: 'results';
1915
+ results: CommandResult[];
1916
+ } | {
1917
+ kind: 'unauthorized';
1918
+ } | {
1919
+ kind: 'refused';
1920
+ status: number;
1921
+ reason: string;
1922
+ }
1923
+ /** `reason` is `network` when the store could not be reached, `timeout` when a sent request got no answer in time,
1924
+ * else what the store answered (`status_503`, `bad_body`, ...). The order outbox counts every reason but `network`. */
1925
+ | {
1926
+ kind: 'retry';
1927
+ reason: string;
1928
+ retryAfterMs?: number;
1929
+ };
1930
+ interface CommandTransport<E extends AnyCommandEnvelope = CommandEnvelope<OrderCreatePayload>> {
1931
+ send(batch: E[]): Promise<TransportOutcome>;
1932
+ }
1933
+ interface OutboxState {
1934
+ pending: number;
1935
+ /** The order outbox's count of `pos_orders` the store refused (`syncStatus: 'rejected'`), read with `pending`. Each
1936
+ * stays until requeue() sends it again. The register outbox leaves it unset. */
1937
+ rejected?: number;
1938
+ sending: boolean;
1939
+ lastRetryReason?: string;
1940
+ nextAttemptAt?: number;
1941
+ /** Set after 3 consecutive 401s; the app should ask the cashier to sign in, then call flush(). */
1942
+ authRequired?: boolean;
1943
+ /** Set when the server refused a whole batch (HTTP 400, 403, 413, 415, 422). No order is changed; sending pauses until the next flush(). */
1944
+ refused?: {
1945
+ status: number;
1946
+ reason: string;
1947
+ };
1948
+ /** Set while the store (not offline) has kept failing some orders for 15 minutes, each on its own clock. Those
1949
+ * orders stay pending and keep retrying. `orders` has each order's own entry: its `since` is when its clock
1950
+ * would have started had there been no offline gaps (now minus its answered time; while offline, as of the
1951
+ * moment the clock paused), and its latest `reason`. `since` is the earliest of theirs; `reason` the latest. */
1952
+ stuck?: {
1953
+ commandIds: string[];
1954
+ since: number;
1955
+ reason: string;
1956
+ orders: {
1957
+ commandId: string;
1958
+ since: number;
1959
+ reason: string;
1960
+ }[];
1961
+ };
1962
+ /** Set after 3 consecutive 404 answers (counted across the outboxes sharing one BackendNotFound): the store address
1963
+ * may be wrong, or the store's plugin isn't installed or is switched off. The outbox keeps retrying; the next answer
1964
+ * that isn't a 404 clears it (offline changes nothing). `since` is when the first of those 404s arrived. */
1965
+ backendMissing?: {
1966
+ since: number;
1967
+ };
1968
+ }
1969
+
1970
+ interface HttpTransportOptions {
1971
+ baseUrl: string;
1972
+ getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
1973
+ fetch?: typeof fetch;
1974
+ timeoutMs?: number;
1975
+ }
1976
+ declare function createHttpCommandTransport(options: HttpTransportOptions): CommandTransport<AnyCommandEnvelope>;
1977
+
1978
+ /** Counts consecutive 404s across every outbox given it, so a missing plugin shows once, whichever outbox meets it. */
1979
+ interface BackendNotFound {
1980
+ /** A 404 counts; any other answer from the store clears; offline (`network`) changes nothing. */
1981
+ record(outcome: TransportOutcome, at: number): void;
1982
+ backendMissing$: Observable<OutboxState['backendMissing']>;
1983
+ }
1984
+ declare function createBackendNotFound(): BackendNotFound;
1985
+
1986
+ interface OrderOutboxOptions {
1987
+ collection: RxCollection<PosOrder>;
1988
+ transport: CommandTransport<OrderCreateEnvelope>;
1989
+ deviceId: string;
1990
+ getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
1991
+ refreshCapabilities?: () => Promise<void>;
1992
+ batchSize?: number;
1993
+ initialBackoffMs?: number;
1994
+ maxBackoffMs?: number;
1995
+ random?: () => number;
1996
+ now?: () => number;
1997
+ /** Tests only: overrides ISOLATE_AFTER_ATTEMPTS. */
1998
+ isolateAfterAttempts?: number;
1999
+ /** Tests only: overrides STUCK_AFTER_MS. */
2000
+ stuckAfterMs?: number;
2001
+ /** Counts 404s toward OutboxState.backendMissing; pass the register outbox's too, so either one's 404s show one notice. */
2002
+ backendNotFound?: BackendNotFound;
2003
+ }
2004
+ interface OrderOutbox {
2005
+ /** Sends pending orders until empty or retrying. Concurrent calls share one run. */
2006
+ flush(): Promise<void>;
2007
+ /** Moves rejected orders (all, or those whose id is listed) back to pending with a new commandId, then flushes.
2008
+ * Orders rejected with idempotency_mismatch are left for reconciliation. Resolves to the number requeued. */
2009
+ requeue(orderIds?: string[]): Promise<number>;
2010
+ /** Watches for pending orders and sends them. */
2011
+ start(): void;
2012
+ /** Stops watching and retrying. */
2013
+ stop(): void;
2014
+ state$: Observable<OutboxState>;
2015
+ }
2016
+ declare function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox;
2017
+
2018
+ interface RegisterOutboxOptions {
2019
+ collection: RxCollection<RegisterCommand>;
2020
+ transport: CommandTransport<RegisterCommandEnvelope>;
2021
+ deviceId: string;
2022
+ /** Checked at the start of every run; false sends nothing. */
2023
+ isEnabled?: () => boolean;
2024
+ /** Runs before marking, so a crash resends; a throw is logged and marking continues. */
2025
+ onResult?: (command: RegisterCommand, result: CommandResult) => Promise<void> | void;
2026
+ batchSize?: number;
2027
+ initialBackoffMs?: number;
2028
+ maxBackoffMs?: number;
2029
+ random?: () => number;
2030
+ now?: () => number;
2031
+ /** Tests only: overrides STUCK_AFTER_MS. */
2032
+ stuckAfterMs?: number;
2033
+ /** Counts 404s toward OutboxState.backendMissing; pass the order outbox's too, so either one's 404s show one notice. */
2034
+ backendNotFound?: BackendNotFound;
2035
+ }
2036
+ interface RegisterOutbox {
2037
+ flush(): Promise<void>;
2038
+ start(): void;
2039
+ stop(): void;
2040
+ state$: Observable<OutboxState>;
2041
+ }
2042
+ declare function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox;
2043
+
2044
+ declare const outboxLogger: Logger;
2045
+
2046
+ interface UseOrderOutboxOptions {
2047
+ /** Which order store to use (medusapos: the backend's base URL); `null` means no store. A change reopens. */
2048
+ storeKey: string | null;
2049
+ /** Opens the order store for `storeKey`; `close()` is called when the key or device id changes, or on unmount. */
2050
+ open(storeKey: string): Promise<{
2051
+ orders: RxCollection<PosOrder>;
2052
+ close(): Promise<void>;
2053
+ }>;
2054
+ /** Builds the command transport for `storeKey` (the app's HTTP transport and auth headers). Read once per open. */
2055
+ transport(storeKey: string): CommandTransport<OrderCreateEnvelope>;
2056
+ /** The device id sent on every command (see `getDeviceId`). A change reopens. */
2057
+ deviceId: string;
2058
+ /** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
2059
+ getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
2060
+ /** Presence is fixed when the store opens; calls use the latest function. Reopen to add or remove. */
2061
+ refreshCapabilities?: () => Promise<void>;
2062
+ /** Called with `state.sending`, and with `false` on cleanup (medusapos: live-tab's `markBusy('outbox', …)`). */
2063
+ onBusy?(busy: boolean): void;
2064
+ /** Called when `open` rejects, even if the key has changed since (medusapos: reports storage worker failures). */
2065
+ onOpenError?(error: unknown): void;
2066
+ }
2067
+ interface UseOrderOutboxResult {
2068
+ /** The open orders collection, or `null` until the current store is ready. */
2069
+ orders: RxCollection<PosOrder> | null;
2070
+ /** The outbox state, or idle until the current store is ready. */
2071
+ state: OutboxState;
2072
+ /** The newest 50 orders, newest first; empty until the current store is ready. */
2073
+ recent: PosOrder[];
2074
+ /**
2075
+ * Stores a finalized order, then flushes. Throws the opening error, or "Orders are not ready.", before the store is ready.
2076
+ * Recording an order whose `id` is already stored, not deleted, with the same money-bearing content (`sameSale`;
2077
+ * a retried `complete()`) counts as stored, never overwrites it, and still flushes, whatever the stored `commandId`
2078
+ * (a requeue mints a new one; the difference is logged at warn). Other content rejects with `OrderContentMismatchError`.
2079
+ */
2080
+ record(posOrder: PosOrder): Promise<void>;
2081
+ /**
2082
+ * Whether the current store holds this order `id`, not deleted, with the same money-bearing content (`sameSale`),
2083
+ * whatever its `commandId`. A primary-key read on the storage instance, past RxDB's query cache. False before the
2084
+ * store is ready; a content mismatch is false and logged at error.
2085
+ */
2086
+ isStored(order: PosOrder): Promise<boolean>;
2087
+ /** Sends pending orders; does nothing before the current store is ready. */
2088
+ flush(): Promise<void>;
2089
+ /** Moves rejected orders back to pending (see `OrderOutbox.requeue`); resolves to 0 before the current store is ready. */
2090
+ requeue(orderIds?: string[]): Promise<number>;
2091
+ /**
2092
+ * The count of `record()` calls not yet settled (resolved or rejected). An app holds sign-out
2093
+ * (closing the store) while this is above 0, because a close that lands under a write stuck in
2094
+ * storage can wait forever, and TallyUI deliberately doesn't bound that close (#155).
2095
+ */
2096
+ savesInFlight: number;
2097
+ /** The command ids in `state.stuck`: pending orders the store keeps failing. Pass them to `needsAttention` and
2098
+ * `OrdersList`. Empty when none, and before the current store is ready. */
2099
+ stuckCommandIds: readonly string[];
2100
+ }
2101
+ /** Opens the order store for `storeKey`, runs its outbox and watches the recent orders (lifted from medusapos/app, ADR-052). */
2102
+ declare function useOrderOutbox(options: UseOrderOutboxOptions): UseOrderOutboxResult;
2103
+
2104
+ /** The transport a payment terminal is reached over: hardware-neutral, not tied to any backend. */
2105
+ type PaymentTransport = 'bluetooth' | 'usb' | 'network' | 'tap_to_pay';
2106
+ type TenderView = 'select' | 'amount' | 'cancel';
2107
+ type TenderLineId = string | number;
2108
+ type TenderPlan = {
2109
+ kind: 'even';
2110
+ ways: number;
2111
+ from: number;
2112
+ } | {
2113
+ kind: 'fixed';
2114
+ firstMinor: number;
2115
+ title: string | null;
2116
+ from: number;
2117
+ } | {
2118
+ kind: 'items';
2119
+ lineIds: TenderLineId[];
2120
+ firstMinor: number;
2121
+ ways: number;
2122
+ from: number;
2123
+ };
2124
+ type SplitTab = 'even' | 'amount' | 'percent' | 'item';
2125
+ interface TenderState {
2126
+ view: TenderView;
2127
+ /** Method id being tendered; null in the 'select' and 'cancel' views. */
2128
+ methodId: string | null;
2129
+ readerId: string | null;
2130
+ transport: PaymentTransport | null;
2131
+ /** Keypad entry in minor units. */
2132
+ entryMinor: number;
2133
+ /** False until the cashier has touched the keypad since the entry was pre-filled. */
2134
+ entryDirty: boolean;
2135
+ plan: TenderPlan | null;
2136
+ splitView: boolean;
2137
+ splitTab: SplitTab;
2138
+ pickedLineIds: TenderLineId[];
2139
+ linesPaidBy: Record<TenderLineId, string[]>;
2140
+ }
2141
+ /** '0'..'9' plus the two edit keys. There is deliberately no decimal key: digits shift in from the right. */
2142
+ type TenderKey = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'clear' | 'backspace';
2143
+ type TenderAction = {
2144
+ type: 'pick-method';
2145
+ methodId: string;
2146
+ prefillMinor: number;
2147
+ readerId: string | null;
2148
+ transport?: PaymentTransport | null;
2149
+ } | {
2150
+ type: 'pick-transport';
2151
+ transport: PaymentTransport;
2152
+ } | {
2153
+ type: 'pick-reader';
2154
+ readerId: string | null;
2155
+ } | {
2156
+ type: 'tender-started';
2157
+ } | {
2158
+ type: 'key';
2159
+ key: TenderKey;
2160
+ } | {
2161
+ type: 'set-entry';
2162
+ minor: number;
2163
+ } | {
2164
+ type: 'back';
2165
+ } | {
2166
+ type: 'tender-recorded';
2167
+ rowsSinceFrom: {
2168
+ title: string;
2169
+ amountMinor: number;
2170
+ }[];
2171
+ /** The order's balance once this row counts, so the next leg can be pre-typed. */
2172
+ balanceMinor: number;
2173
+ } | {
2174
+ type: 'open-split';
2175
+ } | {
2176
+ type: 'close-split';
2177
+ } | {
2178
+ type: 'set-split-tab';
2179
+ tab: SplitTab;
2180
+ } | {
2181
+ type: 'toggle-split-line';
2182
+ lineId: TenderLineId;
2183
+ } | {
2184
+ type: 'set-plan';
2185
+ plan: TenderPlan;
2186
+ balanceMinor: number;
2187
+ } | {
2188
+ type: 'clear-plan';
2189
+ balanceMinor: number;
2190
+ } | {
2191
+ type: 'arm-custom';
2192
+ } | {
2193
+ type: 'request-cancel';
2194
+ } | {
2195
+ type: 'reset';
2196
+ };
2197
+ /** $9,999,999.99 at two decimals — a till will never legitimately take more, and it stops a stuck key running the display off the screen. */
2198
+ declare const MAX_TENDER_MINOR = 999999999;
2199
+ declare const initialTenderState: TenderState;
2200
+ /**
2201
+ * Reducer init. An order whose method the checkout store already holds — a URL seed, or a
2202
+ * tab the cashier is coming back to — reopens its keypad prefilled with the balance, exactly
2203
+ * as a tap on that tile would. Everything else starts from scratch.
2204
+ */
2205
+ declare function initTenderState({ methodId, balanceMinor, }: {
2206
+ methodId: string | null;
2207
+ balanceMinor: number;
2208
+ }): TenderState;
2209
+ declare function tenderReducer(state: TenderState, action: TenderAction): TenderState;
2210
+ /**
2211
+ * What the leg applies to the order. Cash may be tendered above the balance;
2212
+ * the excess is change, never an overpayment on the order.
2213
+ */
2214
+ declare function appliedMinor(entryMinor: number, balanceMinor: number): number;
2215
+ /** Change handed back. Zero for any tender whose method cannot give change. */
2216
+ declare function changeMinor(entryMinor: number, appliedAmountMinor: number, givesChange: boolean): number;
2217
+ /**
2218
+ * Quick tendered amounts under a cash keypad: the balance itself, then the next
2219
+ * whole 5, 10 and 50 above it. Deduped, ascending, never below the balance.
2220
+ * The caller supplies those major-unit steps already scaled to minor units.
2221
+ */
2222
+ declare function quickTenderedAmounts(balanceMinor: number, stepsMinor: readonly number[]): number[];
2223
+ /**
2224
+ * Even split shares. Returns the next tender's share, rounded half-up to the
2225
+ * minor unit; the last leg remains whatever balance is left.
2226
+ */
2227
+ declare function evenSplitShareMinor(balanceMinor: number, ways: number): number;
2228
+ /** Completion is derived from payment rows, never an extra counter in the reducer. */
2229
+ declare function activePlan(plan: TenderPlan | null, taken: number, balanceMinor: number): TenderPlan | null;
2230
+ interface PlanLeg {
2231
+ minor: number;
2232
+ state: 'done' | 'now' | 'todo' | 'rest';
2233
+ title?: string;
2234
+ }
2235
+ /** Label pieces stay untranslated here; the flow supplies localized copy and item names. */
2236
+ declare function planLegs(plan: TenderPlan, rowsSinceFrom: {
2237
+ minor: number;
2238
+ title: string;
2239
+ }[], balanceMinor: number): {
2240
+ legs: PlanLeg[];
2241
+ thisPaymentMinor: number;
2242
+ label: {
2243
+ n: number;
2244
+ ways: number;
2245
+ rest: boolean;
2246
+ title: string | null;
2247
+ };
2248
+ };
261
2249
 
262
- export { type AppliedDiscount, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, type Discount, type LineItem, type LogEntry, type LogLevel, type LogSink, type Logger, type Order, type OrderBuilder, type OrderBuilderOptions, type OrderManager, type OrderManagerOptions, type ParkedOrderSummary, type Payment, type PaymentMethod, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type Repository, type TaxContext, TaxProvider, type TaxProviderProps, type TaxRateMap, type TaxResult, addTax, buildReceiptData, calculateTax, callbackSink, consoleSink, createLogger, createOrderBuilder, createOrderManager, createRepository, extractTax, formatCurrency, useCurrencyFormatter, useTax };
2250
+ export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OutboxState, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, initTenderState, initialTenderState, isServerDecimal, labelKeys, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, observeRegister$, openSession, openSessionSelector, outboxLogger, overThreshold, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, requireOpenSession, resolveStoreSettings, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure };