@teincfood/core 0.9.5 → 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" });
@@ -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.5",
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",