@teincfood/core 0.10.17 → 0.10.19

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.
@@ -36,7 +36,7 @@ export declare const sellerService: {
36
36
  }>;
37
37
  };
38
38
  export declare const kioskService: {
39
- assignKioskOrderToRider: (_orderId: string, _riderId: string) => Promise<unknown>;
39
+ assignKioskOrderToRider: (_orderId: string, _riderId: string, _idempotencyKey?: string) => Promise<unknown>;
40
40
  };
41
41
  export declare const menuService: {
42
42
  fetchBusinessMenuItems: (_businessId: string, _page: number) => Promise<unknown>;
@@ -54,7 +54,7 @@ export const sellerService = {
54
54
  };
55
55
  // kiosk.service
56
56
  export const kioskService = {
57
- assignKioskOrderToRider: async (_orderId, _riderId) => {
57
+ assignKioskOrderToRider: async (_orderId, _riderId, _idempotencyKey) => {
58
58
  notImpl("kioskService.assignKioskOrderToRider");
59
59
  },
60
60
  };
package/dist/index.d.ts CHANGED
@@ -59,7 +59,7 @@ export * from "./sync/command-builder";
59
59
  export { commandQueueService } from "./sync/command-queue.service";
60
60
  export { buildCommand } from "./sync/command-builder";
61
61
  export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
62
- export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
62
+ export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, type DeviceLeaseFailure, } from "./sync/device-lease.service";
63
63
  export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
64
64
  export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, type StartupReadiness, } from "./sync/startup-readiness";
65
65
  export { useStartupReadiness, type UseStartupReadinessOptions, type UseStartupReadinessResult, } from "./hooks/startup-readiness.hooks";
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ export * from "./sync/command-builder";
61
61
  export { commandQueueService } from "./sync/command-queue.service";
62
62
  export { buildCommand } from "./sync/command-builder";
63
63
  export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
64
- export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
64
+ export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, } from "./sync/device-lease.service";
65
65
  export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
66
66
  export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, } from "./sync/startup-readiness";
67
67
  export { useStartupReadiness, } from "./hooks/startup-readiness.hooks";
@@ -141,8 +141,12 @@ export const orderRepository = {
141
141
  commandType: "delivery:assign",
142
142
  businessId: input.businessId,
143
143
  entityId: input.orderId,
144
- payload: { rider_id: input.riderId },
144
+ payload: { rider_id: input.riderId, idempotency_key: "" },
145
145
  });
146
+ // One user action = one stable key. Replays of this command (timeout,
147
+ // reconnect, LAN relay) must converge on the same delivery instead of
148
+ // creating another one.
149
+ command.payload.idempotency_key = command.command_id;
146
150
  const events = await syncEngine.execute(command);
147
151
  const event = events[0];
148
152
  if (!event) {
@@ -4,6 +4,25 @@
4
4
  * KV persists device_number per businessId.
5
5
  */
6
6
  import { getAppInstanceIdAsync } from "../adapters/devices";
7
+ /**
8
+ * Why the last device-lease attempt failed. Surfaced by startup readiness so a
9
+ * plan/terminal limit is not reported as "offline data incomplete".
10
+ */
11
+ export interface DeviceLeaseFailure {
12
+ code: "TERMINAL_LIMIT_REACHED" | "DEVICE_LIMIT_REACHED" | "AUTH_REQUIRED" | "OFFLINE" | "LEASE_FAILED";
13
+ message: string;
14
+ status?: number;
15
+ limit?: number;
16
+ current?: number;
17
+ }
18
+ export declare function getLastLeaseFailure(businessId: string): DeviceLeaseFailure | null;
19
+ /**
20
+ * Normalise the HTTP-client error into a user-presentable lease failure.
21
+ * Desktop injects its own ApiError shape; mobile/core may inject another, so
22
+ * inspect both the error and the backend `{ error: { code, message, details } }`
23
+ * envelope instead of relying on one class.
24
+ */
25
+ export declare function classifyDeviceLeaseFailure(error: unknown): DeviceLeaseFailure;
7
26
  export declare function getDeviceNumber(businessId: string): string | null;
8
27
  export declare function isOnlineOnly(businessId: string): boolean;
9
28
  export declare function ensureLease(businessId: string): Promise<string | null>;
@@ -10,6 +10,80 @@ import { getAppInstanceIdAsync } from "../adapters/devices";
10
10
  import { getConnectivityState } from "../adapters/connectivity";
11
11
  const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
12
12
  const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
+ const leaseFailures = new Map();
14
+ export function getLastLeaseFailure(businessId) {
15
+ return leaseFailures.get(businessId) ?? null;
16
+ }
17
+ function rememberLeaseFailure(businessId, failure) {
18
+ leaseFailures.set(businessId, failure);
19
+ }
20
+ function clearLeaseFailure(businessId) {
21
+ leaseFailures.delete(businessId);
22
+ }
23
+ function asRecord(value) {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value)
25
+ ? value
26
+ : null;
27
+ }
28
+ function asNumber(value) {
29
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
30
+ }
31
+ /**
32
+ * Normalise the HTTP-client error into a user-presentable lease failure.
33
+ * Desktop injects its own ApiError shape; mobile/core may inject another, so
34
+ * inspect both the error and the backend `{ error: { code, message, details } }`
35
+ * envelope instead of relying on one class.
36
+ */
37
+ export function classifyDeviceLeaseFailure(error) {
38
+ const e = (asRecord(error) ?? {});
39
+ const responseData = asRecord(e.data) ?? asRecord(e.response?.data);
40
+ const errorBody = asRecord(responseData?.error);
41
+ const details = asRecord(errorBody?.details);
42
+ const status = asNumber(e.status);
43
+ const code = typeof errorBody?.code === "string"
44
+ ? errorBody.code
45
+ : typeof e.code === "string"
46
+ ? e.code
47
+ : undefined;
48
+ const message = typeof errorBody?.message === "string"
49
+ ? errorBody.message
50
+ : typeof e.message === "string"
51
+ ? e.message
52
+ : "";
53
+ const limit = asNumber(details?.limit);
54
+ const current = asNumber(details?.current);
55
+ if (status === 402 || code === "TERMINAL_LIMIT_REACHED") {
56
+ return {
57
+ code: "TERMINAL_LIMIT_REACHED",
58
+ message: message ||
59
+ "This business has reached the number of linked terminals allowed by its plan.",
60
+ status,
61
+ limit,
62
+ current,
63
+ };
64
+ }
65
+ if (status === 429 || code === "DEVICE_LIMIT_REACHED") {
66
+ return {
67
+ code: "DEVICE_LIMIT_REACHED",
68
+ message: message || "This business has reached the maximum number of linked terminals.",
69
+ status,
70
+ };
71
+ }
72
+ if (status === 401 ||
73
+ message.includes("TOKEN_MISSING") ||
74
+ message.includes("No authentication token")) {
75
+ return {
76
+ code: "AUTH_REQUIRED",
77
+ message: message || "Sign-in is still finishing. Retry in a moment.",
78
+ status,
79
+ };
80
+ }
81
+ return {
82
+ code: "LEASE_FAILED",
83
+ message: message || "The terminal could not be activated.",
84
+ status,
85
+ };
86
+ }
13
87
  export function getDeviceNumber(businessId) {
14
88
  const v = getSync(LEASE_KEY(businessId));
15
89
  if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
@@ -23,6 +97,7 @@ export async function ensureLease(businessId) {
23
97
  // Offline-first: keep leased DD even offline — if online check/lease fails, return cached
24
98
  const existingSync = getDeviceNumber(businessId);
25
99
  if (existingSync) {
100
+ clearLeaseFailure(businessId);
26
101
  // Opportunistically refresh touch if online, but never discard cached on failure
27
102
  if (getConnectivityState().isOnline) {
28
103
  try {
@@ -48,6 +123,7 @@ export async function ensureLease(businessId) {
48
123
  setSync(LEASE_KEY(businessId), asyncCached);
49
124
  }
50
125
  catch { }
126
+ clearLeaseFailure(businessId);
51
127
  return asyncCached;
52
128
  }
53
129
  }
@@ -55,13 +131,18 @@ export async function ensureLease(businessId) {
55
131
  // No cached DD — must lease online. If offline, keep null (caller blocks POS with clear message)
56
132
  if (!getConnectivityState().isOnline) {
57
133
  syncLogger.debug("DeviceLease", "Offline and no cached DD — need online once", { businessId });
134
+ rememberLeaseFailure(businessId, {
135
+ code: "OFFLINE",
136
+ message: "Connect to the internet once so this terminal can be activated.",
137
+ });
58
138
  return null;
59
139
  }
60
140
  try {
61
141
  const http = getHttpClient();
62
142
  const deviceUuid = await getAppInstanceIdAsync();
63
143
  const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
64
- const data = res.data?.data ?? res.data;
144
+ const payload = res.data;
145
+ const data = payload.data ?? payload;
65
146
  const dn = data.device_number;
66
147
  if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
67
148
  setSync(LEASE_KEY(businessId), dn);
@@ -70,51 +151,57 @@ export async function ensureLease(businessId) {
70
151
  await getItem(LEASE_KEY(businessId));
71
152
  }
72
153
  catch { }
154
+ clearLeaseFailure(businessId);
73
155
  syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
74
156
  return dn;
75
157
  }
158
+ rememberLeaseFailure(businessId, {
159
+ code: "LEASE_FAILED",
160
+ message: "The server did not return a terminal number. Try again.",
161
+ });
76
162
  }
77
163
  catch (e) {
78
- const apiErr = e;
79
- const msg = String(e?.message ?? "");
80
- const isAuth = apiErr?.status === 401 || msg.includes("TOKEN_MISSING") || msg.includes("No authentication token");
81
- if (isAuth) {
82
- syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
83
- // fallback to any async cache that may have appeared concurrently
84
- try {
85
- const fallback = await getItem(LEASE_KEY(businessId));
86
- if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback))
87
- return fallback;
88
- }
89
- catch { }
90
- const again = getDeviceNumber(businessId);
91
- if (again)
92
- return again;
93
- return null;
164
+ const failure = classifyDeviceLeaseFailure(e);
165
+ rememberLeaseFailure(businessId, failure);
166
+ if (failure.code === "TERMINAL_LIMIT_REACHED" || failure.code === "DEVICE_LIMIT_REACHED") {
167
+ syncLogger.warn("DeviceLease", "Terminal limit reached — activation needs a free slot", {
168
+ businessId,
169
+ code: failure.code,
170
+ limit: failure.limit,
171
+ current: failure.current,
172
+ });
173
+ return cachedLeaseFallback(businessId);
94
174
  }
95
- if (apiErr?.status === 429 || msg.includes("DEVICE_LIMIT_REACHED")) {
96
- syncLogger.warn("DeviceLease", "Device limit reached — online only", { businessId });
97
- return null;
175
+ if (failure.code === "AUTH_REQUIRED") {
176
+ syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
177
+ return cachedLeaseFallback(businessId);
98
178
  }
99
- // Network/offline failure while leasing — keep any cached that may exist
179
+ // Network/offline failure while leasing — keep any cached that may exist.
100
180
  syncLogger.warn("DeviceLease", "Lease failed — keep cached if any", {
101
181
  businessId,
102
182
  error: String(e),
103
183
  });
104
- try {
105
- const fallback = await getItem(LEASE_KEY(businessId));
106
- if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
107
- try {
108
- setSync(LEASE_KEY(businessId), fallback);
109
- }
110
- catch { }
111
- return fallback;
184
+ return cachedLeaseFallback(businessId);
185
+ }
186
+ return cachedLeaseFallback(businessId);
187
+ }
188
+ async function cachedLeaseFallback(businessId) {
189
+ try {
190
+ const fallback = await getItem(LEASE_KEY(businessId));
191
+ if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
192
+ try {
193
+ setSync(LEASE_KEY(businessId), fallback);
112
194
  }
195
+ catch { }
196
+ clearLeaseFailure(businessId);
197
+ return fallback;
113
198
  }
114
- catch { }
115
- const again = getDeviceNumber(businessId);
116
- if (again)
117
- return again;
199
+ }
200
+ catch { }
201
+ const again = getDeviceNumber(businessId);
202
+ if (again) {
203
+ clearLeaseFailure(businessId);
204
+ return again;
118
205
  }
119
206
  return null;
120
207
  }
@@ -141,6 +228,7 @@ export async function refreshLease(businessId) {
141
228
  export function clearLeaseForTests(businessId) {
142
229
  removeItemSync(LEASE_KEY(businessId));
143
230
  removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
231
+ clearLeaseFailure(businessId);
144
232
  }
145
233
  export const deviceLeaseService = {
146
234
  getDeviceNumber,
@@ -11,6 +11,8 @@
11
11
  *
12
12
  * Seller-only. Fleet/rider surfaces are fully online and never gate here.
13
13
  */
14
+ import { type DeviceLeaseFailure } from "./device-lease.service";
15
+ import { type ConnectivityState } from "../adapters/connectivity";
14
16
  import type { ReferenceDataKey } from "../reference/types";
15
17
  /**
16
18
  * Reference keys required before the app presents itself as ready to take
@@ -31,6 +33,13 @@ export interface StartupReadiness {
31
33
  * by itself block readiness.
32
34
  */
33
35
  entitlementUnverified: boolean;
36
+ /**
37
+ * Why the terminal lease failed, when `needsLease` is true. Lets the UI show
38
+ * "terminal limit reached" instead of the generic offline-data message.
39
+ */
40
+ leaseError: DeviceLeaseFailure | null;
41
+ /** Connectivity snapshot used for this check. */
42
+ network: ConnectivityState;
34
43
  }
35
44
  /** Evaluate the local cache only (no network). */
36
45
  export declare function getStartupReadiness(businessId: string): Promise<StartupReadiness>;
@@ -15,7 +15,7 @@ import { openSyncDatabase } from "../db/connection";
15
15
  import { getReferenceData } from "../reference/operations";
16
16
  import { referenceSyncService } from "../reference/service";
17
17
  import { evaluateBusinessEntitlements } from "../reference/entitlement-guard";
18
- import { ensureLease } from "./device-lease.service";
18
+ import { ensureLease, getLastLeaseFailure, } from "./device-lease.service";
19
19
  import { getConnectivityState } from "../adapters/connectivity";
20
20
  import { syncLogger } from "../utils/sync-logger";
21
21
  /**
@@ -52,6 +52,7 @@ export async function getStartupReadiness(businessId) {
52
52
  syncLogger.warn("StartupReadiness", "lease check failed", { err: String(e) });
53
53
  }
54
54
  const needsLease = !deviceNumber;
55
+ const leaseError = needsLease ? getLastLeaseFailure(businessId) : null;
55
56
  let entitlementUnverified = missingKeys.includes("subscription:entitlements");
56
57
  if (!entitlementUnverified) {
57
58
  try {
@@ -67,6 +68,8 @@ export async function getStartupReadiness(businessId) {
67
68
  missingKeys,
68
69
  needsLease,
69
70
  entitlementUnverified,
71
+ leaseError,
72
+ network: getConnectivityState(),
70
73
  };
71
74
  }
72
75
  /**
@@ -217,7 +217,12 @@ async function tryRest(businessId, payload, commandType) {
217
217
  const riderId = (payload.rider_id ?? payload.riderId);
218
218
  if (!riderId)
219
219
  return { ok: false, status: 422, code: "MISSING_RIDER", message: "Assign rider job has no rider_id — re-assign from the app" };
220
- await http.post(`/deliveries/assign`, { order_id: orderId, rider_id: riderId });
220
+ const idempotencyKey = (payload.idempotency_key ?? payload.idempotencyKey);
221
+ await http.post(`/deliveries/assign`, {
222
+ order_id: orderId,
223
+ rider_id: riderId,
224
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
225
+ });
221
226
  return { ok: true };
222
227
  }
223
228
  // POST /deliveries/:deliveryId/confirm-handover — resolve the delivery
@@ -84,7 +84,8 @@ export class RestTransport {
84
84
  return this.executeOrderTransition(command, "cancel", timestamp, () => sellerService.cancelOrder(entity_id));
85
85
  }
86
86
  case "delivery:assign": {
87
- const response = await kioskService.assignKioskOrderToRider(entity_id, payload.rider_id);
87
+ const assignPayload = payload;
88
+ const response = await kioskService.assignKioskOrderToRider(entity_id, assignPayload.rider_id, assignPayload.idempotency_key ?? command.command_id);
88
89
  return this.wrapResponse("delivery.assigned", response, entity_id, timestamp, command);
89
90
  }
90
91
  case "delivery:confirm_handover": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.17",
3
+ "version": "0.10.19",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",