@teincfood/core 0.9.4 → 0.9.7
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/domain/orders/service.js +1 -1
- package/dist/hooks/business-hours.hooks.d.ts +2 -2
- package/dist/hooks/business-hours.hooks.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-node/service.js +11 -0
- package/dist/printer/receipt.d.ts +1 -0
- package/dist/printer/receipt.js +85 -27
- package/dist/reference/entitlement-guard.d.ts +31 -0
- package/dist/reference/entitlement-guard.js +83 -0
- package/dist/reference/menu-record.d.ts +2 -3
- package/dist/reference/menu-record.js +5 -6
- package/dist/sync/command-queue.service.js +1 -1
- package/dist/sync/engine.js +17 -0
- package/dist/sync/online-gates.js +2 -2
- package/dist/types/api/menu.api.d.ts +4 -2
- package/dist/utils/business-hours.d.ts +8 -4
- package/dist/utils/business-hours.js +8 -6
- package/dist/utils/order-transitions.d.ts +13 -0
- package/dist/utils/order-transitions.js +14 -0
- package/dist/validation/offline.d.ts +2 -0
- package/dist/validation/offline.js +6 -0
- package/package.json +1 -1
|
@@ -80,7 +80,7 @@ export async function createLocalOrder(businessId, input) {
|
|
|
80
80
|
// Operating-hours gate (fail-closed, offline-first): matches backend
|
|
81
81
|
// validate_business_open. Throws BUSINESS_CLOSED / HOURS_NOT_SYNCED before
|
|
82
82
|
// anything is written, so a doomed order never enters SQLite/sync_jobs.
|
|
83
|
-
await assertBusinessOpenForCreate(businessId);
|
|
83
|
+
await assertBusinessOpenForCreate(businessId, new Date(), "pos");
|
|
84
84
|
// DD lease required (backend lease, not local alloc) — keep leased DD even offline
|
|
85
85
|
let dd = getDeviceNumber(businessId);
|
|
86
86
|
if (!dd) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type BusinessOpenState } from "../utils/business-hours";
|
|
1
|
+
import { type BusinessOpenState, type OrderChannel } from "../utils/business-hours";
|
|
2
2
|
import type { OperatingHoursEntry } from "../types/api/business.api";
|
|
3
3
|
export declare const BUSINESS_HOURS_KEYS: {
|
|
4
4
|
all: readonly ["business-hours"];
|
|
5
5
|
open: (bid?: string) => readonly ["business-hours", "open", string | undefined];
|
|
6
6
|
};
|
|
7
|
-
export declare function useBusinessOpenNow(businessId?: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<BusinessOpenState, Error>;
|
|
7
|
+
export declare function useBusinessOpenNow(businessId?: string, enabled?: boolean, channel?: OrderChannel): import("@tanstack/react-query").UseQueryResult<BusinessOpenState, Error>;
|
|
8
8
|
export declare function useOperatingHours(businessId?: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<OperatingHoursEntry[], Error>;
|
|
@@ -11,13 +11,13 @@ export const BUSINESS_HOURS_KEYS = {
|
|
|
11
11
|
all: ["business-hours"],
|
|
12
12
|
open: (bid) => [...BUSINESS_HOURS_KEYS.all, "open", bid],
|
|
13
13
|
};
|
|
14
|
-
export function useBusinessOpenNow(businessId, enabled = true) {
|
|
14
|
+
export function useBusinessOpenNow(businessId, enabled = true, channel = "online") {
|
|
15
15
|
return useQuery({
|
|
16
|
-
queryKey: BUSINESS_HOURS_KEYS.open(businessId),
|
|
16
|
+
queryKey: [...BUSINESS_HOURS_KEYS.open(businessId), channel],
|
|
17
17
|
queryFn: async () => {
|
|
18
18
|
if (!businessId)
|
|
19
19
|
throw new Error("No business");
|
|
20
|
-
return isBusinessOpenNow(businessId);
|
|
20
|
+
return isBusinessOpenNow(businessId, new Date(), channel);
|
|
21
21
|
},
|
|
22
22
|
enabled: enabled && !!businessId,
|
|
23
23
|
staleTime: 30 * 1000,
|
package/dist/index.d.ts
CHANGED
|
@@ -98,6 +98,7 @@ export * from "./reference/menu-record";
|
|
|
98
98
|
export * from "./reference/menu-apply";
|
|
99
99
|
export * from "./reference/capabilities";
|
|
100
100
|
export * from "./reference/entitlements";
|
|
101
|
+
export * from "./reference/entitlement-guard";
|
|
101
102
|
export * from "./reference/entitlement-clock";
|
|
102
103
|
export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
|
|
103
104
|
export * from "./adapters/sqlite";
|
package/dist/index.js
CHANGED
|
@@ -100,6 +100,7 @@ export * from "./reference/menu-record";
|
|
|
100
100
|
export * from "./reference/menu-apply";
|
|
101
101
|
export * from "./reference/capabilities";
|
|
102
102
|
export * from "./reference/entitlements";
|
|
103
|
+
export * from "./reference/entitlement-guard";
|
|
103
104
|
export * from "./reference/entitlement-clock";
|
|
104
105
|
export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
|
|
105
106
|
// ── Adapters (for host apps to supply) ──
|
|
@@ -731,6 +731,17 @@ class LocalNodeServiceCore {
|
|
|
731
731
|
event = events[0] ?? null;
|
|
732
732
|
}
|
|
733
733
|
catch (error) {
|
|
734
|
+
// A 4xx is a backend rejection of THIS command (validation, auth,
|
|
735
|
+
// state) — not evidence the cloud is down. Rethrow without touching
|
|
736
|
+
// global reachability (mirrors sync engine); only network/5xx marks
|
|
737
|
+
// unreachable. Otherwise one rejected sync poisons every later
|
|
738
|
+
// resolveStrategy() into queueing and gates unrelated API ops.
|
|
739
|
+
const rejectionStatus = error?.status;
|
|
740
|
+
if (typeof rejectionStatus === "number" &&
|
|
741
|
+
rejectionStatus >= 400 &&
|
|
742
|
+
rejectionStatus < 500) {
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
734
745
|
syncLogger.warn("LocalNodeService", "Cloud forward failed, queueing", { error: String(error) });
|
|
735
746
|
try {
|
|
736
747
|
setConnectivityState({ isCloudReachable: "unreachable" });
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Keep this file in sync with backend when template changes.
|
|
8
8
|
*/
|
|
9
9
|
import type { SellerOrder } from "../types/api/seller.api";
|
|
10
|
+
export declare function escposText(s: string | null | undefined): string;
|
|
10
11
|
export declare function calloutNumber(order: SellerOrder): string;
|
|
11
12
|
export declare function generateEscpos(order: SellerOrder): string;
|
|
12
13
|
export declare function generateHtmlAsync(order: SellerOrder, opts?: {
|
package/dist/printer/receipt.js
CHANGED
|
@@ -18,9 +18,43 @@ const ESC_BOLD_OFF = "\x1B\x45\x00";
|
|
|
18
18
|
const GS_SIZE_NORMAL = "\x1D\x21\x00";
|
|
19
19
|
const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
|
|
20
20
|
const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
|
|
21
|
+
const GS_BAR_HEIGHT = "\x1D\x68\x3C";
|
|
22
|
+
const GS_BAR_WIDTH = "\x1D\x77\x02";
|
|
21
23
|
const LF = "\x0A";
|
|
22
24
|
const ESC_FEED_3 = "\x1B\x64\x03";
|
|
23
25
|
const GS_CUT_FULL = "\x1D\x56\x00";
|
|
26
|
+
/**
|
|
27
|
+
* Thermal printers speak bytes, not Unicode. Control sequences are pushed
|
|
28
|
+
* raw, but every human-readable text segment must pass through here first:
|
|
29
|
+
* known symbols map to ASCII, anything else non-ASCII becomes "?".
|
|
30
|
+
* Without this, ₵/═/─/accents print as gibberish (UTF-8 bytes read as CP437).
|
|
31
|
+
*/
|
|
32
|
+
const ESCPOS_REPLACEMENTS = [
|
|
33
|
+
[/[₵]/g, "GHc"],
|
|
34
|
+
[/[€]/g, "EUR"],
|
|
35
|
+
[/[£]/g, "GBP"],
|
|
36
|
+
[/[—–]/g, "-"],
|
|
37
|
+
[/[•·]/g, "*"],
|
|
38
|
+
[/[═╬]/g, "="],
|
|
39
|
+
[/[─│]/g, "-"],
|
|
40
|
+
[/[“”]/g, '"'],
|
|
41
|
+
[/[‘’`]/g, "'"],
|
|
42
|
+
[/[…]/g, "..."],
|
|
43
|
+
[/[×]/g, "x"],
|
|
44
|
+
[/[°]/g, "deg"],
|
|
45
|
+
[/ /g, " "],
|
|
46
|
+
];
|
|
47
|
+
export function escposText(s) {
|
|
48
|
+
if (s == null)
|
|
49
|
+
return "";
|
|
50
|
+
let out = String(s);
|
|
51
|
+
for (const [re, sub] of ESCPOS_REPLACEMENTS)
|
|
52
|
+
out = out.replace(re, sub);
|
|
53
|
+
// Strip any remaining non-ASCII (accents, emoji, CJK) — the printer cannot
|
|
54
|
+
// render them and would print garbage bytes.
|
|
55
|
+
out = out.replace(/[^\x20-\x7E\n]/g, "?");
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
24
58
|
function padR(text, width) {
|
|
25
59
|
if (text.length >= width)
|
|
26
60
|
return text.slice(0, width);
|
|
@@ -60,14 +94,19 @@ function formatDatetime(iso) {
|
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
export function calloutNumber(order) {
|
|
97
|
+
// The backend assigns a real callout_number — always prefer it.
|
|
98
|
+
const direct = order.callout_number;
|
|
99
|
+
if (typeof direct === "number" && Number.isFinite(direct))
|
|
100
|
+
return String(Math.abs(direct));
|
|
63
101
|
const num = order.order_number || "";
|
|
64
102
|
if (!num)
|
|
65
103
|
return "----";
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
// Strip non-digits first: local DD-RRR-CCC numbers end in "-CCC" and
|
|
105
|
+
// parseInt("-005") would give -5 (the old "-5" bug).
|
|
106
|
+
const digits = num.slice(-4).replace(/\D/g, "");
|
|
107
|
+
if (!digits)
|
|
108
|
+
return num.slice(-4);
|
|
109
|
+
return String(parseInt(digits, 10));
|
|
71
110
|
}
|
|
72
111
|
function itemName(item) {
|
|
73
112
|
return item.menu_item?.name || "(deleted item)";
|
|
@@ -115,8 +154,19 @@ function businessAddress(business) {
|
|
|
115
154
|
return null;
|
|
116
155
|
if (b.location?.address)
|
|
117
156
|
return b.location.address;
|
|
118
|
-
if (b.address)
|
|
157
|
+
if (typeof b.address === "string")
|
|
119
158
|
return b.address;
|
|
159
|
+
// Desktop snapshots carry a structured address object — flatten it
|
|
160
|
+
// (printing the object itself would render "[object Object]").
|
|
161
|
+
if (b.address && typeof b.address === "object") {
|
|
162
|
+
if (b.address.fullAddress)
|
|
163
|
+
return b.address.fullAddress;
|
|
164
|
+
if (b.address.full_address)
|
|
165
|
+
return b.address.full_address;
|
|
166
|
+
const parts = [b.address.street, b.address.city].filter(Boolean);
|
|
167
|
+
if (parts.length)
|
|
168
|
+
return parts.join(", ");
|
|
169
|
+
}
|
|
120
170
|
return null;
|
|
121
171
|
}
|
|
122
172
|
function businessLogoUrl(order) {
|
|
@@ -165,39 +215,47 @@ export function generateEscpos(order) {
|
|
|
165
215
|
const parts = [];
|
|
166
216
|
const push = (s) => { if (s != null)
|
|
167
217
|
parts.push(s); };
|
|
218
|
+
// Sanitized text push: every human-readable segment goes through escposText
|
|
219
|
+
// (control codes and barcode data use raw push).
|
|
220
|
+
const pushT = (s) => { if (s != null)
|
|
221
|
+
parts.push(escposText(s)); };
|
|
168
222
|
// Header — matches receipt.ex to_escpos/1
|
|
223
|
+
// NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
|
|
224
|
+
// a raster pipeline (GS v 0). HTML receipts embed the logo instead.
|
|
169
225
|
push(ESC_INIT);
|
|
170
226
|
push(ESC_ALIGN_CENTER);
|
|
171
227
|
push(GS_SIZE_DOUBLE_WH);
|
|
172
|
-
|
|
228
|
+
pushT(business?.name || "Teinc Food");
|
|
173
229
|
push(LF);
|
|
174
230
|
push(GS_SIZE_NORMAL);
|
|
175
231
|
const addr = businessAddress(business);
|
|
176
232
|
if (addr)
|
|
177
|
-
|
|
233
|
+
pushT(addr + LF);
|
|
178
234
|
const bPhone = business?.phone;
|
|
179
235
|
if (bPhone)
|
|
180
|
-
|
|
236
|
+
pushT(`Tel: ${bPhone}${LF}`);
|
|
181
237
|
push(GS_SIZE_NORMAL);
|
|
182
|
-
|
|
238
|
+
pushT(doubleLineEscpos());
|
|
183
239
|
// Order info
|
|
184
240
|
push(ESC_ALIGN_LEFT);
|
|
185
|
-
|
|
241
|
+
pushT(`Order: ${order.order_number}${LF}`);
|
|
186
242
|
// callout_box_escpos
|
|
187
243
|
push(ESC_ALIGN_CENTER);
|
|
188
244
|
push(GS_SIZE_DOUBLE_W);
|
|
189
|
-
|
|
245
|
+
pushT(`Callout: ${calloutNumber(order)}${LF}`);
|
|
190
246
|
push(GS_SIZE_NORMAL);
|
|
191
247
|
push(LF);
|
|
192
248
|
push(ESC_ALIGN_LEFT);
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
pushT(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
|
|
250
|
+
pushT(`Type: ${orderTypeEscpos(order)}${LF}`);
|
|
195
251
|
const custLine = customerLineEscpos(order);
|
|
196
252
|
if (custLine)
|
|
197
|
-
|
|
253
|
+
pushT(custLine);
|
|
198
254
|
push(LF);
|
|
199
|
-
// Barcode — CODE128 GS k 73
|
|
255
|
+
// Barcode — CODE128 GS k 73, sized down (default height is huge).
|
|
200
256
|
push(ESC_ALIGN_CENTER);
|
|
257
|
+
push(GS_BAR_HEIGHT);
|
|
258
|
+
push(GS_BAR_WIDTH);
|
|
201
259
|
const data = order.order_number || "";
|
|
202
260
|
push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
|
|
203
261
|
push(LF);
|
|
@@ -205,44 +263,44 @@ export function generateEscpos(order) {
|
|
|
205
263
|
push(ESC_ALIGN_LEFT);
|
|
206
264
|
push(`${padR("Item", 16)} Qty Price`);
|
|
207
265
|
push(LF);
|
|
208
|
-
|
|
266
|
+
pushT(singleLineEscpos());
|
|
209
267
|
push(LF);
|
|
210
268
|
for (const item of items) {
|
|
211
269
|
const qty = item.quantity || 1;
|
|
212
270
|
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
213
|
-
|
|
271
|
+
pushT(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
|
|
214
272
|
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
215
273
|
for (const addon of addons) {
|
|
216
274
|
const aName = addonName(addon);
|
|
217
275
|
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
218
276
|
const addonPrice = formatMoney(aPriceMinor, order);
|
|
219
|
-
|
|
277
|
+
pushT(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
|
|
220
278
|
}
|
|
221
279
|
}
|
|
222
|
-
|
|
280
|
+
pushT(singleLineEscpos());
|
|
223
281
|
push(LF);
|
|
224
282
|
// Subtotal section — mirrors subtotal_section_escpos/2
|
|
225
|
-
|
|
283
|
+
pushT(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
|
|
226
284
|
if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
|
|
227
|
-
|
|
285
|
+
pushT(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
|
|
228
286
|
}
|
|
229
287
|
for (const tax of taxLines) {
|
|
230
288
|
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
231
289
|
const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
|
|
232
|
-
|
|
290
|
+
pushT(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
|
|
233
291
|
}
|
|
234
292
|
if (order.discount_minor && order.discount_minor > 0) {
|
|
235
|
-
|
|
293
|
+
pushT(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
|
|
236
294
|
}
|
|
237
295
|
push(ESC_BOLD_ON);
|
|
238
|
-
|
|
296
|
+
pushT(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
|
|
239
297
|
push(ESC_BOLD_OFF);
|
|
240
298
|
push(LF);
|
|
241
299
|
push(ESC_ALIGN_CENTER);
|
|
242
|
-
|
|
300
|
+
pushT(paymentLineEscpos(order));
|
|
243
301
|
push(LF);
|
|
244
302
|
// Footer
|
|
245
|
-
|
|
303
|
+
pushT(doubleLineEscpos());
|
|
246
304
|
push("Thank you for your order!" + LF);
|
|
247
305
|
push("Come back soon!" + LF);
|
|
248
306
|
push(ESC_FEED_3);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline entitlement auto-guard — evaluates the cached signed
|
|
3
|
+
* `subscription:entitlements` envelope and enforces gated commands locally,
|
|
4
|
+
* before any transport is attempted. The backend remains the final authority
|
|
5
|
+
* (HTTP 402 SUBSCRIPTION_REQUIRED); this guard gives immediate feedback and
|
|
6
|
+
* keeps disallowed work out of the outbox.
|
|
7
|
+
*
|
|
8
|
+
* Fail-closed: missing envelope, missing secret, bad signature, expired, or
|
|
9
|
+
* stale (> max_offline_days) => gated commands throw SUBSCRIPTION_REQUIRED.
|
|
10
|
+
* The host app provisions the HMAC secret at boot via setEntitlementSecret
|
|
11
|
+
* and refreshes the envelope via reference sync; useEntitlements surfaces
|
|
12
|
+
* mustReconnect/paywall state for UI gates.
|
|
13
|
+
*/
|
|
14
|
+
import { type EntitlementEvaluation } from "./entitlements";
|
|
15
|
+
/** Provisioned once at app boot from the host app's env. */
|
|
16
|
+
export declare function setEntitlementSecret(secret: string | null): void;
|
|
17
|
+
export declare function getEntitlementSecret(): string | null;
|
|
18
|
+
export interface BusinessEntitlementState {
|
|
19
|
+
evaluation: EntitlementEvaluation;
|
|
20
|
+
features: Record<string, unknown> | null;
|
|
21
|
+
}
|
|
22
|
+
/** Evaluate the cached signed envelope for a business (no network). */
|
|
23
|
+
export declare function evaluateBusinessEntitlements(businessId: string): Promise<BusinessEntitlementState>;
|
|
24
|
+
export declare function isEntitlementGated(commandType: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Throw SUBSCRIPTION_REQUIRED unless the cached entitlement proof covers
|
|
27
|
+
* this command. No-ops for non-gated commands — and when no envelope was
|
|
28
|
+
* ever cached (fresh terminal that never synced): the backend remains the
|
|
29
|
+
* authority there. Once proof exists, invalid/expired/stale proof denies.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertEntitledForCommand(businessId: string, commandType: string): Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline entitlement auto-guard — evaluates the cached signed
|
|
3
|
+
* `subscription:entitlements` envelope and enforces gated commands locally,
|
|
4
|
+
* before any transport is attempted. The backend remains the final authority
|
|
5
|
+
* (HTTP 402 SUBSCRIPTION_REQUIRED); this guard gives immediate feedback and
|
|
6
|
+
* keeps disallowed work out of the outbox.
|
|
7
|
+
*
|
|
8
|
+
* Fail-closed: missing envelope, missing secret, bad signature, expired, or
|
|
9
|
+
* stale (> max_offline_days) => gated commands throw SUBSCRIPTION_REQUIRED.
|
|
10
|
+
* The host app provisions the HMAC secret at boot via setEntitlementSecret
|
|
11
|
+
* and refreshes the envelope via reference sync; useEntitlements surfaces
|
|
12
|
+
* mustReconnect/paywall state for UI gates.
|
|
13
|
+
*/
|
|
14
|
+
import { openSyncDatabase } from "../db/connection";
|
|
15
|
+
import { getReferenceData } from "../reference/operations";
|
|
16
|
+
import { evaluateEntitlements, getEntitledFeature, } from "./entitlements";
|
|
17
|
+
import { assertEntitledFor } from "../sync/online-gates";
|
|
18
|
+
let entitlementSecret = null;
|
|
19
|
+
/** Provisioned once at app boot from the host app's env. */
|
|
20
|
+
export function setEntitlementSecret(secret) {
|
|
21
|
+
entitlementSecret = secret;
|
|
22
|
+
}
|
|
23
|
+
export function getEntitlementSecret() {
|
|
24
|
+
return entitlementSecret;
|
|
25
|
+
}
|
|
26
|
+
/** Evaluate the cached signed envelope for a business (no network). */
|
|
27
|
+
export async function evaluateBusinessEntitlements(businessId) {
|
|
28
|
+
const db = await openSyncDatabase();
|
|
29
|
+
const rec = await getReferenceData(db, businessId, "subscription:entitlements");
|
|
30
|
+
const payload = (rec?.payload ?? null);
|
|
31
|
+
const evaluation = await evaluateEntitlements({
|
|
32
|
+
businessId,
|
|
33
|
+
envelope: payload?.envelope,
|
|
34
|
+
features: payload?.data,
|
|
35
|
+
secret: entitlementSecret ?? "",
|
|
36
|
+
});
|
|
37
|
+
return { evaluation, features: payload?.data ?? null };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Per-command feature keys. Commands without an entry require overall
|
|
41
|
+
* `entitled` only (counts/quotas stay backend-enforced). delivery:assign is
|
|
42
|
+
* intentionally overall-only: merchants assigning their own riders are not
|
|
43
|
+
* subject to the fleet feature (backend scopes fleet.dispatch to
|
|
44
|
+
* delivery_provider businesses the same way).
|
|
45
|
+
*/
|
|
46
|
+
const FEATURE_BY_COMMAND = {
|
|
47
|
+
"promo:create": "seller.promo",
|
|
48
|
+
};
|
|
49
|
+
/** Real engine command types subject to the entitlement guard. */
|
|
50
|
+
const GATED_COMMANDS = new Set([
|
|
51
|
+
"order:create",
|
|
52
|
+
"menu:create_item",
|
|
53
|
+
"menu:update_item",
|
|
54
|
+
"menu:delete_item",
|
|
55
|
+
"menu:create_category",
|
|
56
|
+
"promo:create",
|
|
57
|
+
"member:invite",
|
|
58
|
+
"member:manage",
|
|
59
|
+
"terminal:link",
|
|
60
|
+
"delivery:assign",
|
|
61
|
+
]);
|
|
62
|
+
export function isEntitlementGated(commandType) {
|
|
63
|
+
return GATED_COMMANDS.has(commandType);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Throw SUBSCRIPTION_REQUIRED unless the cached entitlement proof covers
|
|
67
|
+
* this command. No-ops for non-gated commands — and when no envelope was
|
|
68
|
+
* ever cached (fresh terminal that never synced): the backend remains the
|
|
69
|
+
* authority there. Once proof exists, invalid/expired/stale proof denies.
|
|
70
|
+
*/
|
|
71
|
+
export async function assertEntitledForCommand(businessId, commandType) {
|
|
72
|
+
if (!isEntitlementGated(commandType))
|
|
73
|
+
return;
|
|
74
|
+
const db = await openSyncDatabase();
|
|
75
|
+
const rec = await getReferenceData(db, businessId, "subscription:entitlements");
|
|
76
|
+
if (!rec?.payload)
|
|
77
|
+
return;
|
|
78
|
+
const { evaluation, features } = await evaluateBusinessEntitlements(businessId);
|
|
79
|
+
const feature = FEATURE_BY_COMMAND[commandType];
|
|
80
|
+
const entitled = evaluation.entitled &&
|
|
81
|
+
(!feature || getEntitledFeature(evaluation, features, feature));
|
|
82
|
+
assertEntitledFor(commandType, entitled, evaluation.reason);
|
|
83
|
+
}
|
|
@@ -22,7 +22,7 @@ export interface MenuItemRecordInput {
|
|
|
22
22
|
variants?: Array<{
|
|
23
23
|
id?: string;
|
|
24
24
|
name: string;
|
|
25
|
-
|
|
25
|
+
price?: number;
|
|
26
26
|
}>;
|
|
27
27
|
extras?: Array<{
|
|
28
28
|
id?: string;
|
|
@@ -51,8 +51,7 @@ export interface MenuItemEnvelopeInput extends MenuItemRecordInput {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
54
|
-
* backend
|
|
55
|
-
* the caller (form layer owns both fields).
|
|
54
|
+
* absolute variant prices (backend model) — the form layer owns the field.
|
|
56
55
|
*
|
|
57
56
|
* Variant/extra ids are preserved when they are backend UUIDs. The backend
|
|
58
57
|
* update path deletes existing rows absent from the incoming list, so
|
|
@@ -29,8 +29,8 @@ export function buildMenuItemRecord(input) {
|
|
|
29
29
|
variants: (input.variants ?? []).map((v) => ({
|
|
30
30
|
id: v.id ?? generateUUID(),
|
|
31
31
|
name: v.name,
|
|
32
|
-
//
|
|
33
|
-
price: { amountMinor:
|
|
32
|
+
// Absolute variant price (backend model) — no size defaults to base.
|
|
33
|
+
price: { amountMinor: v.price ?? input.priceMinor, currencyCode },
|
|
34
34
|
})),
|
|
35
35
|
extras: (input.extras ?? []).map((e) => ({
|
|
36
36
|
id: e.id ?? generateUUID(),
|
|
@@ -123,8 +123,7 @@ function wireId(id) {
|
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
126
|
-
* backend
|
|
127
|
-
* the caller (form layer owns both fields).
|
|
126
|
+
* absolute variant prices (backend model) — the form layer owns the field.
|
|
128
127
|
*
|
|
129
128
|
* Variant/extra ids are preserved when they are backend UUIDs. The backend
|
|
130
129
|
* update path deletes existing rows absent from the incoming list, so
|
|
@@ -145,8 +144,8 @@ export function buildMenuItemEnvelope(input) {
|
|
|
145
144
|
variants: (input.variants ?? []).map((v) => {
|
|
146
145
|
const vid = wireId(v.id);
|
|
147
146
|
return vid
|
|
148
|
-
? { id: vid, name: v.name,
|
|
149
|
-
: { name: v.name,
|
|
147
|
+
? { id: vid, name: v.name, price: v.price ?? input.priceMinor }
|
|
148
|
+
: { name: v.name, price: v.price ?? input.priceMinor };
|
|
150
149
|
}),
|
|
151
150
|
extras: (input.extras ?? []).map((e) => {
|
|
152
151
|
const eid = wireId(e.id);
|
|
@@ -210,7 +210,7 @@ class CommandQueueService {
|
|
|
210
210
|
// types (transitions, payments, delivery) are unaffected so in-progress
|
|
211
211
|
// orders can still be completed while closed.
|
|
212
212
|
if (command.command_type === "order:create") {
|
|
213
|
-
await assertBusinessOpenForCreate(command.business_id);
|
|
213
|
+
await assertBusinessOpenForCreate(command.business_id, new Date(), "pos");
|
|
214
214
|
}
|
|
215
215
|
const db = getSyncDatabase();
|
|
216
216
|
const preparedCommand = injectLocalOrderNumber(command);
|
package/dist/sync/engine.js
CHANGED
|
@@ -24,6 +24,7 @@ import { getReferenceData } from "../reference/operations";
|
|
|
24
24
|
import { openSyncDatabase } from "../db/connection";
|
|
25
25
|
import { canBusinessSync } from "./sync-guard";
|
|
26
26
|
import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
|
|
27
|
+
import { assertEntitledForCommand } from "../reference/entitlement-guard";
|
|
27
28
|
function withTimeout(promise, ms) {
|
|
28
29
|
return new Promise((resolve, reject) => {
|
|
29
30
|
const timer = setTimeout(() => {
|
|
@@ -79,6 +80,22 @@ export class SyncEngine {
|
|
|
79
80
|
// otherwise fail open
|
|
80
81
|
}
|
|
81
82
|
}
|
|
83
|
+
// Entitlement guard (fail-closed, offline-first): subscription-gated
|
|
84
|
+
// commands need cached proof before routing. Backend 402 backstops.
|
|
85
|
+
try {
|
|
86
|
+
await assertEntitledForCommand(command.business_id, command.command_type);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e instanceof TransitionError &&
|
|
90
|
+
e.code === TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED) {
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
// Guard infra failure (no DB yet, corrupt cache) must not brick the
|
|
94
|
+
// terminal — backend remains the authority. Log and continue.
|
|
95
|
+
syncLogger.warn("SyncEngine", "entitlement guard skipped", {
|
|
96
|
+
error: e instanceof Error ? e.message : String(e),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
82
99
|
let strategy = this.resolveStrategy(command);
|
|
83
100
|
const { isCloudReachable, isOnline } = getConnectivityState();
|
|
84
101
|
syncLogger.info("SyncEngine", "Command strategy resolved", {
|
|
@@ -69,7 +69,7 @@ export function assertOnlineFor(commandType, payload) {
|
|
|
69
69
|
*/
|
|
70
70
|
export function assertEntitledFor(commandType, entitled, evaluationReason) {
|
|
71
71
|
const gated = new Set([
|
|
72
|
-
"order:
|
|
72
|
+
"order:create",
|
|
73
73
|
"menu:create_item",
|
|
74
74
|
"menu:update_item",
|
|
75
75
|
"menu:delete_item",
|
|
@@ -78,7 +78,7 @@ export function assertEntitledFor(commandType, entitled, evaluationReason) {
|
|
|
78
78
|
"member:invite",
|
|
79
79
|
"member:manage",
|
|
80
80
|
"terminal:link",
|
|
81
|
-
"
|
|
81
|
+
"delivery:assign",
|
|
82
82
|
]);
|
|
83
83
|
if (!gated.has(commandType))
|
|
84
84
|
return;
|
|
@@ -52,7 +52,8 @@ export interface MenuItemListResponse {
|
|
|
52
52
|
export interface MenuItemVariant {
|
|
53
53
|
id: string;
|
|
54
54
|
name: string;
|
|
55
|
-
|
|
55
|
+
/** Absolute variant price in minor units (replaces the base price). */
|
|
56
|
+
price: number;
|
|
56
57
|
}
|
|
57
58
|
export interface MenuItemAddon {
|
|
58
59
|
id: string;
|
|
@@ -80,7 +81,8 @@ export interface CatalogItemRequest {
|
|
|
80
81
|
variants: {
|
|
81
82
|
id?: string;
|
|
82
83
|
name: string;
|
|
83
|
-
|
|
84
|
+
/** Absolute variant price in minor units (replaces the base price). */
|
|
85
|
+
price: number;
|
|
84
86
|
}[];
|
|
85
87
|
extras: {
|
|
86
88
|
id?: string;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Business open/closed evaluation — single source for the lib.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors backend `TeincFoodBackend.Businesses.is_open?/1`
|
|
5
|
-
*
|
|
4
|
+
* Mirrors backend `TeincFoodBackend.Businesses.is_open?/1` for the `online`
|
|
5
|
+
* channel and `open_for_pos?/1` for the `pos` channel:
|
|
6
|
+
* - `manually_closed` → closed for online; IGNORED for pos (temporary
|
|
7
|
+
* online shutdown must not stop the POS terminal)
|
|
6
8
|
* - else UTC clock: day = Elixir `Date.day_of_week - 1` (Monday=0 … Sunday=6),
|
|
7
9
|
* a row matches when `day_of_week` equals today, `is_closed` is false and
|
|
8
10
|
* `open_time <= now < close_time` (open inclusive, close exclusive).
|
|
@@ -13,6 +15,8 @@
|
|
|
13
15
|
* fixed here): overnight ranges (close <= open) never match.
|
|
14
16
|
*/
|
|
15
17
|
export type BusinessClosedReason = "manually_closed" | "outside_hours" | "no_hours_configured";
|
|
18
|
+
/** Order channel: online buyer orders vs in-store POS orders. */
|
|
19
|
+
export type OrderChannel = "online" | "pos";
|
|
16
20
|
export interface BusinessOpenState {
|
|
17
21
|
open: boolean;
|
|
18
22
|
reason?: BusinessClosedReason;
|
|
@@ -27,11 +31,11 @@ export declare class BusinessHoursError extends Error {
|
|
|
27
31
|
reason?: BusinessClosedReason;
|
|
28
32
|
constructor(code: "BUSINESS_CLOSED" | "HOURS_NOT_SYNCED", message: string, reason?: BusinessClosedReason);
|
|
29
33
|
}
|
|
30
|
-
export declare function isBusinessOpenNow(businessId: string, now?: Date): Promise<BusinessOpenState>;
|
|
34
|
+
export declare function isBusinessOpenNow(businessId: string, now?: Date, channel?: OrderChannel): Promise<BusinessOpenState>;
|
|
31
35
|
/**
|
|
32
36
|
* Guard for order creation — fail-closed. Throws `BusinessHoursError` with
|
|
33
37
|
* code `HOURS_NOT_SYNCED` when no hours are cached (configure in Settings,
|
|
34
38
|
* works offline) or `BUSINESS_CLOSED` when the business is closed.
|
|
35
39
|
* Backend remains the final gate for clock-skew edge cases.
|
|
36
40
|
*/
|
|
37
|
-
export declare function assertBusinessOpenForCreate(businessId: string, now?: Date): Promise<BusinessOpenState>;
|
|
41
|
+
export declare function assertBusinessOpenForCreate(businessId: string, now?: Date, channel?: OrderChannel): Promise<BusinessOpenState>;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Business open/closed evaluation — single source for the lib.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors backend `TeincFoodBackend.Businesses.is_open?/1`
|
|
5
|
-
*
|
|
4
|
+
* Mirrors backend `TeincFoodBackend.Businesses.is_open?/1` for the `online`
|
|
5
|
+
* channel and `open_for_pos?/1` for the `pos` channel:
|
|
6
|
+
* - `manually_closed` → closed for online; IGNORED for pos (temporary
|
|
7
|
+
* online shutdown must not stop the POS terminal)
|
|
6
8
|
* - else UTC clock: day = Elixir `Date.day_of_week - 1` (Monday=0 … Sunday=6),
|
|
7
9
|
* a row matches when `day_of_week` equals today, `is_closed` is false and
|
|
8
10
|
* `open_time <= now < close_time` (open inclusive, close exclusive).
|
|
@@ -41,7 +43,7 @@ function normalizeEntries(payload) {
|
|
|
41
43
|
return data;
|
|
42
44
|
return [];
|
|
43
45
|
}
|
|
44
|
-
export async function isBusinessOpenNow(businessId, now = new Date()) {
|
|
46
|
+
export async function isBusinessOpenNow(businessId, now = new Date(), channel = "online") {
|
|
45
47
|
const evaluatedAt = new Date().toISOString();
|
|
46
48
|
// Backend: Date.day_of_week (Mon=1..Sun=7) minus 1 → Mon=0..Sun=6 (UTC).
|
|
47
49
|
const dayOfWeek = (now.getUTCDay() + 6) % 7;
|
|
@@ -56,7 +58,7 @@ export async function isBusinessOpenNow(businessId, now = new Date()) {
|
|
|
56
58
|
const profileData = profile && typeof profile === "object" && "data" in profile && profile.data !== undefined
|
|
57
59
|
? profile.data
|
|
58
60
|
: (profile ?? {});
|
|
59
|
-
if (profileData.manually_closed === true) {
|
|
61
|
+
if (profileData.manually_closed === true && channel !== "pos") {
|
|
60
62
|
return { open: false, reason: "manually_closed", dayOfWeek, timeUtc, evaluatedAt };
|
|
61
63
|
}
|
|
62
64
|
const entries = normalizeEntries(hoursRec?.payload);
|
|
@@ -82,8 +84,8 @@ export async function isBusinessOpenNow(businessId, now = new Date()) {
|
|
|
82
84
|
* works offline) or `BUSINESS_CLOSED` when the business is closed.
|
|
83
85
|
* Backend remains the final gate for clock-skew edge cases.
|
|
84
86
|
*/
|
|
85
|
-
export async function assertBusinessOpenForCreate(businessId, now = new Date()) {
|
|
86
|
-
const state = await isBusinessOpenNow(businessId, now);
|
|
87
|
+
export async function assertBusinessOpenForCreate(businessId, now = new Date(), channel = "online") {
|
|
88
|
+
const state = await isBusinessOpenNow(businessId, now, channel);
|
|
87
89
|
if (state.reason === "no_hours_configured") {
|
|
88
90
|
throw new BusinessHoursError("HOURS_NOT_SYNCED", "Operating hours are not available offline — configure them in Settings (works offline) or sync once before taking orders", state.reason);
|
|
89
91
|
}
|
|
@@ -12,6 +12,19 @@ export declare const ORDER_STATUSES: readonly ["pending", "awaiting_payment", "a
|
|
|
12
12
|
export type OrderStatus = (typeof ORDER_STATUSES)[number];
|
|
13
13
|
export declare const CANCELLABLE_STATUSES: readonly ["pending", "awaiting_payment", "accepted", "preparing", "ready"];
|
|
14
14
|
export declare const PIPELINE: readonly ["pending", "accepted", "preparing", "ready", "delivered"];
|
|
15
|
+
export type StageState = "done" | "current" | "upcoming";
|
|
16
|
+
export interface PipelineStage {
|
|
17
|
+
status: string;
|
|
18
|
+
state: StageState;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Derives hierarchical stage states for `current` over `steps`
|
|
22
|
+
* (defaults to PIPELINE). Stages before the current index are `done`,
|
|
23
|
+
* the current index is `current`, later stages are `upcoming`.
|
|
24
|
+
* Unknown statuses (e.g. cancelled, awaiting_payment) yield all `upcoming`
|
|
25
|
+
* so callers can apply their own mapping first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stageStatuses(current: string, steps?: readonly string[]): PipelineStage[];
|
|
15
28
|
export type OrderAction = "accept" | "reject" | "start_preparing" | "mark_ready" | "complete_pickup" | "cancel" | "assign_rider" | "confirm_handover" | "confirm_cash" | "pay";
|
|
16
29
|
export declare function isUnpaid(order: SellerOrder): boolean;
|
|
17
30
|
export declare function isCancellable(status: string): boolean;
|
|
@@ -19,6 +19,20 @@ export const CANCELLABLE_STATUSES = [
|
|
|
19
19
|
"ready",
|
|
20
20
|
];
|
|
21
21
|
export const PIPELINE = ["pending", "accepted", "preparing", "ready", "delivered"];
|
|
22
|
+
/**
|
|
23
|
+
* Derives hierarchical stage states for `current` over `steps`
|
|
24
|
+
* (defaults to PIPELINE). Stages before the current index are `done`,
|
|
25
|
+
* the current index is `current`, later stages are `upcoming`.
|
|
26
|
+
* Unknown statuses (e.g. cancelled, awaiting_payment) yield all `upcoming`
|
|
27
|
+
* so callers can apply their own mapping first.
|
|
28
|
+
*/
|
|
29
|
+
export function stageStatuses(current, steps = PIPELINE) {
|
|
30
|
+
const idx = steps.indexOf(current);
|
|
31
|
+
return steps.map((status, i) => ({
|
|
32
|
+
status,
|
|
33
|
+
state: idx < 0 || i > idx ? "upcoming" : i === idx ? "current" : "done",
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
22
36
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
23
37
|
export function isUnpaid(order) {
|
|
24
38
|
const o = order;
|
|
@@ -41,6 +41,12 @@ export function validateMenuItemInput(input) {
|
|
|
41
41
|
for (const v of input.variants ?? []) {
|
|
42
42
|
if (!v || typeof v.name !== "string" || !v.name.trim())
|
|
43
43
|
fail("variants", "Variant name is required", details);
|
|
44
|
+
// Absolute variant price mirrors backend (must be 0 or more when given).
|
|
45
|
+
// Accepts both envelope shape (price) and form shape (price_minor).
|
|
46
|
+
const vp = v.price ?? v.price_minor;
|
|
47
|
+
if (vp !== undefined && (typeof vp !== "number" || !Number.isFinite(vp) || vp < 0)) {
|
|
48
|
+
fail("variants", "Variant price must be 0 or more", details);
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
for (const e of input.extras ?? []) {
|
|
46
52
|
if (!e || typeof e.name !== "string" || !e.name.trim())
|
package/package.json
CHANGED