@teincfood/core 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/db/operations.d.ts +8 -0
  2. package/dist/db/operations.js +11 -0
  3. package/dist/domain/orders/service.js +4 -3
  4. package/dist/hooks/analytics.hooks.d.ts +72 -0
  5. package/dist/hooks/analytics.hooks.js +17 -8
  6. package/dist/hooks/dashboard.hooks.d.ts +14 -0
  7. package/dist/hooks/dashboard.hooks.js +14 -7
  8. package/dist/hooks/orders.hooks.js +6 -3
  9. package/dist/hooks/realtime-stubs.js +48 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.js +5 -0
  12. package/dist/reference/api.js +12 -2
  13. package/dist/reference/entitlement-clock.d.ts +39 -0
  14. package/dist/reference/entitlement-clock.js +116 -0
  15. package/dist/reference/entitlement-crypto.d.ts +10 -0
  16. package/dist/reference/entitlement-crypto.js +111 -0
  17. package/dist/reference/entitlements.d.ts +51 -0
  18. package/dist/reference/entitlements.js +124 -0
  19. package/dist/reference/operations.d.ts +7 -0
  20. package/dist/reference/operations.js +26 -0
  21. package/dist/reference/service.js +33 -1
  22. package/dist/repositories/order.repository.js +11 -0
  23. package/dist/sync/command-queue.service.js +13 -13
  24. package/dist/sync/engine.js +18 -9
  25. package/dist/sync/online-gates.d.ts +9 -0
  26. package/dist/sync/online-gates.js +36 -3
  27. package/dist/sync/order-snapshots.service.d.ts +10 -0
  28. package/dist/sync/order-snapshots.service.js +96 -0
  29. package/dist/sync/sync-jobs.service.js +35 -8
  30. package/dist/sync/transition-endpoints.d.ts +9 -1
  31. package/dist/sync/transition-endpoints.js +16 -5
  32. package/dist/sync/transition-errors.d.ts +32 -0
  33. package/dist/sync/transition-errors.js +61 -0
  34. package/dist/sync/transport-rest.d.ts +5 -3
  35. package/dist/sync/transport-rest.js +10 -3
  36. package/dist/utils/error.js +3 -0
  37. package/dist/utils/order-transitions.js +19 -1
  38. package/package.json +1 -1
@@ -7,8 +7,12 @@
7
7
  * ever fulfill them without the backend.
8
8
  */
9
9
  import { getConnectivityState } from "../adapters/connectivity";
10
+ import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
10
11
  /** Command types that require the cloud, unconditionally. */
11
12
  export const ONLINE_REQUIRED = {
13
+ "delivery:assign": "Assign rider requires online",
14
+ "delivery:confirm_handover": "Handover confirmation requires online",
15
+ // Aliases used by the domain/LAN action path (transitionLocalOrder).
12
16
  "order:assign_rider": "Assign rider requires online",
13
17
  "order:confirm_handover": "Handover confirmation requires online",
14
18
  "chat:send": "Chat requires online",
@@ -22,6 +26,8 @@ export const ONLINE_REQUIRED = {
22
26
  "wallet:payout": "Payouts require online",
23
27
  "payment:charge_online": "Online payment requires online",
24
28
  "device:lease": "Device lease requires online (first lease only)",
29
+ "subscription:manage": "Plan changes and checkout require online",
30
+ "subscription:checkout": "Plan checkout requires online",
25
31
  };
26
32
  export function isOnlineCapable() {
27
33
  const s = getConnectivityState();
@@ -36,20 +42,47 @@ export function assertOnlineFor(commandType, payload) {
36
42
  const fixed = ONLINE_REQUIRED[commandType];
37
43
  if (fixed) {
38
44
  if (!isOnlineCapable())
39
- throw new Error(fixed);
45
+ throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, fixed);
40
46
  return;
41
47
  }
42
48
  if (commandType === "order:create" && payload) {
43
49
  const orderType = payload.order_type;
44
50
  if (orderType === "delivery" || payload.delivery_params) {
45
51
  if (!isOnlineCapable())
46
- throw new Error("Delivery orders require online");
52
+ throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, "Delivery orders require online");
47
53
  return;
48
54
  }
49
55
  const method = payload.pos_payment_method;
50
56
  if (typeof method === "string" && method !== "cash") {
51
57
  if (!isOnlineCapable())
52
- throw new Error("Online payment requires online — use cash while offline");
58
+ throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, "Online payment requires online — use cash while offline");
53
59
  }
54
60
  }
55
61
  }
62
+ /**
63
+ * Offline entitlement gate (fail-closed). Host apps evaluate the cached
64
+ * signed envelope via `evaluateEntitlements` and pass `entitled` here for
65
+ * gated commands (menu/member/promo/terminal/dispatch creation, online
66
+ * orders). Expired, stale (> max_offline_days), or unverified => throw.
67
+ * RBAC (`hasCapabilitySync`) stays fail-open (backend 403 backstop);
68
+ * entitlements are the opposite: no proof => no access.
69
+ */
70
+ export function assertEntitledFor(commandType, entitled, evaluationReason) {
71
+ const gated = new Set([
72
+ "order:create_online",
73
+ "menu:create_item",
74
+ "menu:update_item",
75
+ "menu:delete_item",
76
+ "menu:create_category",
77
+ "promo:create",
78
+ "member:invite",
79
+ "member:manage",
80
+ "terminal:link",
81
+ "fleet:dispatch_create",
82
+ ]);
83
+ if (!gated.has(commandType))
84
+ return;
85
+ if (!entitled) {
86
+ throw new TransitionError(TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED, evaluationReason ?? "Subscription required — upgrade or reconnect to verify");
87
+ }
88
+ }
@@ -33,8 +33,18 @@ declare class OrderSnapshotsService {
33
33
  /**
34
34
  * Patch the latest status of a known order inside every list snapshot for the
35
35
  * business, so offline devices see live status changes without a refetch.
36
+ * Also applies delivery linkage (delivery.assigned / delivery.status_changed
37
+ * / delivery.pickup_confirmed): backend emits delivery events WITHOUT an
38
+ * order.status_changed for assign + merchant/rider single-confirms, so
39
+ * without this other terminals keep offering "Assign rider" forever.
36
40
  */
37
41
  private applyOrderStatusToReadModel;
42
+ private businessIdOf;
43
+ private orderIdOf;
44
+ /** delivery.assigned {order_id, rider_id}, entity = delivery id. */
45
+ private applyDeliveryAssignedToReadModel;
46
+ /** delivery.status_changed {order_id, status} / delivery.pickup_confirmed {order_id, rider_confirmed, merchant_confirmed, status}. */
47
+ private applyDeliveryStatusToReadModel;
38
48
  /**
39
49
  * Write through a fetched order list so it is available offline. Used by the
40
50
  * seller and POS order queries. `scope` distinguishes complementary lists
@@ -145,9 +145,21 @@ class OrderSnapshotsService {
145
145
  /**
146
146
  * Patch the latest status of a known order inside every list snapshot for the
147
147
  * business, so offline devices see live status changes without a refetch.
148
+ * Also applies delivery linkage (delivery.assigned / delivery.status_changed
149
+ * / delivery.pickup_confirmed): backend emits delivery events WITHOUT an
150
+ * order.status_changed for assign + merchant/rider single-confirms, so
151
+ * without this other terminals keep offering "Assign rider" forever.
148
152
  */
149
153
  async applyOrderStatusToReadModel(db, event) {
150
154
  const payload = event.payload;
155
+ if (event.event_type === "delivery.assigned") {
156
+ await this.applyDeliveryAssignedToReadModel(db, event);
157
+ return;
158
+ }
159
+ if (event.event_type === "delivery.status_changed" || event.event_type === "delivery.pickup_confirmed") {
160
+ await this.applyDeliveryStatusToReadModel(db, event);
161
+ return;
162
+ }
151
163
  const orderId = payload?.order_id;
152
164
  const toStatus = payload?.to_status ??
153
165
  payload?.status;
@@ -156,6 +168,90 @@ class OrderSnapshotsService {
156
168
  }
157
169
  await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
158
170
  }
171
+ businessIdOf(event) {
172
+ const payload = event.payload;
173
+ return (payload?.business_id ??
174
+ payload?.businessId ??
175
+ null);
176
+ }
177
+ orderIdOf(event) {
178
+ const payload = event.payload;
179
+ return payload?.order_id ?? event.entity_id;
180
+ }
181
+ /** delivery.assigned {order_id, rider_id}, entity = delivery id. */
182
+ async applyDeliveryAssignedToReadModel(db, event) {
183
+ try {
184
+ const payload = event.payload;
185
+ const orderId = this.orderIdOf(event);
186
+ const businessId = this.businessIdOf(event);
187
+ const deliveryId = event.entity_id ?? payload?.delivery_id;
188
+ if (!orderId || !businessId || !deliveryId)
189
+ return;
190
+ try {
191
+ const { updateOrderDelivery } = await import("../db/operations");
192
+ await updateOrderDelivery(db, businessId, orderId, { delivery_id: deliveryId });
193
+ }
194
+ catch { }
195
+ const lists = await getEntitySnapshotsByType(db, "seller_orders", businessId);
196
+ for (const list of lists) {
197
+ const data = list.payload.data ?? [];
198
+ const idx = data.findIndex((o) => o.id === orderId);
199
+ if (idx === -1)
200
+ continue;
201
+ const updated = data.map((o, i) => i === idx ? { ...o, delivery_id: deliveryId, delivery_status: "assigned" } : o);
202
+ await upsertEntitySnapshot(db, {
203
+ entityType: "seller_orders",
204
+ entityId: list.entity_id,
205
+ businessId: list.business_id,
206
+ lastEventId: event.event_id,
207
+ lastEventTimestamp: event.timestamp,
208
+ payload: { ...list.payload, data: updated },
209
+ });
210
+ }
211
+ }
212
+ catch (error) {
213
+ syncLogger.error("OrderSnapshots", "Failed to apply delivery.assigned to read model", { error: String(error) });
214
+ }
215
+ }
216
+ /** delivery.status_changed {order_id, status} / delivery.pickup_confirmed {order_id, rider_confirmed, merchant_confirmed, status}. */
217
+ async applyDeliveryStatusToReadModel(db, event) {
218
+ try {
219
+ const payload = event.payload;
220
+ const orderId = this.orderIdOf(event);
221
+ const businessId = this.businessIdOf(event);
222
+ if (!orderId || !businessId)
223
+ return;
224
+ const deliveryStatus = payload?.status;
225
+ const patch = {};
226
+ if (deliveryStatus)
227
+ patch.delivery_status = deliveryStatus;
228
+ if (typeof payload?.merchant_confirmed === "boolean")
229
+ patch.merchant_handover_confirmed_at = payload.merchant_confirmed ? event.timestamp : null;
230
+ if (typeof payload?.rider_confirmed === "boolean")
231
+ patch.rider_handover_confirmed_at = payload.rider_confirmed ? event.timestamp : null;
232
+ if (Object.keys(patch).length === 0)
233
+ return;
234
+ const lists = await getEntitySnapshotsByType(db, "seller_orders", businessId);
235
+ for (const list of lists) {
236
+ const data = list.payload.data ?? [];
237
+ const idx = data.findIndex((o) => o.id === orderId);
238
+ if (idx === -1)
239
+ continue;
240
+ const updated = data.map((o, i) => (i === idx ? { ...o, ...patch } : o));
241
+ await upsertEntitySnapshot(db, {
242
+ entityType: "seller_orders",
243
+ entityId: list.entity_id,
244
+ businessId: list.business_id,
245
+ lastEventId: event.event_id,
246
+ lastEventTimestamp: event.timestamp,
247
+ payload: { ...list.payload, data: updated },
248
+ });
249
+ }
250
+ }
251
+ catch (error) {
252
+ syncLogger.error("OrderSnapshots", "Failed to apply delivery status to read model", { error: String(error) });
253
+ }
254
+ }
159
255
  /**
160
256
  * Write through a fetched order list so it is available offline. Used by the
161
257
  * seller and POS order queries. `scope` distinguishes complementary lists
@@ -15,7 +15,8 @@ import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { BackoffTracker } from "./backoff";
16
16
  import { generateOrderNumber } from "./order-number";
17
17
  import { orderSnapshotsService } from "./order-snapshots.service";
18
- import { getTransitionPath } from "./transition-endpoints";
18
+ import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
19
+ import { extractApiErrorCode } from "../utils/error";
19
20
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
20
21
  const inflight = new Map();
21
22
  const retryTimers = new Map();
@@ -167,6 +168,33 @@ async function tryRest(businessId, payload, commandType) {
167
168
  const orderId = (payload.orderId ?? payload.order_id ?? payload.entity_id ?? payload.id);
168
169
  if (!orderId)
169
170
  return { ok: false, message: "missing orderId" };
171
+ if (DELIVERY_COMMANDS.has(commandType)) {
172
+ const db = await openSyncDatabase();
173
+ const isAssign = commandType === "delivery:assign" || commandType === "order:assign_rider";
174
+ if (isAssign) {
175
+ // POST /deliveries/assign {order_id, rider_id} — rider_id must have
176
+ // been captured at tap time; without it the job can never succeed.
177
+ const riderId = (payload.rider_id ?? payload.riderId);
178
+ if (!riderId)
179
+ return { ok: false, status: 422, code: "MISSING_RIDER", message: "Assign rider job has no rider_id — re-assign from the app" };
180
+ await http.post(`/deliveries/assign`, { order_id: orderId, rider_id: riderId });
181
+ return { ok: true };
182
+ }
183
+ // POST /deliveries/:deliveryId/confirm-handover — resolve the delivery
184
+ // id from the local order row (sync payloads carry the order id).
185
+ let deliveryId = payload.delivery_id;
186
+ if (!deliveryId) {
187
+ try {
188
+ const row = await db.getFirstAsync(`SELECT delivery_id FROM orders WHERE business_id=? AND id=?;`, businessId, orderId);
189
+ deliveryId = row?.delivery_id ?? undefined;
190
+ }
191
+ catch { }
192
+ }
193
+ if (!deliveryId)
194
+ return { ok: false, status: 422, code: "NO_DELIVERY", message: "Handover job has no delivery — rider not assigned yet" };
195
+ await http.post(`/deliveries/${deliveryId}/confirm-handover`, {});
196
+ return { ok: true };
197
+ }
170
198
  const path = getTransitionPath(commandType, orderId);
171
199
  if (!path)
172
200
  return { ok: false, message: `unknown command ${commandType}` };
@@ -176,7 +204,8 @@ async function tryRest(businessId, payload, commandType) {
176
204
  catch (e) {
177
205
  const err = e;
178
206
  const status = err.status ?? err.response?.status;
179
- const bodyCode = err.response?.data?.error?.code;
207
+ // Backend code via every envelope shape (core ApiError, axios, app ApiError).
208
+ const bodyCode = extractApiErrorCode(e);
180
209
  const bodyMsg = err.response?.data?.error?.message;
181
210
  return {
182
211
  ok: false,
@@ -267,9 +296,9 @@ export async function drainOne(businessId, entityId) {
267
296
  catch { }
268
297
  return;
269
298
  }
270
- // 3) Handle 409 RRR regen same UUID (POS create only)
299
+ // 3) Handle 409 RRR regen same UUID (POS create only, exact backend code)
271
300
  syncLogger.debug("SyncJobs", "check 409", { businessId, entityId, status: rest.status, code: rest.code });
272
- const is409 = rest.status === 409 && (String(rest.code ?? "").includes("ORDER_NUMBER_TAKEN") || String(rest.message ?? "").includes("ORDER_NUMBER_TAKEN"));
301
+ const is409 = rest.status === 409 && rest.code === "ORDER_NUMBER_TAKEN";
273
302
  if (is409) {
274
303
  const on = (payload.order_number ?? payload.local_order_number);
275
304
  const parsed = on ? String(on).match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
@@ -329,10 +358,8 @@ export async function drainOne(businessId, entityId) {
329
358
  // 404 is only retryable for order:create (FIFO race: order not yet created
330
359
  // on backend). For transitions, 404 means the order was deleted or never
331
360
  // synced — permanent. 409 handled above for RRR regen. 401/408/429 need
332
- // special handling below.
333
- const codeUpper = String(rest.code ?? "").toUpperCase();
334
- const msgUpper = String(rest.message ?? "").toUpperCase();
335
- const isBusinessClosed = codeUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS IS CLOSED") || codeUpper.includes("HOURS_NOT_SYNCED");
361
+ // special handling below. Backend codes are exact (no message matching).
362
+ const isBusinessClosed = rest.code === "BUSINESS_CLOSED" || rest.code === "HOURS_NOT_SYNCED";
336
363
  const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
337
364
  const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409 || rest.status === 429
338
365
  || (rest.status === 404 && row.command_type === "order:create")
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * Single source used by both the sync_jobs drain (tryRest) and the REST
5
5
  * transport (executeOrderTransition) so endpoint definitions cannot drift.
6
+ *
7
+ * Delivery commands do NOT live under /orders/:id — backend routes are
8
+ * POST /deliveries/assign {order_id, rider_id} and
9
+ * POST /deliveries/:deliveryId/confirm-handover. tryRest builds those
10
+ * bodies/paths (resolving delivery_id from SQLite when needed).
6
11
  */
12
+ /** Command types fulfilled by the delivery endpoints (strictly online). */
13
+ export declare const DELIVERY_COMMANDS: Set<string>;
7
14
  /**
8
15
  * Build the REST path for a given command type and order/delivery ID.
9
- * Returns null for unknown command types.
16
+ * Returns null for unknown command types AND for delivery commands, which
17
+ * need body/path resolution in tryRest (rider_id, delivery_id lookup).
10
18
  */
11
19
  export declare function getTransitionPath(commandType: string, entityId: string): string | null;
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * Single source used by both the sync_jobs drain (tryRest) and the REST
5
5
  * transport (executeOrderTransition) so endpoint definitions cannot drift.
6
+ *
7
+ * Delivery commands do NOT live under /orders/:id — backend routes are
8
+ * POST /deliveries/assign {order_id, rider_id} and
9
+ * POST /deliveries/:deliveryId/confirm-handover. tryRest builds those
10
+ * bodies/paths (resolving delivery_id from SQLite when needed).
6
11
  */
7
12
  const ORDER_ENDPOINTS = {
8
13
  "order:accept": "accept",
@@ -13,16 +18,22 @@ const ORDER_ENDPOINTS = {
13
18
  "order:cancel": "cancel",
14
19
  "order:confirm_cash": "confirm-cash-payment",
15
20
  "order:pay": "confirm-cash-payment",
16
- "order:assign_rider": "assign",
17
21
  };
22
+ /** Command types fulfilled by the delivery endpoints (strictly online). */
23
+ export const DELIVERY_COMMANDS = new Set([
24
+ "delivery:assign",
25
+ "delivery:confirm_handover",
26
+ "order:assign_rider",
27
+ "order:confirm_handover",
28
+ ]);
18
29
  /**
19
30
  * Build the REST path for a given command type and order/delivery ID.
20
- * Returns null for unknown command types.
31
+ * Returns null for unknown command types AND for delivery commands, which
32
+ * need body/path resolution in tryRest (rider_id, delivery_id lookup).
21
33
  */
22
34
  export function getTransitionPath(commandType, entityId) {
23
- if (commandType === "order:confirm_handover") {
24
- return `/deliveries/${entityId}/confirm-handover`;
25
- }
35
+ if (DELIVERY_COMMANDS.has(commandType))
36
+ return null;
26
37
  const suffix = ORDER_ENDPOINTS[commandType];
27
38
  if (!suffix)
28
39
  return null;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Coded errors for order/delivery transitions and sync control flow.
3
+ *
4
+ * Never branch on error message text: every backend action error carries a
5
+ * machine-readable `code` ({error: {code, message}}), and local guard
6
+ * rejections use the same codes below. Callers switch on `error.code`.
7
+ *
8
+ * Codes mirror the backend where semantics match (INVALID_TRANSITION,
9
+ * FORBIDDEN, ORDER_NOT_FOUND); client-only conditions use their own codes
10
+ * (ONLINE_REQUIRED, NO_ACTIVE_BUSINESS, SUBSCRIPTION_REQUIRED,
11
+ * REPLAY_DEFERRED, MISSING_RIDER, NO_DELIVERY).
12
+ */
13
+ export declare const TRANSITION_ERROR_CODES: {
14
+ readonly INVALID_TRANSITION: "INVALID_TRANSITION";
15
+ readonly ONLINE_REQUIRED: "ONLINE_REQUIRED";
16
+ readonly NO_ACTIVE_BUSINESS: "NO_ACTIVE_BUSINESS";
17
+ readonly ORDER_NOT_FOUND: "ORDER_NOT_FOUND";
18
+ readonly FORBIDDEN: "FORBIDDEN";
19
+ readonly SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED";
20
+ readonly REPLAY_DEFERRED: "REPLAY_DEFERRED";
21
+ readonly MISSING_RIDER: "MISSING_RIDER";
22
+ readonly NO_DELIVERY: "NO_DELIVERY";
23
+ };
24
+ export type TransitionErrorCode = (typeof TRANSITION_ERROR_CODES)[keyof typeof TRANSITION_ERROR_CODES];
25
+ export declare class TransitionError extends Error {
26
+ code: TransitionErrorCode | string;
27
+ constructor(code: TransitionErrorCode | string, message: string);
28
+ }
29
+ /** Read a machine-readable error code off any thrown value. */
30
+ export declare function errorCodeOf(error: unknown): string | undefined;
31
+ export declare function isFinalGuardRejection(error: unknown): boolean;
32
+ export declare function isReplayDeferred(error: unknown): boolean;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Coded errors for order/delivery transitions and sync control flow.
3
+ *
4
+ * Never branch on error message text: every backend action error carries a
5
+ * machine-readable `code` ({error: {code, message}}), and local guard
6
+ * rejections use the same codes below. Callers switch on `error.code`.
7
+ *
8
+ * Codes mirror the backend where semantics match (INVALID_TRANSITION,
9
+ * FORBIDDEN, ORDER_NOT_FOUND); client-only conditions use their own codes
10
+ * (ONLINE_REQUIRED, NO_ACTIVE_BUSINESS, SUBSCRIPTION_REQUIRED,
11
+ * REPLAY_DEFERRED, MISSING_RIDER, NO_DELIVERY).
12
+ */
13
+ export const TRANSITION_ERROR_CODES = {
14
+ INVALID_TRANSITION: "INVALID_TRANSITION",
15
+ ONLINE_REQUIRED: "ONLINE_REQUIRED",
16
+ NO_ACTIVE_BUSINESS: "NO_ACTIVE_BUSINESS",
17
+ ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
18
+ FORBIDDEN: "FORBIDDEN",
19
+ SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED",
20
+ REPLAY_DEFERRED: "REPLAY_DEFERRED",
21
+ MISSING_RIDER: "MISSING_RIDER",
22
+ NO_DELIVERY: "NO_DELIVERY",
23
+ };
24
+ export class TransitionError extends Error {
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.name = "TransitionError";
28
+ this.code = code;
29
+ }
30
+ }
31
+ import { extractApiErrorCode } from "../utils/error";
32
+ /** Read a machine-readable error code off any thrown value. */
33
+ export function errorCodeOf(error) {
34
+ if (!error || typeof error !== "object")
35
+ return undefined;
36
+ const code = error.code;
37
+ if (typeof code === "string")
38
+ return code;
39
+ // Backend-shaped errors nested under response/data ({error: {code}}).
40
+ return extractApiErrorCode(error);
41
+ }
42
+ /**
43
+ * Guard rejections that must stand: the transport must surface them instead
44
+ * of POSTing past them (the complete-pickup 422 storm on delivery orders).
45
+ * ORDER_NOT_FOUND is intentionally excluded — the device may simply never
46
+ * have seen the order, in which case direct-REST fallback is correct.
47
+ */
48
+ const FINAL_GUARD_CODES = new Set([
49
+ TRANSITION_ERROR_CODES.INVALID_TRANSITION,
50
+ TRANSITION_ERROR_CODES.ONLINE_REQUIRED,
51
+ TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS,
52
+ TRANSITION_ERROR_CODES.FORBIDDEN,
53
+ TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED,
54
+ ]);
55
+ export function isFinalGuardRejection(error) {
56
+ const code = errorCodeOf(error);
57
+ return code !== undefined && FINAL_GUARD_CODES.has(code);
58
+ }
59
+ export function isReplayDeferred(error) {
60
+ return errorCodeOf(error) === TRANSITION_ERROR_CODES.REPLAY_DEFERRED;
61
+ }
@@ -14,9 +14,11 @@ export declare class RestTransport implements Transport {
14
14
  private dispatchCommand;
15
15
  /**
16
16
  * Order transitions go through the domain service first (single write path:
17
- * SQLite + sync_jobs). If this device never saw the order (e.g. a LAN node
18
- * handling a cloud-created order before its first pull), fall back to the
19
- * previous direct-REST behavior so availability never regresses.
17
+ * SQLite + sync_jobs). Direct-REST fallback is ONLY for devices that never
18
+ * saw the order (LAN node handling a cloud-created order before first pull).
19
+ * Local guard rejections (Cannot <action>, online-required, auth/business,
20
+ * capability) are rethrown — POSTing past them is what caused the
21
+ * complete-pickup 422 storm on delivery orders.
20
22
  */
21
23
  private executeOrderTransition;
22
24
  private wrapResponse;
@@ -9,6 +9,7 @@ import { sellerService, kioskService, menuService } from "../adapters/services";
9
9
  import { syncLogger } from "../utils/sync-logger";
10
10
  import { generateEventId } from "../utils/uuid";
11
11
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
12
+ import { isFinalGuardRejection } from "./transition-errors";
12
13
  import { openSyncDatabase } from "../db/connection";
13
14
  import { getOrderById, updateOrderStatus } from "../db/operations";
14
15
  export class RestTransport {
@@ -134,9 +135,11 @@ export class RestTransport {
134
135
  }
135
136
  /**
136
137
  * Order transitions go through the domain service first (single write path:
137
- * SQLite + sync_jobs). If this device never saw the order (e.g. a LAN node
138
- * handling a cloud-created order before its first pull), fall back to the
139
- * previous direct-REST behavior so availability never regresses.
138
+ * SQLite + sync_jobs). Direct-REST fallback is ONLY for devices that never
139
+ * saw the order (LAN node handling a cloud-created order before first pull).
140
+ * Local guard rejections (Cannot <action>, online-required, auth/business,
141
+ * capability) are rethrown — POSTing past them is what caused the
142
+ * complete-pickup 422 storm on delivery orders.
140
143
  */
141
144
  async executeOrderTransition(command, action, timestamp, restFallback, eventType = "order.status_changed") {
142
145
  const { business_id, entity_id } = command;
@@ -146,6 +149,10 @@ export class RestTransport {
146
149
  return this.wrapResponse(eventType, data, order.id, timestamp, command);
147
150
  }
148
151
  catch (error) {
152
+ // Guard rejections (by code, never message text) must stand — never
153
+ // POST past them. Only ORDER_NOT_FOUND falls back to REST.
154
+ if (isFinalGuardRejection(error))
155
+ throw error;
149
156
  syncLogger.warn("RestTransport", "domain transition unavailable, REST fallback", {
150
157
  command_type: command.command_type,
151
158
  entity_id,
@@ -39,6 +39,9 @@ export function extractApiErrorCode(error) {
39
39
  if (!error || typeof error !== "object")
40
40
  return undefined;
41
41
  const err = error;
42
+ // Core ApiError / TransitionError carry code on top level.
43
+ if (typeof err.code === "string")
44
+ return err.code;
42
45
  const response = err.response;
43
46
  if (response?.data && typeof response.data === "object") {
44
47
  const data = response.data;
@@ -22,6 +22,8 @@ export const PIPELINE = ["pending", "accepted", "preparing", "ready", "delivered
22
22
  // ── Helpers ───────────────────────────────────────────────────────────────
23
23
  export function isUnpaid(order) {
24
24
  const o = order;
25
+ if (o.pos_payment_method === "cash")
26
+ return false;
25
27
  return !o.is_paid && !o.paid_at && !o.payment?.is_paid;
26
28
  }
27
29
  export function isCancellable(status) {
@@ -72,11 +74,27 @@ export function nextActionFor(order) {
72
74
  if (status === "preparing")
73
75
  return { label: "Mark ready", key: "mark_ready" };
74
76
  if (status === "ready") {
77
+ // Delivery orders can NEVER complete pickup: backend rejects with
78
+ // INVALID_TRANSITION ("Only self-pickup orders can be completed this way").
79
+ // Ready delivery without a rider → assign one (online-only); with a rider
80
+ // → merchant handover confirm (online-only). Walk-in only below.
81
+ if (isDeliveryOrder(order)) {
82
+ const hasDelivery = !!order.delivery_id;
83
+ if (hasDelivery)
84
+ return { label: "Handover to rider", key: "confirm_handover" };
85
+ return { label: "Assign rider", key: "assign_rider" };
86
+ }
75
87
  const orderType = order.order_type ?? null;
76
- // dine_in walk-in → Complete, else Pickup (takeout or delivery handover gate handled via getAllowedActions)
88
+ // dine_in walk-in → Complete, else Pickup (takeout)
77
89
  const label = orderType === "dine_in" ? "Complete" : "Picked up";
78
90
  return { label, key: "complete_pickup" };
79
91
  }
92
+ if (status === "waiting_pickup" || status === "on_the_way") {
93
+ if (isDeliveryOrder(order)) {
94
+ return { label: "Handover to rider", key: "confirm_handover" };
95
+ }
96
+ return null;
97
+ }
80
98
  return null;
81
99
  }
82
100
  export function canTransition(order, action) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
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",