@teincfood/core 0.8.4 → 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.
- package/dist/db/operations.d.ts +8 -0
- package/dist/db/operations.js +11 -0
- package/dist/domain/orders/service.js +4 -3
- package/dist/hooks/analytics.hooks.d.ts +72 -0
- package/dist/hooks/analytics.hooks.js +17 -8
- package/dist/hooks/dashboard.hooks.d.ts +14 -0
- package/dist/hooks/dashboard.hooks.js +14 -7
- package/dist/hooks/orders.hooks.js +6 -3
- package/dist/hooks/realtime-stubs.js +48 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/reference/api.js +12 -2
- package/dist/reference/entitlement-clock.d.ts +39 -0
- package/dist/reference/entitlement-clock.js +116 -0
- package/dist/reference/entitlement-crypto.d.ts +10 -0
- package/dist/reference/entitlement-crypto.js +111 -0
- package/dist/reference/entitlements.d.ts +51 -0
- package/dist/reference/entitlements.js +124 -0
- package/dist/reference/operations.d.ts +7 -0
- package/dist/reference/operations.js +26 -0
- package/dist/reference/service.js +33 -1
- package/dist/repositories/order.repository.js +11 -0
- package/dist/sync/command-queue.service.js +13 -13
- package/dist/sync/engine.js +18 -9
- package/dist/sync/online-gates.d.ts +9 -0
- package/dist/sync/online-gates.js +36 -3
- package/dist/sync/order-snapshots.service.d.ts +10 -0
- package/dist/sync/order-snapshots.service.js +96 -0
- package/dist/sync/sync-jobs.service.js +35 -8
- package/dist/sync/transition-endpoints.d.ts +9 -1
- package/dist/sync/transition-endpoints.js +16 -5
- package/dist/sync/transition-errors.d.ts +32 -0
- package/dist/sync/transition-errors.js +61 -0
- package/dist/sync/transport-rest.d.ts +5 -3
- package/dist/sync/transport-rest.js +10 -3
- package/dist/utils/error.js +3 -0
- package/dist/utils/order-transitions.js +17 -1
- package/package.json +1 -1
package/dist/db/operations.d.ts
CHANGED
|
@@ -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>;
|
package/dist/db/operations.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
47
|
+
metrics,
|
|
41
48
|
recent_orders: recent,
|
|
42
49
|
};
|
|
43
50
|
}
|
|
@@ -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");
|
|
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");
|
|
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";
|
package/dist/reference/api.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SHA-256 + HMAC-SHA256 (pure TS, no dependencies).
|
|
3
|
+
*
|
|
4
|
+
* Used to verify server-signed entitlement envelopes on every platform
|
|
5
|
+
* (Hermes / Tauri / Node) without relying on `crypto.subtle` availability.
|
|
6
|
+
* Test vectors: SHA256("") = e3b0c442...7852b855, HMAC per RFC 4231.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sha256Bytes(message: number[]): number[];
|
|
9
|
+
export declare function sha256Hex(input: string): string;
|
|
10
|
+
export declare function hmacSha256Hex(key: string, message: string): string;
|