@teincfood/core 0.8.4 → 0.9.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.
Files changed (40) 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/members.hooks.d.ts +4 -0
  9. package/dist/hooks/members.hooks.js +33 -0
  10. package/dist/hooks/orders.hooks.js +6 -3
  11. package/dist/hooks/realtime-stubs.js +48 -0
  12. package/dist/index.d.ts +5 -0
  13. package/dist/index.js +5 -0
  14. package/dist/reference/api.js +12 -2
  15. package/dist/reference/entitlement-clock.d.ts +39 -0
  16. package/dist/reference/entitlement-clock.js +116 -0
  17. package/dist/reference/entitlement-crypto.d.ts +10 -0
  18. package/dist/reference/entitlement-crypto.js +111 -0
  19. package/dist/reference/entitlements.d.ts +51 -0
  20. package/dist/reference/entitlements.js +124 -0
  21. package/dist/reference/operations.d.ts +7 -0
  22. package/dist/reference/operations.js +26 -0
  23. package/dist/reference/service.js +33 -1
  24. package/dist/repositories/order.repository.js +11 -0
  25. package/dist/sync/command-queue.service.js +13 -13
  26. package/dist/sync/engine.js +18 -9
  27. package/dist/sync/online-gates.d.ts +9 -0
  28. package/dist/sync/online-gates.js +36 -3
  29. package/dist/sync/order-snapshots.service.d.ts +10 -0
  30. package/dist/sync/order-snapshots.service.js +96 -0
  31. package/dist/sync/sync-jobs.service.js +35 -8
  32. package/dist/sync/transition-endpoints.d.ts +9 -1
  33. package/dist/sync/transition-endpoints.js +16 -5
  34. package/dist/sync/transition-errors.d.ts +32 -0
  35. package/dist/sync/transition-errors.js +61 -0
  36. package/dist/sync/transport-rest.d.ts +5 -3
  37. package/dist/sync/transport-rest.js +10 -3
  38. package/dist/utils/error.js +3 -0
  39. package/dist/utils/order-transitions.js +17 -1
  40. package/package.json +1 -1
@@ -88,6 +88,14 @@ export declare function updateOrderPayment(db: SQLiteDatabase, businessId: strin
88
88
  is_paid: boolean;
89
89
  paid_at: string | null;
90
90
  }): Promise<void>;
91
+ /**
92
+ * Patch delivery linkage from realtime delivery events (delivery.assigned /
93
+ * delivery.status_changed / delivery.pickup_confirmed) so every terminal
94
+ * converges on assign-vs-handover branching without a refetch.
95
+ */
96
+ export declare function updateOrderDelivery(db: SQLiteDatabase, businessId: string, orderId: string, patch: {
97
+ delivery_id?: string | null;
98
+ }): Promise<void>;
91
99
  export declare function clearAllSyncData(db: SQLiteDatabase): Promise<void>;
92
100
  export declare function wipeForNewInstall(db: SQLiteDatabase): Promise<void>;
93
101
  export declare function clearReferenceData(db: SQLiteDatabase): Promise<void>;
@@ -284,6 +284,17 @@ export async function updateOrderPayment(db, businessId, orderId, patch) {
284
284
  await db.runAsync(`UPDATE orders SET payment = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, JSON.stringify(payment), new Date().toISOString(), businessId, orderId, orderId);
285
285
  notifyDbChange(businessId, "orders");
286
286
  }
287
+ /**
288
+ * Patch delivery linkage from realtime delivery events (delivery.assigned /
289
+ * delivery.status_changed / delivery.pickup_confirmed) so every terminal
290
+ * converges on assign-vs-handover branching without a refetch.
291
+ */
292
+ export async function updateOrderDelivery(db, businessId, orderId, patch) {
293
+ if (patch.delivery_id === undefined)
294
+ return;
295
+ await db.runAsync(`UPDATE orders SET delivery_id = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, patch.delivery_id, new Date().toISOString(), businessId, orderId, orderId);
296
+ notifyDbChange(businessId, "orders");
297
+ }
287
298
  // ─────────────────────────────────────────────────────────────
288
299
  // Maintenance — clear stale local data (used by Diagnostics)
289
300
  // ─────────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import { getConnectivityState } from "../../adapters/connectivity";
12
12
  import { assertOnlineFor } from "../../sync/online-gates";
13
13
  import { assertBusinessOpenForCreate } from "../../utils/business-hours";
14
14
  import { canTransition } from "../../utils/order-transitions";
15
+ import { TRANSITION_ERROR_CODES, TransitionError } from "../../sync/transition-errors";
15
16
  import { validateOrderCreateInput } from "../../validation/offline";
16
17
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
17
18
  import { getAuthToken } from "../../adapters/auth";
@@ -164,12 +165,12 @@ export async function createLocalOrder(businessId, input) {
164
165
  }
165
166
  export async function transitionLocalOrder(businessId, orderId, action) {
166
167
  if (!businessId || !canBusinessSync(businessId)) {
167
- throw new Error("Transition requires active business/login");
168
+ throw new TransitionError(TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS, "Transition requires active business/login");
168
169
  }
169
170
  const db = await openSyncDatabase();
170
171
  const order = await getOrderById(db, businessId, orderId);
171
172
  if (!order)
172
- throw new Error("Order not found");
173
+ throw new TransitionError(TRANSITION_ERROR_CODES.ORDER_NOT_FOUND, "Order not found");
173
174
  // Delivery transitions require online (single source: sync/online-gates).
174
175
  if (action === "assign_rider")
175
176
  assertOnlineFor("order:assign_rider");
@@ -178,7 +179,7 @@ export async function transitionLocalOrder(businessId, orderId, action) {
178
179
  else if (order.order_type === "delivery")
179
180
  assertOnlineFor("order:create", { order_type: "delivery" });
180
181
  if (!canTransition(order, action)) {
181
- throw new Error(`Cannot ${action} from ${order.status}`);
182
+ throw new TransitionError(TRANSITION_ERROR_CODES.INVALID_TRANSITION, `Cannot ${action} from ${order.status}`);
182
183
  }
183
184
  const map = { accept: "accepted", reject: "cancelled", start_preparing: "preparing", mark_ready: "ready", complete_pickup: "delivered", cancel: "cancelled", assign_rider: "assigned", confirm_handover: "delivered", confirm_cash: order.status, pay: order.status };
184
185
  const toStatus = map[action] ?? order.status;
@@ -11,6 +11,77 @@ export declare const REVENUE_KEYS: {
11
11
  all: readonly ["revenue-summary"];
12
12
  summary: (businessId?: string, period?: string) => readonly ["revenue-summary", string | undefined, string | undefined];
13
13
  };
14
+ interface LocalOrderRow {
15
+ status: string;
16
+ total_minor: number;
17
+ subtotal_minor?: number;
18
+ inserted_at: string;
19
+ payment_mode?: string;
20
+ pos_payment_method?: string;
21
+ order_type?: string;
22
+ items?: Array<{
23
+ quantity?: number;
24
+ subtotal?: number;
25
+ total_price_minor?: number;
26
+ menu_item?: {
27
+ name?: string;
28
+ };
29
+ }>;
30
+ }
31
+ interface AggregateOptions {
32
+ forRevenueSummary: boolean;
33
+ }
34
+ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today" | "this_week" | "this_month", opts: AggregateOptions): {
35
+ period: "this_month" | "this_week" | "today";
36
+ total_revenue_minor: number;
37
+ total_orders: number;
38
+ average_order_value_minor: number;
39
+ by_payment_method: {
40
+ revenue_minor: number;
41
+ count: number;
42
+ method: string;
43
+ percentage: number;
44
+ }[];
45
+ by_order_type: {
46
+ revenue_minor: number;
47
+ count: number;
48
+ type: string;
49
+ }[];
50
+ by_day: {
51
+ revenue_minor: number;
52
+ count: number;
53
+ date: string;
54
+ }[];
55
+ offline: boolean;
56
+ total_refunds_minor?: undefined;
57
+ top_items?: undefined;
58
+ orders_by_status?: undefined;
59
+ revenue_by_day?: undefined;
60
+ } | {
61
+ by_payment_method?: undefined;
62
+ by_order_type?: undefined;
63
+ by_day?: undefined;
64
+ period: "this_month" | "this_week" | "today";
65
+ total_revenue_minor: number;
66
+ total_orders: number;
67
+ average_order_value_minor: number;
68
+ total_refunds_minor: number;
69
+ top_items: {
70
+ name: string;
71
+ quantity: number;
72
+ revenue_minor: number;
73
+ }[];
74
+ orders_by_status: {
75
+ delivered: number;
76
+ cancelled: number;
77
+ pending: number;
78
+ };
79
+ revenue_by_day: {
80
+ date: string;
81
+ revenue_minor: number;
82
+ }[];
83
+ offline: boolean;
84
+ };
14
85
  export declare function useSellerAnalyticsQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
15
86
  data?: {
16
87
  period?: string;
@@ -23,3 +94,4 @@ export declare function useRevenueSummaryQuery(period?: "today" | "this_week" |
23
94
  by_day?: unknown[];
24
95
  };
25
96
  }, Error>;
97
+ export {};
@@ -50,18 +50,22 @@ function filterByPeriod(orders, period) {
50
50
  return d >= startStr && d <= todayStr;
51
51
  });
52
52
  }
53
- function aggregateLocal(orders, period, opts) {
53
+ export function aggregateLocal(orders, period, opts) {
54
54
  const filtered = filterByPeriod(orders, period);
55
- const total_revenue_minor = filtered.reduce((s, o) => s + (o.total_minor ?? 0), 0);
56
- const total_orders = filtered.length;
57
- const average_order_value_minor = total_orders ? Math.round(total_revenue_minor / total_orders) : 0;
55
+ // Backend parity (orders.ex get_business_analytics / get_revenue_summary):
56
+ // revenue-eligible = all EXCEPT cancelled. Cancelled orders must never
57
+ // inflate revenue, counts, breakdowns, or top items — online and offline.
58
+ const revenueOrders = filtered.filter((o) => o.status !== "cancelled");
59
+ const total_revenue_minor = revenueOrders.reduce((s, o) => s + (o.total_minor ?? 0), 0);
60
+ const total_orders = revenueOrders.length;
61
+ // Backend uses div (truncation) — mirror, don't round.
62
+ const average_order_value_minor = total_orders ? Math.floor(total_revenue_minor / total_orders) : 0;
58
63
  const byMethod = new Map();
59
64
  const byType = new Map();
60
65
  const byDay = new Map();
61
- const byStatus = {};
62
66
  const itemQty = new Map();
63
67
  const itemRev = new Map();
64
- for (const o of filtered) {
68
+ for (const o of revenueOrders) {
65
69
  const method = o.pos_payment_method ?? o.payment_mode ?? "cash";
66
70
  const m = byMethod.get(method) ?? { revenue_minor: 0, count: 0 };
67
71
  m.revenue_minor += o.total_minor ?? 0;
@@ -77,7 +81,6 @@ function aggregateLocal(orders, period, opts) {
77
81
  d.revenue_minor += o.total_minor ?? 0;
78
82
  d.count += 1;
79
83
  byDay.set(day, d);
80
- byStatus[o.status] = (byStatus[o.status] ?? 0) + 1;
81
84
  for (const it of o.items ?? []) {
82
85
  const name = it.menu_item?.name ?? "Unknown";
83
86
  const qty = it.quantity ?? 0;
@@ -87,6 +90,12 @@ function aggregateLocal(orders, period, opts) {
87
90
  }
88
91
  }
89
92
  const pct = (v) => (total_revenue_minor > 0 ? Math.round((v / total_revenue_minor) * 1000) / 10 : 0);
93
+ // Backend orders_by_status counts over ALL period orders (not revenue-only).
94
+ const orders_by_status = {
95
+ delivered: filtered.filter((o) => o.status === "delivered").length,
96
+ cancelled: filtered.filter((o) => o.status === "cancelled").length,
97
+ pending: filtered.filter((o) => o.status === "pending").length,
98
+ };
90
99
  if (opts.forRevenueSummary) {
91
100
  return {
92
101
  period,
@@ -115,7 +124,7 @@ function aggregateLocal(orders, period, opts) {
115
124
  .map(([name, quantity]) => ({ name, quantity, revenue_minor: itemRev.get(name) ?? 0 }))
116
125
  .sort((a, b) => b.revenue_minor - a.revenue_minor)
117
126
  .slice(0, 10),
118
- orders_by_status: byStatus,
127
+ orders_by_status,
119
128
  revenue_by_day: [...byDay.entries()]
120
129
  .map(([date, v]) => ({ date, revenue_minor: v.revenue_minor }))
121
130
  .sort((a, b) => a.date.localeCompare(b.date)),
@@ -10,6 +10,19 @@ export declare const SELLER_DASHBOARD_KEYS: {
10
10
  recentOrders: (businessId?: string) => readonly ["seller-dashboard", "recent-orders", string | undefined];
11
11
  full: (businessId?: string) => readonly ["seller-dashboard", "full", string | undefined];
12
12
  };
13
+ interface LocalOrderRow {
14
+ status: string;
15
+ total_minor: number;
16
+ inserted_at: string;
17
+ order_number: string;
18
+ id: string;
19
+ }
20
+ /**
21
+ * Pure metrics aggregation — exported for tests. Mirrors backend
22
+ * get_business_dashboard: revenue = delivered-only, today_orders = all,
23
+ * pending = pending-only, active = accepted/preparing/ready.
24
+ */
25
+ export declare function computeDashboardMetrics(orders: LocalOrderRow[], todayStr: string): SellerDashboardMetrics;
13
26
  export declare function useSellerDashboardMetricsQuery(businessId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<SellerDashboardMetrics, Error>;
14
27
  export declare function useSellerDashboardRecentOrdersQuery(businessId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
15
28
  id: string;
@@ -28,3 +41,4 @@ export declare function useSellerDashboardQuery(businessId: string, enabled?: bo
28
41
  item_count: number;
29
42
  }[];
30
43
  }, Error>;
44
+ export {};
@@ -21,23 +21,30 @@ const EMPTY_METRICS = {
21
21
  average_rating: 0,
22
22
  };
23
23
  /**
24
- * Local SQLite aggregates — instant, even online. Single source for all
25
- * three dashboard queries so metrics/recent/full never diverge.
24
+ * Pure metrics aggregation — exported for tests. Mirrors backend
25
+ * get_business_dashboard: revenue = delivered-only, today_orders = all,
26
+ * pending = pending-only, active = accepted/preparing/ready.
26
27
  */
28
+ export function computeDashboardMetrics(orders, todayStr) {
29
+ const todayOrders = orders.filter((o) => (o.inserted_at ?? "").slice(0, 10) === todayStr);
30
+ const todayRevenue = todayOrders
31
+ .filter((o) => o.status === "delivered")
32
+ .reduce((sum, o) => sum + (o.total_minor ?? 0), 0);
33
+ const pending = orders.filter((o) => o.status === "pending").length;
34
+ const active = orders.filter((o) => ["accepted", "preparing", "ready"].includes(o.status)).length;
35
+ return { today_orders: todayOrders.length, today_revenue_minor: todayRevenue, pending_orders: pending, active_orders: active, average_rating: 0 };
36
+ }
27
37
  async function computeLocalDashboard(businessId) {
28
38
  const { data } = await listLocalOrders(businessId, {}, 1, 200);
29
39
  const orders = (data ?? []);
30
40
  const todayStr = new Date().toISOString().slice(0, 10);
31
- const todayOrders = orders.filter((o) => (o.inserted_at ?? "").slice(0, 10) === todayStr);
32
- const todayRevenue = todayOrders.reduce((sum, o) => sum + (o.total_minor ?? 0), 0);
33
- const pending = orders.filter((o) => ["pending", "awaiting_payment", "accepted"].includes(o.status)).length;
34
- const active = orders.filter((o) => !["delivered", "cancelled", "rejected"].includes(o.status)).length;
41
+ const metrics = computeDashboardMetrics(orders, todayStr);
35
42
  const recent = [...orders]
36
43
  .sort((a, b) => (b.inserted_at ?? "").localeCompare(a.inserted_at ?? ""))
37
44
  .slice(0, 5)
38
45
  .map((o) => ({ id: o.id, order_number: o.order_number, status: o.status, total_minor: o.total_minor, item_count: 1 }));
39
46
  return {
40
- metrics: { today_orders: todayOrders.length, today_revenue_minor: todayRevenue, pending_orders: pending, active_orders: active, average_rating: 0 },
47
+ metrics,
41
48
  recent_orders: recent,
42
49
  };
43
50
  }
@@ -38,6 +38,10 @@ export declare function useAcceptFleetInvitationMutation(): import("@tanstack/re
38
38
  export declare function useAcceptBusinessInvitationMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
39
39
  code: string;
40
40
  }, unknown>;
41
+ export declare function useDeclineBusinessInvitationMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
42
+ code: string;
43
+ }, unknown>;
44
+ export declare function useResendMemberInvitationMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
41
45
  export declare function useBusinessRolesQuery(enabled?: boolean): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
42
46
  export declare function useBusinessTaxRulesQuery(enabled?: boolean): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
43
47
  export declare function useCreateTaxRuleMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, Record<string, unknown>, unknown>;
@@ -209,6 +209,39 @@ export function useAcceptBusinessInvitationMutation() {
209
209
  },
210
210
  });
211
211
  }
212
+ export function useDeclineBusinessInvitationMutation() {
213
+ const queryClient = useQueryClient();
214
+ return useMutation({
215
+ mutationFn: (data) => {
216
+ assertOnlineFor("member:manage");
217
+ return businessService.declineBusinessInvitation(data);
218
+ },
219
+ onSuccess: () => {
220
+ notifySuccess("Invitation declined");
221
+ queryClient.invalidateQueries({ queryKey: INVITATION_KEYS.list() });
222
+ },
223
+ onError: (error) => {
224
+ notifyError(extractApiErrorMessage(error));
225
+ },
226
+ });
227
+ }
228
+ export function useResendMemberInvitationMutation() {
229
+ const queryClient = useQueryClient();
230
+ const businessId = useBusinessId();
231
+ return useMutation({
232
+ mutationFn: (invitationId) => {
233
+ assertOnlineFor("member:manage");
234
+ return businessService.resendBusinessMemberInvitation(businessId, invitationId);
235
+ },
236
+ onSuccess: () => {
237
+ notifySuccess("Invitation resent");
238
+ queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.invitations(businessId ?? "") });
239
+ },
240
+ onError: (error) => {
241
+ notifyError(extractApiErrorMessage(error));
242
+ },
243
+ });
244
+ }
212
245
  // ─── Roles ───
213
246
  export function useBusinessRolesQuery(enabled = true) {
214
247
  const businessId = useBusinessId();
@@ -8,7 +8,8 @@ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
8
8
  import { useSyncExternalStore } from "react";
9
9
  import { listLocalOrders, getLocalOrder } from "../domain/orders/service";
10
10
  import { getDeviceNumber, ensureLease } from "../sync/device-lease.service";
11
- import { assertOnlineFor } from "../sync/online-gates";
11
+ import { assertOnlineFor, isOnlineCapable } from "../sync/online-gates";
12
+ import { localNodeTransport } from "../sync/transport-local-node";
12
13
  import { SELLER_DASHBOARD_KEYS } from "../adapters/query-keys";
13
14
  import { openSyncDatabase } from "../db/connection";
14
15
  import { getReferenceData } from "../reference/operations";
@@ -157,13 +158,15 @@ export function useConfirmHandoverMutation() {
157
158
  const bid = useBusinessId();
158
159
  const qc = useQueryClient();
159
160
  return useMutation({ mutationFn: (deliveryId) => { if (!bid)
160
- throw new Error("No business"); assertOnlineFor("order:confirm_handover"); return orderRepository.confirmHandover({ deliveryId, businessId: bid }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: SELLER_KEYS.all }); qc.invalidateQueries({ queryKey: POS_KEYS.all }); qc.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all }); } });
161
+ throw new Error("No business"); if (!isOnlineCapable() && !localNodeTransport.isAvailable())
162
+ assertOnlineFor("order:confirm_handover"); return orderRepository.confirmHandover({ deliveryId, businessId: bid }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: SELLER_KEYS.all }); qc.invalidateQueries({ queryKey: POS_KEYS.all }); qc.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all }); } });
161
163
  }
162
164
  export function useAssignRiderMutation() {
163
165
  const bid = useBusinessId();
164
166
  const qc = useQueryClient();
165
167
  return useMutation({ mutationFn: ({ orderId, riderId }) => { if (!bid)
166
- throw new Error("No business"); assertOnlineFor("order:assign_rider"); return orderRepository.assignRider({ orderId, riderId, businessId: bid }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: SELLER_KEYS.all }); qc.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all }); } });
168
+ throw new Error("No business"); if (!isOnlineCapable() && !localNodeTransport.isAvailable())
169
+ assertOnlineFor("order:assign_rider"); return orderRepository.assignRider({ orderId, riderId, businessId: bid }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: SELLER_KEYS.all }); qc.invalidateQueries({ queryKey: POS_KEYS.all }); qc.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all }); } });
167
170
  }
168
171
  export function useConfirmCashPaymentMutation() {
169
172
  const bid = useBusinessId();
@@ -16,7 +16,11 @@ import { orderSnapshotsService } from "../sync/order-snapshots.service";
16
16
  import { syncEngine } from "../sync/engine";
17
17
  import { generateEventId } from "../utils/uuid";
18
18
  import { getAppInstanceId } from "../adapters/devices";
19
+ import { getActiveBusiness } from "../adapters/business";
20
+ import { openSyncDatabase } from "../db/connection";
21
+ import { getOrderById } from "../db/operations";
19
22
  const INVALIDATE_DEBOUNCE_MS = 400;
23
+ const UNKNOWN_ORDER_PULL_DEBOUNCE_MS = 1500;
20
24
  /**
21
25
  * Subscribe to real-time order events and invalidate all queries under the
22
26
  * given root key prefix (e.g. ['seller'], ['pos'], ['kiosk']).
@@ -49,6 +53,16 @@ export function useOrderRealtimeUpdates(rootKey) {
49
53
  if (event.order_id)
50
54
  pendingOrderIds.add(event.order_id);
51
55
  const payload = { ...event };
56
+ // Delivery events carry no business_id — the socket already filtered by
57
+ // business, so stamp the active one for read-model patching.
58
+ if (!payload.business_id && !payload.businessId) {
59
+ try {
60
+ const bid = getActiveBusiness()?.id;
61
+ if (bid)
62
+ payload.business_id = bid;
63
+ }
64
+ catch { }
65
+ }
52
66
  const domainEvent = {
53
67
  event_id: payload.event_id ?? generateEventId(),
54
68
  event_type: event.type,
@@ -61,6 +75,24 @@ export function useOrderRealtimeUpdates(rootKey) {
61
75
  };
62
76
  // Persist to durable offline read model (entity_snapshots table).
63
77
  orderSnapshotsService.persistOrderEvent(domainEvent, "cloud").catch(() => { });
78
+ // order.created from another terminal is summary-only (no full body):
79
+ // invalidate alone can't surface it because SQLite has no row. Pull
80
+ // once (debounced) when the order is unknown locally. Event-driven —
81
+ // no polling.
82
+ if (event.type === "order.created" && event.order_id) {
83
+ const bid = payload.business_id;
84
+ if (bid) {
85
+ void (async () => {
86
+ try {
87
+ const db = await openSyncDatabase();
88
+ const known = await getOrderById(db, bid, event.order_id);
89
+ if (!known)
90
+ scheduleUnknownOrderPull(bid);
91
+ }
92
+ catch { }
93
+ })();
94
+ }
95
+ }
64
96
  // Publish through the Sync Engine so useSyncReconciliation + LAN relay
65
97
  // fire without waiting for the next poll.
66
98
  try {
@@ -77,6 +109,22 @@ export function useOrderRealtimeUpdates(rootKey) {
77
109
  };
78
110
  }, [queryClient, rootKey]);
79
111
  }
112
+ /**
113
+ * Debounced pull for remotely-created orders (one timer per business).
114
+ * Module-level so bursts across screens coalesce into a single pull.
115
+ */
116
+ const unknownOrderPullTimers = new Map();
117
+ function scheduleUnknownOrderPull(businessId) {
118
+ if (unknownOrderPullTimers.has(businessId))
119
+ return;
120
+ const t = setTimeout(() => {
121
+ unknownOrderPullTimers.delete(businessId);
122
+ void import("../sync/sync-jobs.service")
123
+ .then((m) => m.pullRemoteOrders(businessId))
124
+ .catch(() => { });
125
+ }, UNKNOWN_ORDER_PULL_DEBOUNCE_MS);
126
+ unknownOrderPullTimers.set(businessId, t);
127
+ }
80
128
  /**
81
129
  * Global realtime invalidation — currently handled entirely by
82
130
  * useOrderRealtimeUpdates + useSyncReconciliation. Kept as a no-op for
package/dist/index.d.ts CHANGED
@@ -97,6 +97,9 @@ export { referenceSyncService } from "./reference/service";
97
97
  export * from "./reference/menu-record";
98
98
  export * from "./reference/menu-apply";
99
99
  export * from "./reference/capabilities";
100
+ export * from "./reference/entitlements";
101
+ export * from "./reference/entitlement-clock";
102
+ export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
100
103
  export * from "./adapters/sqlite";
101
104
  export { KVDriver, setKVDriver, getKVDriver, kvGetItem, kvSetItem, kvRemoveItem, getItem, setItem, removeItem, getItemSync, setItemSync } from "./adapters/kv";
102
105
  export * from "./adapters/connectivity";
@@ -125,6 +128,8 @@ export * from "./cart/store";
125
128
  export * from "./cart/hooks";
126
129
  export * from "./catalog/hooks";
127
130
  export * from "./utils/order-transitions";
131
+ export * from "./sync/transition-errors";
132
+ export * from "./sync/transition-endpoints";
128
133
  export * from "./utils/business-hours";
129
134
  export * from "./hooks/business-hours.hooks";
130
135
  export * from "./domain/orders/service";
package/dist/index.js CHANGED
@@ -99,6 +99,9 @@ export { referenceSyncService } from "./reference/service";
99
99
  export * from "./reference/menu-record";
100
100
  export * from "./reference/menu-apply";
101
101
  export * from "./reference/capabilities";
102
+ export * from "./reference/entitlements";
103
+ export * from "./reference/entitlement-clock";
104
+ export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
102
105
  // ── Adapters (for host apps to supply) ──
103
106
  export * from "./adapters/sqlite";
104
107
  export { setKVDriver, getKVDriver, kvGetItem, kvSetItem, kvRemoveItem, getItem, setItem, removeItem, getItemSync, setItemSync } from "./adapters/kv";
@@ -135,6 +138,8 @@ export * from "./cart/hooks";
135
138
  export * from "./catalog/hooks";
136
139
  // ── Order transitions (shared business logic — single source) ──
137
140
  export * from "./utils/order-transitions";
141
+ export * from "./sync/transition-errors";
142
+ export * from "./sync/transition-endpoints";
138
143
  // ── Business hours gate (single source — mirrors backend is_open?) ──
139
144
  export * from "./utils/business-hours";
140
145
  export * from "./hooks/business-hours.hooks";
@@ -30,8 +30,18 @@ export async function fetchReferenceData(businessId, keys) {
30
30
  const cached = await localNodeFetcher({ businessId, keys });
31
31
  const out = {};
32
32
  for (const key of keys) {
33
- if (cached[key]?.payload != null)
34
- out[key] = cached[key].payload;
33
+ const rec = cached[key];
34
+ if (rec?.payload == null)
35
+ continue;
36
+ // LAN relays are untrusted for server-authoritative keys: only forward
37
+ // payloads carrying a signed envelope; signature is verified at
38
+ // evaluation time and the monotonic clock anchors to server_time.
39
+ if (key === "subscription:entitlements") {
40
+ const envelope = rec.payload?.envelope;
41
+ if (!envelope?.version || !envelope?.server_time || !envelope?.signature)
42
+ continue;
43
+ }
44
+ out[key] = rec.payload;
35
45
  }
36
46
  return out;
37
47
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Monotonic entitlement clock — own counter, never device wall-clock.
3
+ *
4
+ * Clients anchor to backend `server_time` on every entitlements sync and
5
+ * advance with a monotonic source (`performance.now()`). Wall-clock
6
+ * backdating cannot extend a trial: time only moves forward from the last
7
+ * anchored/seen server instant, and survives restarts via KV persistence.
8
+ */
9
+ export interface EntitlementClockAnchor {
10
+ /** Trusted server instant (ms since epoch) from the last envelope. */
11
+ serverMs: number;
12
+ /** Monotonic reading at the moment of anchoring. */
13
+ monoBase: number;
14
+ }
15
+ type MonoSource = () => number;
16
+ /** Test seam: inject a deterministic monotonic source. */
17
+ export declare function setMonoSource(fn: MonoSource | null): void;
18
+ export declare function monoNow(): number;
19
+ /**
20
+ * Anchor the clock to a trusted backend server_time.
21
+ * Always moves forward: a stale or replayed server_time (older than the
22
+ * current anchor) never rewinds the clock AND never resets the monotonic
23
+ * base — otherwise re-reading a cached envelope would erase elapsed
24
+ * offline time and grant perpetual access.
25
+ */
26
+ export declare function anchorServerTime(serverTimeIso: string): Promise<number>;
27
+ /**
28
+ * Current server-anchored instant (ms). Monotonic non-decreasing within
29
+ * what persistence allows. Falls back to wall-clock only when never anchored
30
+ * (first launch before any sync) — callers treat unanchored as unverified.
31
+ */
32
+ export declare function getServerNowMs(): Promise<number>;
33
+ /** True once at least one trusted server_time has been anchored. */
34
+ export declare function isAnchored(): Promise<boolean>;
35
+ /** Last trusted anchor instant (ms), or null when never synced. */
36
+ export declare function getLastAnchorMs(): Promise<number | null>;
37
+ /** Test seam: clear persisted clock state. */
38
+ export declare function resetEntitlementClock(): Promise<void>;
39
+ export {};
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Monotonic entitlement clock — own counter, never device wall-clock.
3
+ *
4
+ * Clients anchor to backend `server_time` on every entitlements sync and
5
+ * advance with a monotonic source (`performance.now()`). Wall-clock
6
+ * backdating cannot extend a trial: time only moves forward from the last
7
+ * anchored/seen server instant, and survives restarts via KV persistence.
8
+ */
9
+ import { kvGetItem, kvSetItem } from "../adapters/kv";
10
+ const ANCHOR_KEY = "sub:clock-anchor";
11
+ const LAST_SEEN_KEY = "sub:clock-last-seen";
12
+ let monoSource = null;
13
+ /** Test seam: inject a deterministic monotonic source. */
14
+ export function setMonoSource(fn) {
15
+ monoSource = fn;
16
+ }
17
+ export function monoNow() {
18
+ try {
19
+ if (monoSource)
20
+ return monoSource();
21
+ const g = globalThis;
22
+ if (typeof g.performance?.now === "function")
23
+ return g.performance.now();
24
+ }
25
+ catch {
26
+ /* fall through */
27
+ }
28
+ return Date.now();
29
+ }
30
+ async function readAnchor() {
31
+ try {
32
+ const raw = await kvGetItem(ANCHOR_KEY);
33
+ if (!raw)
34
+ return null;
35
+ const parsed = JSON.parse(raw);
36
+ if (!Number.isFinite(parsed.serverMs) || !Number.isFinite(parsed.monoBase))
37
+ return null;
38
+ return parsed;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ async function readLastSeen() {
45
+ try {
46
+ const raw = await kvGetItem(LAST_SEEN_KEY);
47
+ if (raw == null)
48
+ return null;
49
+ const n = Number(raw);
50
+ return Number.isFinite(n) ? n : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * Anchor the clock to a trusted backend server_time.
58
+ * Always moves forward: a stale or replayed server_time (older than the
59
+ * current anchor) never rewinds the clock AND never resets the monotonic
60
+ * base — otherwise re-reading a cached envelope would erase elapsed
61
+ * offline time and grant perpetual access.
62
+ */
63
+ export async function anchorServerTime(serverTimeIso) {
64
+ const serverMs = Date.parse(serverTimeIso);
65
+ const now = monoNow();
66
+ if (!Number.isFinite(serverMs))
67
+ return getServerNowMs();
68
+ const prev = await readAnchor();
69
+ const lastSeen = await readLastSeen();
70
+ if (prev && serverMs <= prev.serverMs)
71
+ return getServerNowMs();
72
+ const base = Math.max(serverMs, prev?.serverMs ?? -Infinity, lastSeen ?? -Infinity);
73
+ const anchor = { serverMs: base, monoBase: now };
74
+ try {
75
+ await kvSetItem(ANCHOR_KEY, JSON.stringify(anchor));
76
+ await kvSetItem(LAST_SEEN_KEY, String(base));
77
+ }
78
+ catch {
79
+ /* persistence is best-effort; in-memory math still holds for the session */
80
+ }
81
+ return base;
82
+ }
83
+ /**
84
+ * Current server-anchored instant (ms). Monotonic non-decreasing within
85
+ * what persistence allows. Falls back to wall-clock only when never anchored
86
+ * (first launch before any sync) — callers treat unanchored as unverified.
87
+ */
88
+ export async function getServerNowMs() {
89
+ const anchor = await readAnchor();
90
+ const lastSeen = await readLastSeen();
91
+ if (!anchor)
92
+ return lastSeen ?? Date.now();
93
+ let elapsed = monoNow() - anchor.monoBase;
94
+ // After a process restart performance.now() resets: elapsed goes negative.
95
+ // Clamp to zero so time never runs backwards; lastSeen still bounds it.
96
+ if (!Number.isFinite(elapsed) || elapsed < 0)
97
+ elapsed = 0;
98
+ const candidate = anchor.serverMs + elapsed;
99
+ const now = Math.max(candidate, lastSeen ?? -Infinity, anchor.serverMs);
100
+ return now;
101
+ }
102
+ /** True once at least one trusted server_time has been anchored. */
103
+ export async function isAnchored() {
104
+ return (await readAnchor()) != null;
105
+ }
106
+ /** Last trusted anchor instant (ms), or null when never synced. */
107
+ export async function getLastAnchorMs() {
108
+ const anchor = await readAnchor();
109
+ return anchor?.serverMs ?? null;
110
+ }
111
+ /** Test seam: clear persisted clock state. */
112
+ export async function resetEntitlementClock() {
113
+ const { kvRemoveItem } = await import("../adapters/kv");
114
+ await kvRemoveItem(ANCHOR_KEY);
115
+ await kvRemoveItem(LAST_SEEN_KEY);
116
+ }