@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.
- 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/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" });
|
|
@@ -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