@tallyui/pos 3.0.0-next.0 → 3.0.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -177,7 +177,7 @@ declare function resolveStoreSettings(options: ResolveStoreSettingsOptions, choi
177
177
  /** The replication's context with the settings' opaque pricing context; the key is left out when the settings have none. */
178
178
  declare function withPricingContext(context: SyncContext, settings: StoreSettings): SyncContext;
179
179
  /**
180
- * `<TaxProvider>`'s props from the same settings, so its tax inclusivity and rounding match the store's. `custom`
180
+ * `<TaxProvider>`'s props from the same settings, so its tax inclusivity, rounding and rate codes match the store's. `custom`
181
181
  * rounding passes none, the default (#287). An explicit `rounding` prop after the spread still wins, since JSX
182
182
  * applies props in order; so `<TaxProvider {...taxProviderProps(s)} rounding={undefined}>` wipes the store's rounding.
183
183
  */
@@ -198,6 +198,7 @@ type StoreSettingsState = {
198
198
  state: 'error';
199
199
  error: unknown;
200
200
  retry: () => void;
201
+ nextRetryAt?: number;
201
202
  } | {
202
203
  state: 'unsupported';
203
204
  };
@@ -208,6 +209,10 @@ type StoreSettingsState = {
208
209
  * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
209
210
  * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
210
211
  * rounding already known: no later change of `settings` holds a sale.
212
+ * Unknown rounding keeps settings unresolved and retries by itself; show "Can't reach the store's settings yet. Retrying…" while `nextRetryAt` is set.
213
+ * A till-class or invalid-credentials sign-in error is not retried by itself, so the app prompts to sign in or update the till.
214
+ * An app shows a sign-in prompt when the `error` state's error is a `ConnectorUnauthorizedError` with `code: 'unauthorized'`
215
+ * or a `SignInError` with `code: 'invalid_credentials'`, and an update prompt for `code: 'till_update_required'`.
211
216
  *
212
217
  * ```tsx
213
218
  * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
@@ -1948,14 +1953,18 @@ interface OutboxState {
1948
1953
  /** Set while the store (not offline) has kept failing some orders for 15 minutes, each on its own clock. Those
1949
1954
  * orders stay pending and keep retrying. `orders` has each order's own entry: its `since` is when its clock
1950
1955
  * 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. */
1956
+ * moment the clock paused), and its latest `reason`. `since` is the earliest of theirs, virtual for the threshold;
1957
+ * `reason` the latest. `firstFailedAt` is the earliest real wall-clock first failure, only when every stuck clock
1958
+ * has one. Each order entry has its own real wall-clock `firstFailedAt`. Both are in memory only, absent after restart. */
1952
1959
  stuck?: {
1953
1960
  commandIds: string[];
1954
1961
  since: number;
1962
+ firstFailedAt?: number;
1955
1963
  reason: string;
1956
1964
  orders: {
1957
1965
  commandId: string;
1958
1966
  since: number;
1967
+ firstFailedAt?: number;
1959
1968
  reason: string;
1960
1969
  }[];
1961
1970
  };
@@ -2041,6 +2050,27 @@ interface RegisterOutbox {
2041
2050
  }
2042
2051
  declare function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox;
2043
2052
 
2053
+ interface UseRegisterOutboxOptions {
2054
+ /** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
2055
+ commands: RegisterCommandCollection | null;
2056
+ /** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
2057
+ transport(): CommandTransport<RegisterCommandEnvelope>;
2058
+ /** The device id sent on every command. A change restarts the outbox. */
2059
+ deviceId: string;
2060
+ /** Passed through to createRegisterOutbox (same meaning). The latest function is called each time, so a new one does not restart the outbox. */
2061
+ isEnabled?: () => boolean;
2062
+ /** Called with each result. The latest function is called each time; a new one does not restart the outbox. */
2063
+ onResult?: RegisterOutboxOptions['onResult'];
2064
+ backendNotFound?: RegisterOutboxOptions['backendNotFound'];
2065
+ }
2066
+ interface UseRegisterOutboxResult {
2067
+ /** The outbox state, or idle ({ pending: 0, sending: false }) while there is no collection. */
2068
+ state: OutboxState;
2069
+ /** Sends pending till updates; does nothing while there is no collection. */
2070
+ flush(): Promise<void>;
2071
+ }
2072
+ declare function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegisterOutboxResult;
2073
+
2044
2074
  declare const outboxLogger: Logger;
2045
2075
 
2046
2076
  interface UseOrderOutboxOptions {
@@ -2247,4 +2277,4 @@ declare function planLegs(plan: TenderPlan, rowsSinceFrom: {
2247
2277
  };
2248
2278
  };
2249
2279
 
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 };
2280
+ 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 UseRegisterOutboxOptions, type UseRegisterOutboxResult, 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, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure };
package/dist/index.js CHANGED
@@ -319,26 +319,47 @@ function withPricingContext(context, settings) {
319
319
  }
320
320
  function taxProviderProps(settings) {
321
321
  const rounding = settings.taxRounding?.granularity === "custom" ? void 0 : settings.taxRounding;
322
- return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...rounding && { rounding } };
322
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...rounding && { rounding }, ...settings.taxRateCodes && { rateCodes: settings.taxRateCodes } };
323
323
  }
324
324
 
325
325
  // src/store-settings/use-store-settings.ts
326
326
  import { useCallback as useCallback2, useEffect, useRef, useState } from "react";
327
+ import { errorKind, SignInError } from "@tallyui/core";
327
328
  var LOADING = { state: "loading" };
329
+ var StoreCapabilitiesUnavailableError = class extends Error {
330
+ constructor(cause) {
331
+ super("Store capabilities unavailable", { cause });
332
+ this.name = "StoreCapabilitiesUnavailableError";
333
+ }
334
+ };
335
+ var INITIAL_RETRY_MS = 5e3;
336
+ var MAX_RETRY_MS = 5 * 6e4;
328
337
  function readTaxRounding({ connector, context }) {
329
338
  if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
330
339
  if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(void 0);
331
340
  const read = connector.capabilities;
332
- return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => void 0);
341
+ return Promise.resolve().then(() => read(context)).then(
342
+ (capabilities) => {
343
+ if (!capabilities) throw new StoreCapabilitiesUnavailableError();
344
+ return capabilities.taxRounding;
345
+ },
346
+ (cause) => {
347
+ if (errorKind(cause) === "till" || cause instanceof SignInError && cause.code === "invalid_credentials") throw cause;
348
+ throw new StoreCapabilitiesUnavailableError(cause);
349
+ }
350
+ );
333
351
  }
334
352
  function useStoreSettings(options) {
335
353
  const { connector, context } = options;
336
354
  const optionsRef = useRef(options);
337
355
  optionsRef.current = options;
338
356
  const requestRef = useRef(0);
357
+ const retryTimerRef = useRef(void 0);
358
+ const retryDelayRef = useRef(INITIAL_RETRY_MS);
339
359
  const [state, setState] = useState(LOADING);
340
360
  const resolve = useCallback2((choice, from, known) => {
341
361
  if (from !== void 0 && from !== requestRef.current) return;
362
+ clearTimeout(retryTimerRef.current);
342
363
  const request = ++requestRef.current;
343
364
  const current = () => request === requestRef.current;
344
365
  setState(LOADING);
@@ -346,6 +367,7 @@ function useStoreSettings(options) {
346
367
  Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
347
368
  ([result, taxRounding]) => {
348
369
  if (!current()) return;
370
+ retryDelayRef.current = INITIAL_RETRY_MS;
349
371
  if (result.status === "ready")
350
372
  setState({ state: "ready", settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
351
373
  else if (result.status === "choose")
@@ -354,13 +376,23 @@ function useStoreSettings(options) {
354
376
  },
355
377
  // A retry keeps the choice that failed: a new pick is saved only once it resolves.
356
378
  (error) => {
357
- if (current()) setState({ state: "error", error, retry: () => resolve(choice, request) });
379
+ if (!current()) return;
380
+ const retry = () => resolve(choice, request);
381
+ if (error instanceof StoreCapabilitiesUnavailableError) {
382
+ const delay = retryDelayRef.current;
383
+ const nextRetryAt = Date.now() + delay;
384
+ retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
385
+ retryTimerRef.current = setTimeout(retry, delay);
386
+ setState({ state: "error", error, retry, nextRetryAt });
387
+ } else setState({ state: "error", error, retry });
358
388
  }
359
389
  );
360
390
  }, []);
361
391
  useEffect(() => {
362
392
  resolve();
363
393
  return () => {
394
+ clearTimeout(retryTimerRef.current);
395
+ retryDelayRef.current = INITIAL_RETRY_MS;
364
396
  requestRef.current += 1;
365
397
  };
366
398
  }, [connector, context, resolve]);
@@ -3533,7 +3565,7 @@ function useSale(settings, opts) {
3533
3565
  const subscription = rendered.order$.subscribe(setOrder);
3534
3566
  return () => subscription.unsubscribe();
3535
3567
  }, [rendered]);
3536
- const idle2 = stage.kind === "cart" && !order.lineItems.length;
3568
+ const idle3 = stage.kind === "cart" && !order.lineItems.length;
3537
3569
  useLayoutEffect(() => {
3538
3570
  const live = builderNow.current.getSnapshot();
3539
3571
  const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
@@ -3620,7 +3652,7 @@ function useSale(settings, opts) {
3620
3652
  order,
3621
3653
  stage,
3622
3654
  error,
3623
- idle: idle2,
3655
+ idle: idle3,
3624
3656
  /** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
3625
3657
  saving,
3626
3658
  /** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
@@ -3940,8 +3972,14 @@ function createOrderOutbox(options) {
3940
3972
  return {
3941
3973
  commandIds: stuck.map(([id2]) => id2),
3942
3974
  since: Math.min(...stuck.map(([, clock]) => clock.since)),
3975
+ ...stuck.every(([, clock]) => clock.firstFailedAt !== void 0) ? { firstFailedAt: Math.min(...stuck.map(([, clock]) => clock.firstFailedAt)) } : {},
3943
3976
  reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
3944
- orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
3977
+ orders: stuck.map(([commandId, { since, firstFailedAt, reason }]) => ({
3978
+ commandId,
3979
+ since,
3980
+ reason,
3981
+ ...firstFailedAt === void 0 ? {} : { firstFailedAt }
3982
+ }))
3945
3983
  };
3946
3984
  }
3947
3985
  function isDue(id2) {
@@ -3980,6 +4018,7 @@ function createOrderOutbox(options) {
3980
4018
  }
3981
4019
  const bare = ({ serverFailures, _rev, _meta, ...data }) => data;
3982
4020
  const failuresOnly = (before, after) => !deepEqual2(before.serverFailures, after.serverFailures) && deepEqual2(bare(before), bare(after));
4021
+ const recordsSentForm = (before, after) => deepEqual2(bare({ ...freezeSentForm(before), sentVersion: before.sentVersion ?? after.sentVersion }), bare(after));
3983
4022
  async function updateState(patch = {}) {
3984
4023
  const pending = await countFresh(collection, { syncStatus: "pending" });
3985
4024
  const rejected = await countFresh(collection, { syncStatus: "rejected" });
@@ -3992,7 +4031,7 @@ function createOrderOutbox(options) {
3992
4031
  }
3993
4032
  function serverFailed(orders, alone, reason, retryAfterMs) {
3994
4033
  const at2 = now();
3995
- for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
4034
+ for (const { commandId } of orders) clocks.set(commandId, { ...clocks.get(commandId) ?? { since: at2, firstFailedAt: at2 }, seq: ++failureSeq, reason });
3996
4035
  const id2 = orders[0].commandId;
3997
4036
  if (alone === "isolated") {
3998
4037
  isolate(id2, reason, retryAfterMs);
@@ -4311,7 +4350,7 @@ function createOrderOutbox(options) {
4311
4350
  stopped = false;
4312
4351
  watchBackendMissing();
4313
4352
  if (!subscription) subscription = collection.$.subscribe((event) => {
4314
- if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE" && !(event.previousDocumentData !== void 0 && failuresOnly(event.previousDocumentData, event.documentData)))) {
4353
+ if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE" && !(event.previousDocumentData !== void 0 && (failuresOnly(event.previousDocumentData, event.documentData) || recordsSentForm(event.previousDocumentData, event.documentData))))) {
4315
4354
  insertedDuringRun = true;
4316
4355
  flush().catch(() => {
4317
4356
  });
@@ -4397,13 +4436,14 @@ function createRegisterOutbox(options) {
4397
4436
  return {
4398
4437
  commandIds: stuck.map(([id2]) => id2),
4399
4438
  since: Math.min(...stuck.map(([, clock]) => clock.since)),
4439
+ firstFailedAt: Math.min(...stuck.map(([, clock]) => clock.firstFailedAt)),
4400
4440
  reason: stuck.reduce((latest, entry) => entry[1].seq > latest[1].seq ? entry : latest)[1].reason,
4401
- orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason }))
4441
+ orders: stuck.map(([commandId, { since, firstFailedAt, reason }]) => ({ commandId, since, firstFailedAt, reason }))
4402
4442
  };
4403
4443
  }
4404
4444
  function serverFailed(commands, reason) {
4405
4445
  const at2 = now();
4406
- for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at2, seq: ++failureSeq, reason });
4446
+ for (const { commandId } of commands) clocks.set(commandId, { ...clocks.get(commandId) ?? { since: at2, firstFailedAt: at2 }, seq: ++failureSeq, reason });
4407
4447
  }
4408
4448
  async function updateState(patch = {}) {
4409
4449
  const pending = await countFresh(collection, { syncStatus: "pending" });
@@ -4601,28 +4641,70 @@ function createRegisterOutbox(options) {
4601
4641
  };
4602
4642
  }
4603
4643
 
4604
- // src/outbox/use-order-outbox.ts
4605
- import { useEffect as useEffect4, useMemo as useMemo4, useRef as useRef4, useState as useState4 } from "react";
4644
+ // src/outbox/use-register-outbox.ts
4645
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
4606
4646
  var idle = { pending: 0, sending: false };
4607
- var noneStuck = [];
4608
- function useOrderOutbox(options) {
4609
- const { storeKey, deviceId } = options;
4647
+ function useRegisterOutbox(options) {
4648
+ const { commands, deviceId } = options;
4610
4649
  const latest = useRef4(options);
4611
4650
  latest.current = options;
4612
4651
  const current = useRef4(null);
4613
- const openingError = useRef4(null);
4614
- const loggedMismatches = useRef4(/* @__PURE__ */ new Set());
4615
- const [orders, setOrders] = useState4(null);
4616
4652
  const [state, setState] = useState4(idle);
4617
- const [recent, setRecent] = useState4([]);
4618
- const savesInFlight = useRef4(0);
4619
- const [savesInFlightCount, setSavesInFlightCount] = useState4(0);
4620
4653
  useEffect4(() => {
4654
+ setState(idle);
4655
+ if (!commands) return;
4656
+ const outbox = createRegisterOutbox({
4657
+ collection: commands,
4658
+ deviceId,
4659
+ transport: latest.current.transport(),
4660
+ isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : void 0,
4661
+ onResult: latest.current.onResult ? async (command, result) => {
4662
+ await latest.current.onResult?.(command, result);
4663
+ } : void 0,
4664
+ backendNotFound: latest.current.backendNotFound
4665
+ });
4666
+ current.current = { commands, deviceId, outbox };
4667
+ const status = outbox.state$.subscribe(setState);
4668
+ outbox.start();
4669
+ return () => {
4670
+ outbox.stop();
4671
+ status.unsubscribe();
4672
+ current.current = null;
4673
+ setState(idle);
4674
+ };
4675
+ }, [commands, deviceId]);
4676
+ const opened = current.current;
4677
+ return {
4678
+ state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
4679
+ async flush() {
4680
+ const active = current.current;
4681
+ if (active?.commands === commands && active.deviceId === deviceId) await active.outbox.flush();
4682
+ }
4683
+ };
4684
+ }
4685
+
4686
+ // src/outbox/use-order-outbox.ts
4687
+ import { useEffect as useEffect5, useMemo as useMemo4, useRef as useRef5, useState as useState5 } from "react";
4688
+ var idle2 = { pending: 0, sending: false };
4689
+ var noneStuck = [];
4690
+ function useOrderOutbox(options) {
4691
+ const { storeKey, deviceId } = options;
4692
+ const latest = useRef5(options);
4693
+ latest.current = options;
4694
+ const current = useRef5(null);
4695
+ const openingError = useRef5(null);
4696
+ const loggedMismatches = useRef5(/* @__PURE__ */ new Set());
4697
+ const [orders, setOrders] = useState5(null);
4698
+ const [state, setState] = useState5(idle2);
4699
+ const [recent, setRecent] = useState5([]);
4700
+ const savesInFlight = useRef5(0);
4701
+ const [savesInFlightCount, setSavesInFlightCount] = useState5(0);
4702
+ useEffect5(() => {
4621
4703
  let active = true;
4622
4704
  let dispose;
4623
4705
  openingError.current = null;
4624
4706
  setOrders(null);
4625
- setState(idle);
4707
+ setState(idle2);
4626
4708
  setRecent([]);
4627
4709
  if (!storeKey) return;
4628
4710
  void latest.current.open(storeKey).then(async (store) => {
@@ -4660,7 +4742,7 @@ function useOrderOutbox(options) {
4660
4742
  dispose?.();
4661
4743
  };
4662
4744
  }, [storeKey, deviceId]);
4663
- useEffect4(() => {
4745
+ useEffect5(() => {
4664
4746
  latest.current.onBusy?.(state.sending);
4665
4747
  return () => latest.current.onBusy?.(false);
4666
4748
  }, [state.sending]);
@@ -4669,7 +4751,7 @@ function useOrderOutbox(options) {
4669
4751
  const stuckCommandIds = useMemo4(() => stuckKey ? stuckKey.split(",") : noneStuck, [stuckKey]);
4670
4752
  return {
4671
4753
  orders: ready ? orders : null,
4672
- state: ready ? state : idle,
4754
+ state: ready ? state : idle2,
4673
4755
  recent: ready ? recent : [],
4674
4756
  async flush() {
4675
4757
  const opened = current.current;
@@ -5040,6 +5122,7 @@ export {
5040
5122
  useCurrencyCode,
5041
5123
  useCurrencyFormatter,
5042
5124
  useOrderOutbox,
5125
+ useRegisterOutbox,
5043
5126
  useRegisterSession,
5044
5127
  useSale,
5045
5128
  useStoreSettings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.0.0-next.0",
3
+ "version": "3.0.0-next.2",
4
4
  "type": "module",
5
5
  "description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
6
6
  "main": "./dist/index.js",
@@ -27,20 +27,20 @@
27
27
  "directory": "packages/pos"
28
28
  },
29
29
  "peerDependencies": {
30
- "@tallyui/core": "^3.0.0-next.0",
30
+ "@tallyui/core": "^3.0.0-next.2",
31
31
  "react": ">=18",
32
32
  "rxdb": "~17.5.0",
33
33
  "rxjs": ">=7"
34
34
  },
35
35
  "devDependencies": {
36
- "@tallyui/core": "3.0.0-next.0",
36
+ "@tallyui/core": "3.0.0-next.2",
37
37
  "@types/react": ">=18",
38
38
  "react": "^19.2.4",
39
39
  "rxdb": "17.5.0",
40
40
  "rxjs": "7.8.2",
41
- "@tallyui/connector-medusa": "3.0.0-next.0",
42
- "@tallyui/connector-woocommerce": "3.0.0-next.0",
43
- "@tallyui/database": "3.0.0-next.0"
41
+ "@tallyui/connector-medusa": "3.0.0-next.2",
42
+ "@tallyui/connector-woocommerce": "3.0.0-next.2",
43
+ "@tallyui/database": "3.0.0-next.2"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -58,8 +58,8 @@ export type { SaleStage, CatalogueEntry } from './sale';
58
58
  export { uuidv7, finalizeOrder, toOrderCreateEnvelope, UnsupportedOrderVersionError, posOrderSchema, posOrderCollection, addPosOrderCollection, PosOrderOpenClosedError, posOrdersLogger, getDeviceId, needsAttention, sameSale, OrderContentMismatchError } from './pos-order';
59
59
  export type { PosOrderSyncStatus, PosOrderLine, PosOrderPayment, PosOrderLocalWarning, PosOrderServerFailures, PosOrder, FinalizeOptions } from './pos-order';
60
60
  export { createHttpCommandTransport, createOrderOutbox, useOrderOutbox, outboxLogger } from './outbox';
61
- export { createRegisterOutbox } from './outbox';
62
- export type { RegisterOutboxOptions, RegisterOutbox } from './outbox';
61
+ export { createRegisterOutbox, useRegisterOutbox } from './outbox';
62
+ export type { RegisterOutboxOptions, RegisterOutbox, UseRegisterOutboxOptions, UseRegisterOutboxResult } from './outbox';
63
63
  export { createBackendNotFound } from './outbox/backend-not-found';
64
64
  export type { BackendNotFound } from './outbox/backend-not-found';
65
65
  export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
@@ -4,6 +4,8 @@ export { createOrderOutbox } from './order-outbox';
4
4
  export type { OrderOutboxOptions, OrderOutbox } from './order-outbox';
5
5
  export { createRegisterOutbox } from './register-outbox';
6
6
  export type { RegisterOutboxOptions, RegisterOutbox } from './register-outbox';
7
+ export { useRegisterOutbox } from './use-register-outbox';
8
+ export type { UseRegisterOutboxOptions, UseRegisterOutboxResult } from './use-register-outbox';
7
9
  export type { TransportOutcome, CommandTransport, OutboxState } from './types';
8
10
  export { useOrderOutbox, outboxLogger } from './use-order-outbox';
9
11
  export type { UseOrderOutboxOptions, UseOrderOutboxResult } from './use-order-outbox';
@@ -104,8 +104,9 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
104
104
  // clock (`pausedAt`), and the next response the store answers, of any kind and for any order, resumes them all,
105
105
  // moving `since` on by the offline gap: a pause is about the connection. So `since` is when the clock would have
106
106
  // started had there been no offline gaps: now minus its answered time (while paused, as of the pause). Progress,
107
- // or the order leaving pending, clears it.
108
- const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
107
+ // or the order leaving pending, clears it. `firstFailedAt` is the wall-clock time of the clock's first answered
108
+ // failure; a pause never moves it, and a clock restored from stored `serverFailures` has none (it is memory only).
109
+ const clocks = new Map<string, { since: number; firstFailedAt?: number; pausedAt?: number; seq: number; reason: string }>();
109
110
  let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
110
111
  // Timer runs alternate between the batch turn (the batch, or the walk's probe) and the isolated turn (one due
111
112
  // isolated order); a turn with nothing to send gives way to the other, so neither side can starve the other.
@@ -119,8 +120,11 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
119
120
  const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
120
121
  if (!stuck.length) return undefined;
121
122
  return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
123
+ ...(stuck.every(([, clock]) => clock.firstFailedAt !== undefined)
124
+ ? { firstFailedAt: Math.min(...stuck.map(([, clock]) => clock.firstFailedAt!)) } : {}),
122
125
  reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
123
- orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
126
+ orders: stuck.map(([commandId, { since, firstFailedAt, reason }]) => ({ commandId, since, reason,
127
+ ...(firstFailedAt === undefined ? {} : { firstFailedAt }) })) };
124
128
  }
125
129
 
126
130
  // An isolated order is due once its backoff has passed, or when the clock was set back further than any backoff.
@@ -166,6 +170,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
166
170
  const bare = ({ serverFailures, _rev, _meta, ...data }: RxDocumentData<PosOrder>) => data;
167
171
  const failuresOnly = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
168
172
  !deepEqual(before.serverFailures, after.serverFailures) && deepEqual(bare(before), bare(after));
173
+ // The outbox's own write of an order's sent form (#300): the form frozen, and sentVersion stored on a first send.
174
+ // Not new work (medusapos/app#160). A changed sentVersion (a downgrade) is not this write.
175
+ const recordsSentForm = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
176
+ deepEqual(bare({ ...freezeSentForm(before), sentVersion: before.sentVersion ?? after.sentVersion } as RxDocumentData<PosOrder>), bare(after));
169
177
 
170
178
  // Every read bypasses the query cache. RxDB 17 fixed RxDB 16.21.1's bug 4 (rxdb#7067);
171
179
  // readFresh, countFresh and watchFresh remain correct public API.
@@ -186,7 +194,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
186
194
  // floor for the retry: the store's Retry-After, or an isolated order's own backoff.
187
195
  function serverFailed(orders: PosOrder[], alone: 'probe' | 'isolated' | undefined, reason: string, retryAfterMs?: number) {
188
196
  const at = now();
189
- for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
197
+ for (const { commandId } of orders) clocks.set(commandId, { ...(clocks.get(commandId) ?? { since: at, firstFailedAt: at }), seq: ++failureSeq, reason });
190
198
  const id = orders[0].commandId;
191
199
  if (alone === 'isolated') {
192
200
  isolate(id, reason, retryAfterMs);
@@ -482,7 +490,8 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
482
490
  if (!subscription) subscription = collection.$.subscribe((event) => {
483
491
  // An UPDATE without previousDocumentData counts as a change: a needless send costs a request, a skipped one a sale.
484
492
  if (event.documentData?.syncStatus === 'pending' && (event.operation === 'INSERT' || event.operation === 'UPDATE' &&
485
- !(event.previousDocumentData !== undefined && failuresOnly(event.previousDocumentData, event.documentData)))) {
493
+ !(event.previousDocumentData !== undefined && (failuresOnly(event.previousDocumentData, event.documentData)
494
+ || recordsSentForm(event.previousDocumentData, event.documentData))))) {
486
495
  insertedDuringRun = true;
487
496
  flush().catch(() => {});
488
497
  }
@@ -73,19 +73,20 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
73
73
  // of a register's ledger holds up the commands behind it, by design, since register facts apply in `seq` order.
74
74
  // In memory only: a restart starts with no clocks, and the first answered failure after it starts them afresh
75
75
  // (accepted: register commands are few, and a restart re-sends at once).
76
- const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
76
+ const clocks = new Map<string, { since: number; firstFailedAt: number; pausedAt?: number; seq: number; reason: string }>();
77
77
  let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
78
78
 
79
79
  function stuckState(): OutboxState['stuck'] {
80
80
  const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
81
81
  if (!stuck.length) return undefined;
82
82
  return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
83
+ firstFailedAt: Math.min(...stuck.map(([, clock]) => clock.firstFailedAt)),
83
84
  reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
84
- orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
85
+ orders: stuck.map(([commandId, { since, firstFailedAt, reason }]) => ({ commandId, since, firstFailedAt, reason })) };
85
86
  }
86
87
  function serverFailed(commands: RegisterCommand[], reason: string) {
87
88
  const at = now();
88
- for (const { commandId } of commands) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
89
+ for (const { commandId } of commands) clocks.set(commandId, { ...(clocks.get(commandId) ?? { since: at, firstFailedAt: at }), seq: ++failureSeq, reason });
89
90
  }
90
91
 
91
92
  async function updateState(patch: Partial<OutboxState> = {}) {
@@ -27,8 +27,11 @@ export interface OutboxState {
27
27
  /** Set while the store (not offline) has kept failing some orders for 15 minutes, each on its own clock. Those
28
28
  * orders stay pending and keep retrying. `orders` has each order's own entry: its `since` is when its clock
29
29
  * would have started had there been no offline gaps (now minus its answered time; while offline, as of the
30
- * moment the clock paused), and its latest `reason`. `since` is the earliest of theirs; `reason` the latest. */
31
- stuck?: { commandIds: string[]; since: number; reason: string; orders: { commandId: string; since: number; reason: string }[] };
30
+ * moment the clock paused), and its latest `reason`. `since` is the earliest of theirs, virtual for the threshold;
31
+ * `reason` the latest. `firstFailedAt` is the earliest real wall-clock first failure, only when every stuck clock
32
+ * has one. Each order entry has its own real wall-clock `firstFailedAt`. Both are in memory only, absent after restart. */
33
+ stuck?: { commandIds: string[]; since: number; firstFailedAt?: number; reason: string;
34
+ orders: { commandId: string; since: number; firstFailedAt?: number; reason: string }[] };
32
35
  /** Set after 3 consecutive 404 answers (counted across the outboxes sharing one BackendNotFound): the store address
33
36
  * may be wrong, or the store's plugin isn't installed or is switched off. The outbox keeps retrying; the next answer
34
37
  * that isn't a 404 clears it (offline changes nothing). `since` is when the first of those 404s arrived. */
@@ -0,0 +1,55 @@
1
+ import type { RegisterCommandEnvelope } from '@tallyui/core';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import type { RegisterCommandCollection } from '../register/register-commands';
4
+ import { createRegisterOutbox, type RegisterOutboxOptions } from './register-outbox';
5
+ import type { CommandTransport, OutboxState } from './types';
6
+
7
+ const idle: OutboxState = { pending: 0, sending: false };
8
+
9
+ export interface UseRegisterOutboxOptions {
10
+ /** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
11
+ commands: RegisterCommandCollection | null;
12
+ /** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
13
+ transport(): CommandTransport<RegisterCommandEnvelope>;
14
+ /** The device id sent on every command. A change restarts the outbox. */
15
+ deviceId: string;
16
+ /** Passed through to createRegisterOutbox (same meaning). The latest function is called each time, so a new one does not restart the outbox. */
17
+ isEnabled?: () => boolean;
18
+ /** Called with each result. The latest function is called each time; a new one does not restart the outbox. */
19
+ onResult?: RegisterOutboxOptions['onResult'];
20
+ backendNotFound?: RegisterOutboxOptions['backendNotFound'];
21
+ }
22
+ export interface UseRegisterOutboxResult {
23
+ /** The outbox state, or idle ({ pending: 0, sending: false }) while there is no collection. */
24
+ state: OutboxState;
25
+ /** Sends pending till updates; does nothing while there is no collection. */
26
+ flush(): Promise<void>;
27
+ }
28
+
29
+ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegisterOutboxResult {
30
+ const { commands, deviceId } = options;
31
+ const latest = useRef(options);
32
+ latest.current = options;
33
+ const current = useRef<{ commands: RegisterCommandCollection; deviceId: string; outbox: ReturnType<typeof createRegisterOutbox> } | null>(null);
34
+ const [state, setState] = useState<OutboxState>(idle);
35
+
36
+ useEffect(() => {
37
+ setState(idle);
38
+ if (!commands) return;
39
+ const outbox = createRegisterOutbox({ collection: commands, deviceId, transport: latest.current.transport(),
40
+ isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : undefined,
41
+ onResult: latest.current.onResult ? async (command, result) => { await latest.current.onResult?.(command, result); } : undefined,
42
+ backendNotFound: latest.current.backendNotFound });
43
+ current.current = { commands, deviceId, outbox };
44
+ const status = outbox.state$.subscribe(setState);
45
+ outbox.start();
46
+ return () => { outbox.stop(); status.unsubscribe(); current.current = null; setState(idle); };
47
+ }, [commands, deviceId]);
48
+
49
+ const opened = current.current;
50
+ return { state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
51
+ async flush() {
52
+ const active = current.current;
53
+ if (active?.commands === commands && active.deviceId === deviceId) await active.outbox.flush();
54
+ } };
55
+ }
@@ -8,11 +8,11 @@ export function withPricingContext(context: SyncContext, settings: StoreSettings
8
8
  }
9
9
 
10
10
  /**
11
- * `<TaxProvider>`'s props from the same settings, so its tax inclusivity and rounding match the store's. `custom`
11
+ * `<TaxProvider>`'s props from the same settings, so its tax inclusivity, rounding and rate codes match the store's. `custom`
12
12
  * rounding passes none, the default (#287). An explicit `rounding` prop after the spread still wins, since JSX
13
13
  * applies props in order; so `<TaxProvider {...taxProviderProps(s)} rounding={undefined}>` wipes the store's rounding.
14
14
  */
15
15
  export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
16
16
  const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
17
- return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }) };
17
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes }) };
18
18
  }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { errorKind, SignInError } from '@tallyui/core';
2
3
  import type { StoreSettings, StoreSettingsChoice, StoreSettingsChoices, TaxRounding } from '@tallyui/core';
3
4
  import { resolveStoreSettings } from './resolve-store-settings';
4
5
  import type { ResolveStoreSettingsOptions } from './resolve-store-settings';
@@ -7,19 +8,39 @@ export type StoreSettingsState =
7
8
  | { state: 'loading' }
8
9
  | { state: 'ready'; settings: StoreSettings; choice?: StoreSettingsChoice }
9
10
  | { state: 'choose'; choices: StoreSettingsChoices; initial?: StoreSettingsChoice; choose: (choice: StoreSettingsChoice) => void }
10
- | { state: 'error'; error: unknown; retry: () => void }
11
+ | { state: 'error'; error: unknown; retry: () => void; nextRetryAt?: number }
11
12
  | { state: 'unsupported' };
12
13
 
13
14
  // One object, so re-entering loading while already loading doesn't re-render.
14
15
  const LOADING: StoreSettingsState = { state: 'loading' };
15
16
 
16
- // The store's taxRounding (#324): the context's capabilities, else one read of the connector's. A failure or no
17
- // value leaves the field out, the default rounding; so does a connector without `capabilities`, with no read.
17
+ export class StoreCapabilitiesUnavailableError extends Error {
18
+ constructor(cause?: unknown) {
19
+ super('Store capabilities unavailable', { cause });
20
+ this.name = 'StoreCapabilitiesUnavailableError';
21
+ }
22
+ }
23
+
24
+ // Exponential retry for unknown rounding: 5 seconds, doubling up to 5 minutes.
25
+ const INITIAL_RETRY_MS = 5_000;
26
+ const MAX_RETRY_MS = 5 * 60_000;
27
+
28
+ // The store's taxRounding (#324): the context's capabilities, else one read of the connector's.
18
29
  function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): Promise<TaxRounding | undefined> {
19
30
  if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
20
31
  if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(undefined);
21
32
  const read = connector.capabilities;
22
- return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => undefined);
33
+ return Promise.resolve().then(() => read(context)).then(
34
+ (capabilities) => {
35
+ if (!capabilities) throw new StoreCapabilitiesUnavailableError();
36
+ return capabilities.taxRounding;
37
+ },
38
+ (cause) => {
39
+ // The till must fix its own credentials or software; retrying cannot help.
40
+ if (errorKind(cause) === 'till' || (cause instanceof SignInError && cause.code === 'invalid_credentials')) throw cause;
41
+ throw new StoreCapabilitiesUnavailableError(cause);
42
+ },
43
+ );
23
44
  }
24
45
 
25
46
  /**
@@ -29,6 +50,10 @@ function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): P
29
50
  * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
30
51
  * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
31
52
  * rounding already known: no later change of `settings` holds a sale.
53
+ * Unknown rounding keeps settings unresolved and retries by itself; show "Can't reach the store's settings yet. Retrying…" while `nextRetryAt` is set.
54
+ * A till-class or invalid-credentials sign-in error is not retried by itself, so the app prompts to sign in or update the till.
55
+ * An app shows a sign-in prompt when the `error` state's error is a `ConnectorUnauthorizedError` with `code: 'unauthorized'`
56
+ * or a `SignInError` with `code: 'invalid_credentials'`, and an update prompt for `code: 'till_update_required'`.
32
57
  *
33
58
  * ```tsx
34
59
  * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
@@ -44,6 +69,8 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
44
69
  const optionsRef = useRef(options);
45
70
  optionsRef.current = options;
46
71
  const requestRef = useRef(0);
72
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
73
+ const retryDelayRef = useRef(INITIAL_RETRY_MS);
47
74
  const [state, setState] = useState<StoreSettingsState>(LOADING);
48
75
 
49
76
  // `from`, when given, is the request that created the `choose`/`retry` calling this: a call
@@ -52,6 +79,7 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
52
79
  // a pick doesn't read it again.
53
80
  const resolve = useCallback((choice?: StoreSettingsChoice, from?: number, known?: Promise<TaxRounding | undefined>) => {
54
81
  if (from !== undefined && from !== requestRef.current) return;
82
+ clearTimeout(retryTimerRef.current);
55
83
  const request = ++requestRef.current;
56
84
  const current = () => request === requestRef.current;
57
85
  setState(LOADING);
@@ -59,6 +87,7 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
59
87
  Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
60
88
  ([result, taxRounding]) => {
61
89
  if (!current()) return;
90
+ retryDelayRef.current = INITIAL_RETRY_MS;
62
91
  if (result.status === 'ready')
63
92
  setState({ state: 'ready', settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
64
93
  else if (result.status === 'choose')
@@ -67,7 +96,15 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
67
96
  },
68
97
  // A retry keeps the choice that failed: a new pick is saved only once it resolves.
69
98
  (error: unknown) => {
70
- if (current()) setState({ state: 'error', error, retry: () => resolve(choice, request) });
99
+ if (!current()) return;
100
+ const retry = () => resolve(choice, request);
101
+ if (error instanceof StoreCapabilitiesUnavailableError) {
102
+ const delay = retryDelayRef.current;
103
+ const nextRetryAt = Date.now() + delay;
104
+ retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
105
+ retryTimerRef.current = setTimeout(retry, delay);
106
+ setState({ state: 'error', error, retry, nextRetryAt });
107
+ } else setState({ state: 'error', error, retry });
71
108
  },
72
109
  );
73
110
  }, []);
@@ -76,6 +113,8 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
76
113
  resolve();
77
114
  // Discards the in-flight result on a store switch or unmount.
78
115
  return () => {
116
+ clearTimeout(retryTimerRef.current);
117
+ retryDelayRef.current = INITIAL_RETRY_MS;
79
118
  requestRef.current += 1;
80
119
  };
81
120
  }, [connector, context, resolve]);