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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,7 @@ 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.
211
213
  *
212
214
  * ```tsx
213
215
  * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
package/dist/index.js CHANGED
@@ -319,26 +319,45 @@ 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
327
  var LOADING = { state: "loading" };
328
+ var StoreCapabilitiesUnavailableError = class extends Error {
329
+ constructor(cause) {
330
+ super("Store capabilities unavailable", { cause });
331
+ this.name = "StoreCapabilitiesUnavailableError";
332
+ }
333
+ };
334
+ var INITIAL_RETRY_MS = 5e3;
335
+ var MAX_RETRY_MS = 5 * 6e4;
328
336
  function readTaxRounding({ connector, context }) {
329
337
  if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
330
338
  if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(void 0);
331
339
  const read = connector.capabilities;
332
- return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => void 0);
340
+ return Promise.resolve().then(() => read(context)).then(
341
+ (capabilities) => {
342
+ if (!capabilities) throw new StoreCapabilitiesUnavailableError();
343
+ return capabilities.taxRounding;
344
+ },
345
+ (cause) => {
346
+ throw new StoreCapabilitiesUnavailableError(cause);
347
+ }
348
+ );
333
349
  }
334
350
  function useStoreSettings(options) {
335
351
  const { connector, context } = options;
336
352
  const optionsRef = useRef(options);
337
353
  optionsRef.current = options;
338
354
  const requestRef = useRef(0);
355
+ const retryTimerRef = useRef(void 0);
356
+ const retryDelayRef = useRef(INITIAL_RETRY_MS);
339
357
  const [state, setState] = useState(LOADING);
340
358
  const resolve = useCallback2((choice, from, known) => {
341
359
  if (from !== void 0 && from !== requestRef.current) return;
360
+ clearTimeout(retryTimerRef.current);
342
361
  const request = ++requestRef.current;
343
362
  const current = () => request === requestRef.current;
344
363
  setState(LOADING);
@@ -346,6 +365,7 @@ function useStoreSettings(options) {
346
365
  Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
347
366
  ([result, taxRounding]) => {
348
367
  if (!current()) return;
368
+ retryDelayRef.current = INITIAL_RETRY_MS;
349
369
  if (result.status === "ready")
350
370
  setState({ state: "ready", settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
351
371
  else if (result.status === "choose")
@@ -354,13 +374,23 @@ function useStoreSettings(options) {
354
374
  },
355
375
  // A retry keeps the choice that failed: a new pick is saved only once it resolves.
356
376
  (error) => {
357
- if (current()) setState({ state: "error", error, retry: () => resolve(choice, request) });
377
+ if (!current()) return;
378
+ const retry = () => resolve(choice, request);
379
+ if (error instanceof StoreCapabilitiesUnavailableError) {
380
+ const delay = retryDelayRef.current;
381
+ const nextRetryAt = Date.now() + delay;
382
+ retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
383
+ retryTimerRef.current = setTimeout(retry, delay);
384
+ setState({ state: "error", error, retry, nextRetryAt });
385
+ } else setState({ state: "error", error, retry });
358
386
  }
359
387
  );
360
388
  }, []);
361
389
  useEffect(() => {
362
390
  resolve();
363
391
  return () => {
392
+ clearTimeout(retryTimerRef.current);
393
+ retryDelayRef.current = INITIAL_RETRY_MS;
364
394
  requestRef.current += 1;
365
395
  };
366
396
  }, [connector, context, resolve]);
@@ -3980,6 +4010,7 @@ function createOrderOutbox(options) {
3980
4010
  }
3981
4011
  const bare = ({ serverFailures, _rev, _meta, ...data }) => data;
3982
4012
  const failuresOnly = (before, after) => !deepEqual2(before.serverFailures, after.serverFailures) && deepEqual2(bare(before), bare(after));
4013
+ const recordsSentForm = (before, after) => deepEqual2(bare({ ...freezeSentForm(before), sentVersion: before.sentVersion ?? after.sentVersion }), bare(after));
3983
4014
  async function updateState(patch = {}) {
3984
4015
  const pending = await countFresh(collection, { syncStatus: "pending" });
3985
4016
  const rejected = await countFresh(collection, { syncStatus: "rejected" });
@@ -4311,7 +4342,7 @@ function createOrderOutbox(options) {
4311
4342
  stopped = false;
4312
4343
  watchBackendMissing();
4313
4344
  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)))) {
4345
+ if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE" && !(event.previousDocumentData !== void 0 && (failuresOnly(event.previousDocumentData, event.documentData) || recordsSentForm(event.previousDocumentData, event.documentData))))) {
4315
4346
  insertedDuringRun = true;
4316
4347
  flush().catch(() => {
4317
4348
  });
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.1",
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.1",
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.1",
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.1",
42
+ "@tallyui/connector-woocommerce": "3.0.0-next.1",
43
+ "@tallyui/database": "3.0.0-next.1"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
@@ -166,6 +166,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
166
166
  const bare = ({ serverFailures, _rev, _meta, ...data }: RxDocumentData<PosOrder>) => data;
167
167
  const failuresOnly = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
168
168
  !deepEqual(before.serverFailures, after.serverFailures) && deepEqual(bare(before), bare(after));
169
+ // The outbox's own write of an order's sent form (#300): the form frozen, and sentVersion stored on a first send.
170
+ // Not new work (medusapos/app#160). A changed sentVersion (a downgrade) is not this write.
171
+ const recordsSentForm = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
172
+ deepEqual(bare({ ...freezeSentForm(before), sentVersion: before.sentVersion ?? after.sentVersion } as RxDocumentData<PosOrder>), bare(after));
169
173
 
170
174
  // Every read bypasses the query cache. RxDB 17 fixed RxDB 16.21.1's bug 4 (rxdb#7067);
171
175
  // readFresh, countFresh and watchFresh remain correct public API.
@@ -482,7 +486,8 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
482
486
  if (!subscription) subscription = collection.$.subscribe((event) => {
483
487
  // An UPDATE without previousDocumentData counts as a change: a needless send costs a request, a skipped one a sale.
484
488
  if (event.documentData?.syncStatus === 'pending' && (event.operation === 'INSERT' || event.operation === 'UPDATE' &&
485
- !(event.previousDocumentData !== undefined && failuresOnly(event.previousDocumentData, event.documentData)))) {
489
+ !(event.previousDocumentData !== undefined && (failuresOnly(event.previousDocumentData, event.documentData)
490
+ || recordsSentForm(event.previousDocumentData, event.documentData))))) {
486
491
  insertedDuringRun = true;
487
492
  flush().catch(() => {});
488
493
  }
@@ -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
  }
@@ -7,19 +7,35 @@ export type StoreSettingsState =
7
7
  | { state: 'loading' }
8
8
  | { state: 'ready'; settings: StoreSettings; choice?: StoreSettingsChoice }
9
9
  | { state: 'choose'; choices: StoreSettingsChoices; initial?: StoreSettingsChoice; choose: (choice: StoreSettingsChoice) => void }
10
- | { state: 'error'; error: unknown; retry: () => void }
10
+ | { state: 'error'; error: unknown; retry: () => void; nextRetryAt?: number }
11
11
  | { state: 'unsupported' };
12
12
 
13
13
  // One object, so re-entering loading while already loading doesn't re-render.
14
14
  const LOADING: StoreSettingsState = { state: 'loading' };
15
15
 
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.
16
+ export class StoreCapabilitiesUnavailableError extends Error {
17
+ constructor(cause?: unknown) {
18
+ super('Store capabilities unavailable', { cause });
19
+ this.name = 'StoreCapabilitiesUnavailableError';
20
+ }
21
+ }
22
+
23
+ // Exponential retry for unknown rounding: 5 seconds, doubling up to 5 minutes.
24
+ const INITIAL_RETRY_MS = 5_000;
25
+ const MAX_RETRY_MS = 5 * 60_000;
26
+
27
+ // The store's taxRounding (#324): the context's capabilities, else one read of the connector's.
18
28
  function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): Promise<TaxRounding | undefined> {
19
29
  if (context.capabilities) return Promise.resolve(context.capabilities.taxRounding);
20
30
  if (!connector.storeSettings || !connector.capabilities) return Promise.resolve(undefined);
21
31
  const read = connector.capabilities;
22
- return Promise.resolve().then(() => read(context)).then((capabilities) => capabilities?.taxRounding, () => undefined);
32
+ return Promise.resolve().then(() => read(context)).then(
33
+ (capabilities) => {
34
+ if (!capabilities) throw new StoreCapabilitiesUnavailableError();
35
+ return capabilities.taxRounding;
36
+ },
37
+ (cause) => { throw new StoreCapabilitiesUnavailableError(cause); },
38
+ );
23
39
  }
24
40
 
25
41
  /**
@@ -29,6 +45,7 @@ function readTaxRounding({ connector, context }: ResolveStoreSettingsOptions): P
29
45
  * The ready settings carry the store's `taxRounding` from the capabilities, so `taxProviderProps` passes it on (#324).
30
46
  * The capabilities are read beside the settings, before `ready`, so each resolve emits the settings once, with the
31
47
  * rounding already known: no later change of `settings` holds a sale.
48
+ * Unknown rounding keeps settings unresolved and retries by itself; show "Can't reach the store's settings yet. Retrying…" while `nextRetryAt` is set.
32
49
  *
33
50
  * ```tsx
34
51
  * const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
@@ -44,6 +61,8 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
44
61
  const optionsRef = useRef(options);
45
62
  optionsRef.current = options;
46
63
  const requestRef = useRef(0);
64
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
65
+ const retryDelayRef = useRef(INITIAL_RETRY_MS);
47
66
  const [state, setState] = useState<StoreSettingsState>(LOADING);
48
67
 
49
68
  // `from`, when given, is the request that created the `choose`/`retry` calling this: a call
@@ -52,6 +71,7 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
52
71
  // a pick doesn't read it again.
53
72
  const resolve = useCallback((choice?: StoreSettingsChoice, from?: number, known?: Promise<TaxRounding | undefined>) => {
54
73
  if (from !== undefined && from !== requestRef.current) return;
74
+ clearTimeout(retryTimerRef.current);
55
75
  const request = ++requestRef.current;
56
76
  const current = () => request === requestRef.current;
57
77
  setState(LOADING);
@@ -59,6 +79,7 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
59
79
  Promise.all([resolveStoreSettings(optionsRef.current, choice), rounding]).then(
60
80
  ([result, taxRounding]) => {
61
81
  if (!current()) return;
82
+ retryDelayRef.current = INITIAL_RETRY_MS;
62
83
  if (result.status === 'ready')
63
84
  setState({ state: 'ready', settings: taxRounding ? { ...result.settings, taxRounding } : result.settings, choice: result.choice });
64
85
  else if (result.status === 'choose')
@@ -67,7 +88,15 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
67
88
  },
68
89
  // A retry keeps the choice that failed: a new pick is saved only once it resolves.
69
90
  (error: unknown) => {
70
- if (current()) setState({ state: 'error', error, retry: () => resolve(choice, request) });
91
+ if (!current()) return;
92
+ const retry = () => resolve(choice, request);
93
+ if (error instanceof StoreCapabilitiesUnavailableError) {
94
+ const delay = retryDelayRef.current;
95
+ const nextRetryAt = Date.now() + delay;
96
+ retryDelayRef.current = Math.min(delay * 2, MAX_RETRY_MS);
97
+ retryTimerRef.current = setTimeout(retry, delay);
98
+ setState({ state: 'error', error, retry, nextRetryAt });
99
+ } else setState({ state: 'error', error, retry });
71
100
  },
72
101
  );
73
102
  }, []);
@@ -76,6 +105,8 @@ export function useStoreSettings(options: ResolveStoreSettingsOptions): StoreSet
76
105
  resolve();
77
106
  // Discards the in-flight result on a store switch or unmount.
78
107
  return () => {
108
+ clearTimeout(retryTimerRef.current);
109
+ retryDelayRef.current = INITIAL_RETRY_MS;
79
110
  requestRef.current += 1;
80
111
  };
81
112
  }, [connector, context, resolve]);