@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 +33 -3
- package/dist/index.js +107 -24
- package/package.json +6 -6
- package/src/index.ts +2 -2
- package/src/outbox/index.ts +2 -0
- package/src/outbox/order-outbox.ts +14 -5
- package/src/outbox/register-outbox.ts +4 -3
- package/src/outbox/types.ts +5 -2
- package/src/outbox/use-register-outbox.ts +55 -0
- package/src/store-settings/map-store-settings.ts +2 -2
- package/src/store-settings/use-store-settings.ts +44 -5
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
|
|
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
|
|
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(
|
|
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())
|
|
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
|
|
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:
|
|
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 }]) => ({
|
|
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, {
|
|
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, {
|
|
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-
|
|
4605
|
-
import { useEffect as useEffect4,
|
|
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
|
-
|
|
4608
|
-
|
|
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(
|
|
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
|
-
|
|
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 :
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.0.0-next.
|
|
43
|
-
"@tallyui/database": "3.0.0-next.
|
|
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';
|
package/src/outbox/index.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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, {
|
|
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> = {}) {
|
package/src/outbox/types.ts
CHANGED
|
@@ -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
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
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())
|
|
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]);
|