@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.
@@ -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?: {
@@ -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
- const last4 = num.slice(-4);
67
- const n = parseInt(last4, 10);
68
- if (Number.isNaN(n))
69
- return last4;
70
- return String(n);
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
- push(business?.name || "Teinc Food");
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
- push(addr + LF);
233
+ pushT(addr + LF);
178
234
  const bPhone = business?.phone;
179
235
  if (bPhone)
180
- push(`Tel: ${bPhone}${LF}`);
236
+ pushT(`Tel: ${bPhone}${LF}`);
181
237
  push(GS_SIZE_NORMAL);
182
- push(doubleLineEscpos());
238
+ pushT(doubleLineEscpos());
183
239
  // Order info
184
240
  push(ESC_ALIGN_LEFT);
185
- push(`Order: ${order.order_number}${LF}`);
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
- push(`Callout: ${calloutNumber(order)}${LF}`);
245
+ pushT(`Callout: ${calloutNumber(order)}${LF}`);
190
246
  push(GS_SIZE_NORMAL);
191
247
  push(LF);
192
248
  push(ESC_ALIGN_LEFT);
193
- push(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
194
- push(`Type: ${orderTypeEscpos(order)}${LF}`);
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
- push(custLine);
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
- push(singleLineEscpos());
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
- push(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
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
- push(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
277
+ pushT(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
220
278
  }
221
279
  }
222
- push(singleLineEscpos());
280
+ pushT(singleLineEscpos());
223
281
  push(LF);
224
282
  // Subtotal section — mirrors subtotal_section_escpos/2
225
- push(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
283
+ pushT(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
226
284
  if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
227
- push(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
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
- push(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
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
- push(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
293
+ pushT(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
236
294
  }
237
295
  push(ESC_BOLD_ON);
238
- push(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
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
- push(paymentLineEscpos(order));
300
+ pushT(paymentLineEscpos(order));
243
301
  push(LF);
244
302
  // Footer
245
- push(doubleLineEscpos());
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
- price_adjustment?: number;
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 adjustment-shaped variants; absolute form prices are converted by
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
- // Matches backend serializer: variant price = base + adjustment.
33
- price: { amountMinor: input.priceMinor + (v.price_adjustment ?? 0), currencyCode },
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 adjustment-shaped variants; absolute form prices are converted by
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, price_adjustment: v.price_adjustment ?? 0 }
149
- : { name: v.name, price_adjustment: v.price_adjustment ?? 0 };
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);
@@ -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:create_online",
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
- "fleet:dispatch_create",
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
- price_adjustment: number;
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
- price_adjustment: number;
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` exactly:
5
- * - `manually_closed` → closed
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` exactly:
5
- * - `manually_closed` → closed
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;
@@ -19,6 +19,8 @@ export declare function validateMenuItemInput(input: {
19
19
  categoryId?: unknown;
20
20
  variants?: Array<{
21
21
  name?: unknown;
22
+ price?: unknown;
23
+ price_minor?: unknown;
22
24
  }>;
23
25
  extras?: Array<{
24
26
  name?: unknown;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.4",
3
+ "version": "0.9.7",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",