@teincfood/core 0.9.5 → 0.9.8
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/hooks/delivery.hooks.d.ts +24 -24
- package/dist/hooks/rider.hooks.d.ts +48 -48
- package/dist/hooks/use-infinite-list.d.ts +24 -24
- package/dist/hooks/wallet.hooks.d.ts +24 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/local-node/service.js +11 -0
- package/dist/printer/company.d.ts +21 -0
- package/dist/printer/company.js +30 -0
- package/dist/printer/receipt.d.ts +13 -7
- package/dist/printer/receipt.js +186 -43
- package/dist/printer/service.d.ts +5 -1
- package/dist/printer/service.js +14 -4
- 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/types/api/seller.api.d.ts +15 -0
- 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
|
@@ -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;
|
|
@@ -20,6 +20,12 @@ export interface OrderMenuItem {
|
|
|
20
20
|
export interface OrderItemAddon {
|
|
21
21
|
id: string;
|
|
22
22
|
price: number;
|
|
23
|
+
/** Minor-unit price (backend order_item_addons carry price_minor). */
|
|
24
|
+
price_minor?: number;
|
|
25
|
+
name?: string;
|
|
26
|
+
addon?: {
|
|
27
|
+
name?: string;
|
|
28
|
+
} | null;
|
|
23
29
|
}
|
|
24
30
|
export interface OrderItem {
|
|
25
31
|
id: string;
|
|
@@ -28,11 +34,20 @@ export interface OrderItem {
|
|
|
28
34
|
unit_price: number;
|
|
29
35
|
subtotal: number;
|
|
30
36
|
addons?: OrderItemAddon[];
|
|
37
|
+
/** Backend order_items preload :variant (absolute size, has name). */
|
|
38
|
+
variant?: {
|
|
39
|
+
id?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
/** Backend shape for addon lines. */
|
|
43
|
+
order_item_addons?: OrderItemAddon[];
|
|
31
44
|
}
|
|
32
45
|
export interface OrderBusiness {
|
|
33
46
|
id: string;
|
|
34
47
|
name: string;
|
|
35
48
|
phone?: string;
|
|
49
|
+
/** Base64 `GS v 0` raster block for the thermal header (synced via business:profile). */
|
|
50
|
+
logo_escpos?: string | null;
|
|
36
51
|
latitude?: number;
|
|
37
52
|
longitude?: number;
|
|
38
53
|
estimated_delivery_time?: number;
|
|
@@ -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