@teincfood/core 0.7.9 → 0.8.0
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/README.md +26 -21
- package/dist/adapters/auth.d.ts +8 -0
- package/dist/adapters/auth.js +36 -0
- package/dist/adapters/devices.d.ts +3 -1
- package/dist/adapters/devices.js +187 -3
- package/dist/adapters/filesystem.d.ts +4 -0
- package/dist/adapters/kv.d.ts +10 -0
- package/dist/adapters/kv.js +23 -7
- package/dist/adapters/local-node.d.ts +4 -6
- package/dist/adapters/local-node.js +9 -32
- package/dist/adapters/notify.d.ts +12 -0
- package/dist/adapters/notify.js +33 -0
- package/dist/adapters/query-keys.d.ts +4 -0
- package/dist/adapters/query-keys.js +4 -0
- package/dist/adapters/services.d.ts +96 -15
- package/dist/adapters/services.js +241 -4
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +172 -0
- package/dist/catalog/hooks.d.ts +10 -0
- package/dist/catalog/hooks.js +82 -11
- package/dist/db/migrations.js +274 -0
- package/dist/db/operations.d.ts +53 -2
- package/dist/db/operations.js +177 -8
- package/dist/domain/business/index.d.ts +5 -0
- package/dist/domain/business/index.js +5 -0
- package/dist/domain/orders/lifecycle.d.ts +6 -0
- package/dist/domain/orders/lifecycle.js +6 -0
- package/dist/domain/orders/service.d.ts +24 -0
- package/dist/domain/orders/service.js +242 -0
- package/dist/domain/payments/index.d.ts +6 -0
- package/dist/domain/payments/index.js +5 -0
- package/dist/domain/reference/index.d.ts +8 -0
- package/dist/domain/reference/index.js +8 -0
- package/dist/e2e/harness.d.ts +1 -0
- package/dist/e2e/harness.js +61 -0
- package/dist/hooks/analytics.hooks.d.ts +25 -0
- package/dist/hooks/analytics.hooks.js +214 -0
- package/dist/hooks/business-hours.hooks.d.ts +8 -0
- package/dist/hooks/business-hours.hooks.js +45 -0
- package/dist/hooks/dashboard.hooks.d.ts +30 -0
- package/dist/hooks/dashboard.hooks.js +132 -0
- package/dist/hooks/delivery.hooks.d.ts +225 -0
- package/dist/hooks/delivery.hooks.js +80 -0
- package/dist/hooks/fleet.hooks.d.ts +37 -0
- package/dist/hooks/fleet.hooks.js +229 -0
- package/dist/hooks/image.hooks.d.ts +20 -0
- package/dist/hooks/image.hooks.js +62 -0
- package/dist/hooks/kiosk.hooks.d.ts +22 -5
- package/dist/hooks/kiosk.hooks.js +74 -5
- package/dist/hooks/members.hooks.d.ts +48 -0
- package/dist/hooks/members.hooks.js +302 -0
- package/dist/hooks/offline-orders.hooks.d.ts +9 -0
- package/dist/hooks/offline-orders.hooks.js +51 -0
- package/dist/hooks/orders.hooks.d.ts +18 -39
- package/dist/hooks/orders.hooks.js +124 -343
- package/dist/hooks/places.hooks.d.ts +21 -0
- package/dist/hooks/places.hooks.js +112 -0
- package/dist/hooks/realtime-stubs.d.ts +28 -0
- package/dist/hooks/realtime-stubs.js +85 -0
- package/dist/hooks/rider.hooks.d.ts +470 -0
- package/dist/hooks/rider.hooks.js +373 -0
- package/dist/hooks/use-infinite-list.d.ts +243 -0
- package/dist/hooks/use-infinite-list.js +58 -0
- package/dist/hooks/wallet.hooks.d.ts +219 -0
- package/dist/hooks/wallet.hooks.js +56 -0
- package/dist/image-cache/service.d.ts +8 -0
- package/dist/image-cache/service.js +15 -3
- package/dist/index.d.ts +56 -6
- package/dist/index.js +73 -6
- package/dist/local-node/adapters.d.ts +87 -0
- package/dist/local-node/adapters.js +4 -0
- package/dist/local-node/index.d.ts +5 -0
- package/dist/local-node/index.js +5 -0
- package/dist/local-node/security.d.ts +25 -0
- package/dist/local-node/security.js +149 -0
- package/dist/local-node/service.d.ts +83 -0
- package/dist/local-node/service.js +706 -0
- package/dist/local-node/store.d.ts +50 -0
- package/dist/local-node/store.js +284 -0
- package/dist/local-node/types.d.ts +92 -0
- package/dist/local-node/types.js +4 -0
- package/dist/printer/receipt.d.ts +4 -0
- package/dist/printer/receipt.js +75 -0
- package/dist/printer/service.js +1 -1
- package/dist/providers/query-client.d.ts +13 -0
- package/dist/providers/query-client.js +58 -0
- package/dist/realtime/phoenix-socket.js +30 -1
- package/dist/realtime/useEventsConnection.js +11 -2
- package/dist/reference/api.d.ts +2 -0
- package/dist/reference/api.js +2 -0
- package/dist/reference/capabilities.d.ts +12 -7
- package/dist/reference/capabilities.js +27 -13
- package/dist/reference/hooks.d.ts +34 -0
- package/dist/reference/hooks.js +124 -0
- package/dist/reference/menu-apply.d.ts +29 -0
- package/dist/reference/menu-apply.js +289 -0
- package/dist/reference/menu-record.d.ts +69 -0
- package/dist/reference/menu-record.js +163 -0
- package/dist/reference/operations.d.ts +3 -0
- package/dist/reference/operations.js +35 -1
- package/dist/reference/service.js +36 -9
- package/dist/reference/types.d.ts +1 -1
- package/dist/repositories/order.repository.d.ts +7 -0
- package/dist/repositories/order.repository.js +15 -2
- package/dist/repositories/pos.repository.d.ts +4 -0
- package/dist/repositories/pos.repository.js +4 -0
- package/dist/sync/backoff.d.ts +21 -0
- package/dist/sync/backoff.js +42 -0
- package/dist/sync/command-builder.js +10 -2
- package/dist/sync/command-queue.service.d.ts +12 -0
- package/dist/sync/command-queue.service.js +81 -16
- package/dist/sync/device-lease.service.d.ts +3 -3
- package/dist/sync/device-lease.service.js +80 -20
- package/dist/sync/engine.d.ts +3 -1
- package/dist/sync/engine.js +44 -4
- package/dist/sync/full-sync.d.ts +6 -0
- package/dist/sync/full-sync.js +72 -0
- package/dist/sync/health-probe.d.ts +24 -0
- package/dist/sync/health-probe.js +138 -0
- package/dist/sync/offline-capable.js +25 -0
- package/dist/sync/online-gates.d.ts +17 -0
- package/dist/sync/online-gates.js +55 -0
- package/dist/sync/order-number.d.ts +4 -4
- package/dist/sync/order-number.js +33 -13
- package/dist/sync/order-snapshots.service.js +18 -3
- package/dist/sync/pending-orders.service.d.ts +18 -0
- package/dist/sync/pending-orders.service.js +67 -1
- package/dist/sync/reconciliation/index.d.ts +5 -0
- package/dist/sync/reconciliation/index.js +5 -0
- package/dist/sync/snapshots/entity.d.ts +6 -0
- package/dist/sync/snapshots/entity.js +6 -0
- package/dist/sync/sync-jobs.service.d.ts +18 -0
- package/dist/sync/sync-jobs.service.js +539 -0
- package/dist/sync/transition-endpoints.d.ts +11 -0
- package/dist/sync/transition-endpoints.js +30 -0
- package/dist/sync/transport-local-node.js +3 -2
- package/dist/sync/transport-rest.d.ts +7 -0
- package/dist/sync/transport-rest.js +84 -26
- package/dist/sync/use-sync-reconciliation.js +15 -1
- package/dist/test-helpers/memory-driver.js +182 -4
- package/dist/test-helpers/seed-hours.d.ts +1 -0
- package/dist/test-helpers/seed-hours.js +23 -0
- package/dist/types/api/business.api.d.ts +42 -0
- package/dist/types/api/menu.api.d.ts +15 -0
- package/dist/types/api/seller.api.d.ts +20 -0
- package/dist/types/places.d.ts +19 -0
- package/dist/types/places.js +2 -0
- package/dist/types/rider.d.ts +67 -0
- package/dist/types/rider.js +2 -0
- package/dist/utils/attempt-stats.d.ts +23 -0
- package/dist/utils/attempt-stats.js +61 -0
- package/dist/utils/business-hours.d.ts +37 -0
- package/dist/utils/business-hours.js +96 -0
- package/dist/utils/debug-log.d.ts +23 -0
- package/dist/utils/debug-log.js +153 -0
- package/dist/utils/order-filter.d.ts +1 -1
- package/dist/utils/order-filter.js +1 -1
- package/dist/utils/order-transitions.d.ts +30 -0
- package/dist/utils/order-transitions.js +185 -0
- package/dist/utils/sync-logger.js +5 -4
- package/dist/validation/offline.d.ts +46 -0
- package/dist/validation/offline.js +79 -0
- package/package.json +6 -1
- package/dist/pricing/engine.test.d.ts +0 -1
- package/dist/pricing/engine.test.js +0 -142
- package/dist/reference/lww.test.d.ts +0 -1
- package/dist/reference/lww.test.js +0 -23
- package/dist/reference/operations.test.d.ts +0 -1
- package/dist/reference/operations.test.js +0 -66
- package/dist/standalone.integration.test.d.ts +0 -1
- package/dist/standalone.integration.test.js +0 -235
- package/dist/sync/device-lease.test.d.ts +0 -1
- package/dist/sync/device-lease.test.js +0 -38
- package/dist/sync/offline-integration.test.d.ts +0 -1
- package/dist/sync/offline-integration.test.js +0 -52
- package/dist/sync/order-number.test.d.ts +0 -1
- package/dist/sync/order-number.test.js +0 -51
- package/dist/sync/perms.test.d.ts +0 -1
- package/dist/sync/perms.test.js +0 -15
- package/dist/utils/currency.test.d.ts +0 -1
- package/dist/utils/currency.test.js +0 -17
- package/dist/utils/hlc.test.d.ts +0 -1
- package/dist/utils/hlc.test.js +0 -29
- package/dist/utils/phone.test.d.ts +0 -1
- package/dist/utils/phone.test.js +0 -19
- package/dist/utils/responsive.d.ts +0 -13
- package/dist/utils/responsive.js +0 -18
- package/dist/utils/uuid.test.d.ts +0 -1
- package/dist/utils/uuid.test.js +0 -13
- package/dist/utils/validation.test.d.ts +0 -1
- package/dist/utils/validation.test.js +0 -23
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export const ORDER_STATUSES = [
|
|
2
|
+
"pending",
|
|
3
|
+
"awaiting_payment",
|
|
4
|
+
"accepted",
|
|
5
|
+
"preparing",
|
|
6
|
+
"ready",
|
|
7
|
+
"waiting_pickup",
|
|
8
|
+
"on_the_way",
|
|
9
|
+
"arrived",
|
|
10
|
+
"delivered",
|
|
11
|
+
"cancelled",
|
|
12
|
+
];
|
|
13
|
+
// 5 includes ready (buyer no-show). Locked per ans 1.
|
|
14
|
+
export const CANCELLABLE_STATUSES = [
|
|
15
|
+
"pending",
|
|
16
|
+
"awaiting_payment",
|
|
17
|
+
"accepted",
|
|
18
|
+
"preparing",
|
|
19
|
+
"ready",
|
|
20
|
+
];
|
|
21
|
+
export const PIPELINE = ["pending", "accepted", "preparing", "ready", "delivered"];
|
|
22
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
23
|
+
export function isUnpaid(order) {
|
|
24
|
+
const o = order;
|
|
25
|
+
return !o.is_paid && !o.paid_at && !o.payment?.is_paid;
|
|
26
|
+
}
|
|
27
|
+
export function isCancellable(status) {
|
|
28
|
+
return CANCELLABLE_STATUSES.includes(status);
|
|
29
|
+
}
|
|
30
|
+
export function cancelLabelFor(status) {
|
|
31
|
+
if (status === "pending")
|
|
32
|
+
return "Reject";
|
|
33
|
+
return "Cancel";
|
|
34
|
+
}
|
|
35
|
+
export function getCancelAction(status) {
|
|
36
|
+
return status === "pending" ? "reject" : "cancel";
|
|
37
|
+
}
|
|
38
|
+
export function isQueuedResult(result) {
|
|
39
|
+
const r = result;
|
|
40
|
+
if (!r)
|
|
41
|
+
return false;
|
|
42
|
+
return r.status === "pending" && !!r.command_id && !r.id;
|
|
43
|
+
}
|
|
44
|
+
export function isDeliveryOrder(order) {
|
|
45
|
+
const o = order;
|
|
46
|
+
if (o.order_type === "delivery")
|
|
47
|
+
return true;
|
|
48
|
+
return !!o.delivery_id;
|
|
49
|
+
}
|
|
50
|
+
export function isWalkInPickup(order) {
|
|
51
|
+
return !isDeliveryOrder(order);
|
|
52
|
+
}
|
|
53
|
+
// ── State machine ───────────────────────────────────────────────────────
|
|
54
|
+
const STATUS_TO_ACTION = {
|
|
55
|
+
pending: "accept",
|
|
56
|
+
accepted: "start_preparing",
|
|
57
|
+
preparing: "mark_ready",
|
|
58
|
+
};
|
|
59
|
+
export function nextActionFor(order) {
|
|
60
|
+
const status = order.status ?? "";
|
|
61
|
+
// awaiting_payment must pay first — no pipeline progression
|
|
62
|
+
if (status === "awaiting_payment")
|
|
63
|
+
return null;
|
|
64
|
+
if (status === "pending") {
|
|
65
|
+
const isPos = !!order.is_pos;
|
|
66
|
+
if (isPos)
|
|
67
|
+
return { label: "Start preparing", key: "start_preparing" };
|
|
68
|
+
return { label: "Accept", key: "accept" };
|
|
69
|
+
}
|
|
70
|
+
if (status === "accepted")
|
|
71
|
+
return { label: "Start preparing", key: "start_preparing" };
|
|
72
|
+
if (status === "preparing")
|
|
73
|
+
return { label: "Mark ready", key: "mark_ready" };
|
|
74
|
+
if (status === "ready") {
|
|
75
|
+
const orderType = order.order_type ?? null;
|
|
76
|
+
// dine_in walk-in → Complete, else Pickup (takeout or delivery handover gate handled via getAllowedActions)
|
|
77
|
+
const label = orderType === "dine_in" ? "Complete" : "Picked up";
|
|
78
|
+
return { label, key: "complete_pickup" };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function canTransition(order, action) {
|
|
83
|
+
const status = order.status ?? "";
|
|
84
|
+
if (status === "cancelled" || status === "delivered")
|
|
85
|
+
return false;
|
|
86
|
+
// awaiting_payment: only pay/confirm_cash/cancel/reject
|
|
87
|
+
if (status === "awaiting_payment") {
|
|
88
|
+
return action === "pay" || action === "confirm_cash" || action === "cancel" || action === "reject";
|
|
89
|
+
}
|
|
90
|
+
switch (action) {
|
|
91
|
+
case "accept":
|
|
92
|
+
return status === "pending" && !order.is_pos;
|
|
93
|
+
case "reject":
|
|
94
|
+
return status === "pending";
|
|
95
|
+
case "start_preparing":
|
|
96
|
+
return status === "accepted" || (status === "pending" && !!order.is_pos);
|
|
97
|
+
case "mark_ready":
|
|
98
|
+
return status === "preparing";
|
|
99
|
+
case "complete_pickup":
|
|
100
|
+
// walk-in only — delivery uses handover gates
|
|
101
|
+
return status === "ready" && isWalkInPickup(order);
|
|
102
|
+
case "cancel":
|
|
103
|
+
return isCancellable(status);
|
|
104
|
+
case "assign_rider":
|
|
105
|
+
return status === "ready" && isDeliveryOrder(order) && !order.delivery_id;
|
|
106
|
+
case "confirm_handover":
|
|
107
|
+
return (status === "ready" || status === "waiting_pickup") && isDeliveryOrder(order) && !!order.delivery_id;
|
|
108
|
+
case "confirm_cash":
|
|
109
|
+
case "pay":
|
|
110
|
+
return isUnpaid(order) && status !== "cancelled" && status !== "delivered";
|
|
111
|
+
default:
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function getAllowedActions(order) {
|
|
116
|
+
const status = order.status ?? "";
|
|
117
|
+
if (status === "cancelled" || status === "delivered" || status === "arrived")
|
|
118
|
+
return [];
|
|
119
|
+
if (status === "awaiting_payment") {
|
|
120
|
+
const actions = ["pay", "confirm_cash"];
|
|
121
|
+
if (isCancellable(status))
|
|
122
|
+
actions.push("cancel");
|
|
123
|
+
return actions;
|
|
124
|
+
}
|
|
125
|
+
if (status === "pending") {
|
|
126
|
+
const isPos = !!order.is_pos;
|
|
127
|
+
const actions = isPos ? [] : ["accept", "reject"];
|
|
128
|
+
if (isPos)
|
|
129
|
+
actions.push("start_preparing");
|
|
130
|
+
if (isCancellable(status)) {
|
|
131
|
+
// reject already covers pending; cancel alias not needed
|
|
132
|
+
}
|
|
133
|
+
// Pending can also start preparing directly on some flows — expose via canTransition but primary is accept/reject
|
|
134
|
+
return actions;
|
|
135
|
+
}
|
|
136
|
+
if (status === "accepted") {
|
|
137
|
+
const actions = ["start_preparing"];
|
|
138
|
+
if (isCancellable(status))
|
|
139
|
+
actions.push("cancel");
|
|
140
|
+
return actions;
|
|
141
|
+
}
|
|
142
|
+
if (status === "preparing") {
|
|
143
|
+
const actions = ["mark_ready"];
|
|
144
|
+
if (isCancellable(status))
|
|
145
|
+
actions.push("cancel");
|
|
146
|
+
return actions;
|
|
147
|
+
}
|
|
148
|
+
if (status === "ready") {
|
|
149
|
+
if (isDeliveryOrder(order)) {
|
|
150
|
+
const hasDelivery = !!order.delivery_id;
|
|
151
|
+
if (!hasDelivery)
|
|
152
|
+
return ["assign_rider", ...(isCancellable(status) ? ["cancel"] : [])];
|
|
153
|
+
return ["confirm_handover", ...(isCancellable(status) ? ["cancel"] : [])];
|
|
154
|
+
}
|
|
155
|
+
// walk-in
|
|
156
|
+
const actions = ["complete_pickup"];
|
|
157
|
+
if (isCancellable(status))
|
|
158
|
+
actions.push("cancel");
|
|
159
|
+
if (isUnpaid(order)) {
|
|
160
|
+
// cash collect also allowed on ready walk-in
|
|
161
|
+
actions.push("confirm_cash");
|
|
162
|
+
}
|
|
163
|
+
return actions;
|
|
164
|
+
}
|
|
165
|
+
if (status === "waiting_pickup" || status === "on_the_way") {
|
|
166
|
+
if (isDeliveryOrder(order))
|
|
167
|
+
return ["confirm_handover"];
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
// Re-export queued status map for callers that need it without importing sync internals
|
|
173
|
+
export const QUEUED_STATUS_BY_COMMAND = {
|
|
174
|
+
"order:accept": "accepted",
|
|
175
|
+
"order:reject": "cancelled",
|
|
176
|
+
"order:start_preparing": "preparing",
|
|
177
|
+
"order:mark_ready": "ready",
|
|
178
|
+
"order:complete_pickup": "delivered",
|
|
179
|
+
"order:cancel": "cancelled",
|
|
180
|
+
"delivery:assign": "assigned",
|
|
181
|
+
"delivery:confirm_handover": "delivered",
|
|
182
|
+
};
|
|
183
|
+
export function getQueuedStatus(commandType) {
|
|
184
|
+
return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
|
|
185
|
+
}
|
|
@@ -7,18 +7,19 @@
|
|
|
7
7
|
function emit(entry) {
|
|
8
8
|
const { level, namespace, message, data } = entry;
|
|
9
9
|
const tag = `[${namespace}] ${message}`;
|
|
10
|
+
const hasData = data !== undefined && data !== null && Object.keys(data).length > 0;
|
|
10
11
|
switch (level) {
|
|
11
12
|
case "debug":
|
|
12
|
-
console.debug(tag, data);
|
|
13
|
+
hasData ? console.debug(tag, data) : console.debug(tag);
|
|
13
14
|
break;
|
|
14
15
|
case "info":
|
|
15
|
-
console.info(tag, data);
|
|
16
|
+
hasData ? console.info(tag, data) : console.info(tag);
|
|
16
17
|
break;
|
|
17
18
|
case "warn":
|
|
18
|
-
console.warn(tag, data);
|
|
19
|
+
hasData ? console.warn(tag, data) : console.warn(tag);
|
|
19
20
|
break;
|
|
20
21
|
case "error":
|
|
21
|
-
console.error(tag, data);
|
|
22
|
+
hasData ? console.error(tag, data) : console.error(tag);
|
|
22
23
|
break;
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline validation mirror — frontend accepts exactly what the backend
|
|
3
|
+
* accepts, so a locally-committed write is never rejected on sync for a
|
|
4
|
+
* reason we could have caught. Each rule cites its backend changeset.
|
|
5
|
+
*
|
|
6
|
+
* Throws ValidationError (with `details` map for per-field form errors) —
|
|
7
|
+
* never returns. Call before every local commit.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ValidationError extends Error {
|
|
10
|
+
details: Record<string, string>;
|
|
11
|
+
constructor(message: string, details?: Record<string, string>);
|
|
12
|
+
}
|
|
13
|
+
/** Mirrors MenuItem.changeset: required name/base_price_minor/category_id, price ≥ 0, category UUID. */
|
|
14
|
+
export declare function validateMenuItemInput(input: {
|
|
15
|
+
name?: unknown;
|
|
16
|
+
price_minor?: unknown;
|
|
17
|
+
priceMinor?: unknown;
|
|
18
|
+
category_id?: unknown;
|
|
19
|
+
categoryId?: unknown;
|
|
20
|
+
variants?: Array<{
|
|
21
|
+
name?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
extras?: Array<{
|
|
24
|
+
name?: unknown;
|
|
25
|
+
}>;
|
|
26
|
+
}): void;
|
|
27
|
+
/** Mirrors MenuCategory.changeset: required name. */
|
|
28
|
+
export declare function validateMenuCategoryInput(input: {
|
|
29
|
+
name?: unknown;
|
|
30
|
+
}): void;
|
|
31
|
+
/** Mirrors Order.changeset: required payment_mode/total/currency, amounts ≥ 0. */
|
|
32
|
+
export declare function validateOrderCreateInput(input: {
|
|
33
|
+
payment_mode?: unknown;
|
|
34
|
+
pos_payment_method?: unknown;
|
|
35
|
+
total_minor?: unknown;
|
|
36
|
+
currency_code?: unknown;
|
|
37
|
+
currency?: unknown;
|
|
38
|
+
items?: unknown;
|
|
39
|
+
order_type?: unknown;
|
|
40
|
+
delivery_address?: unknown;
|
|
41
|
+
}): void;
|
|
42
|
+
/** Mirrors BusinessMemberInvitation changeset: email format. */
|
|
43
|
+
export declare function validateInviteInput(input: {
|
|
44
|
+
email?: unknown;
|
|
45
|
+
invited_email?: unknown;
|
|
46
|
+
}): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline validation mirror — frontend accepts exactly what the backend
|
|
3
|
+
* accepts, so a locally-committed write is never rejected on sync for a
|
|
4
|
+
* reason we could have caught. Each rule cites its backend changeset.
|
|
5
|
+
*
|
|
6
|
+
* Throws ValidationError (with `details` map for per-field form errors) —
|
|
7
|
+
* never returns. Call before every local commit.
|
|
8
|
+
*/
|
|
9
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const EMAIL_RE = /^[^\s]+@[^\s]+\.[^\s]+$/;
|
|
11
|
+
export class ValidationError extends Error {
|
|
12
|
+
constructor(message, details = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ValidationError";
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function fail(field, message, details) {
|
|
19
|
+
details[field] = message;
|
|
20
|
+
throw new ValidationError(message, details);
|
|
21
|
+
}
|
|
22
|
+
/** Mirrors MenuItem.changeset: required name/base_price_minor/category_id, price ≥ 0, category UUID. */
|
|
23
|
+
export function validateMenuItemInput(input) {
|
|
24
|
+
const details = {};
|
|
25
|
+
const name = input.name;
|
|
26
|
+
if (typeof name !== "string" || !name.trim())
|
|
27
|
+
fail("name", "Name is required", details);
|
|
28
|
+
const minor = input.price_minor ?? input.priceMinor;
|
|
29
|
+
if (typeof minor !== "number" || !Number.isFinite(minor))
|
|
30
|
+
fail("price_minor", "Price is required", details);
|
|
31
|
+
if (minor < 0)
|
|
32
|
+
fail("price_minor", "Price must be 0 or more", details);
|
|
33
|
+
const categoryId = input.category_id ?? input.categoryId;
|
|
34
|
+
if (typeof categoryId !== "string" || !categoryId)
|
|
35
|
+
fail("category_id", "Category is required", details);
|
|
36
|
+
if (!UUID_RE.test(categoryId)) {
|
|
37
|
+
// Backend enforces UUID format on category_id — client UUIDs from the
|
|
38
|
+
// envelope builder always pass; temp form ids must never reach here.
|
|
39
|
+
fail("category_id", "Category must be a valid UUID", details);
|
|
40
|
+
}
|
|
41
|
+
for (const v of input.variants ?? []) {
|
|
42
|
+
if (!v || typeof v.name !== "string" || !v.name.trim())
|
|
43
|
+
fail("variants", "Variant name is required", details);
|
|
44
|
+
}
|
|
45
|
+
for (const e of input.extras ?? []) {
|
|
46
|
+
if (!e || typeof e.name !== "string" || !e.name.trim())
|
|
47
|
+
fail("extras", "Extra name is required", details);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Mirrors MenuCategory.changeset: required name. */
|
|
51
|
+
export function validateMenuCategoryInput(input) {
|
|
52
|
+
const details = {};
|
|
53
|
+
if (typeof input.name !== "string" || !input.name.trim())
|
|
54
|
+
fail("name", "Name is required", details);
|
|
55
|
+
}
|
|
56
|
+
/** Mirrors Order.changeset: required payment_mode/total/currency, amounts ≥ 0. */
|
|
57
|
+
export function validateOrderCreateInput(input) {
|
|
58
|
+
const details = {};
|
|
59
|
+
const items = input.item_snapshots ?? input.items;
|
|
60
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
61
|
+
fail("items", "Order must have at least one item", details);
|
|
62
|
+
for (const k of ["total_minor", "subtotal_minor", "delivery_fee_minor", "tax_minor"]) {
|
|
63
|
+
const v = input[k];
|
|
64
|
+
if (v !== undefined && (typeof v !== "number" || v < 0))
|
|
65
|
+
fail(k, `${k} must be 0 or more`, details);
|
|
66
|
+
}
|
|
67
|
+
if (input.order_type === "delivery" && !input.delivery_address) {
|
|
68
|
+
fail("delivery_address", "Delivery address is required for delivery orders", details);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Mirrors BusinessMemberInvitation changeset: email format. */
|
|
72
|
+
export function validateInviteInput(input) {
|
|
73
|
+
const details = {};
|
|
74
|
+
const email = input.invited_email ?? input.email;
|
|
75
|
+
if (typeof email !== "string" || !email.trim())
|
|
76
|
+
fail("email", "Email is required", details);
|
|
77
|
+
if (!EMAIL_RE.test(email))
|
|
78
|
+
fail("email", "Invalid email format", details);
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teincfood/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -53,12 +53,17 @@
|
|
|
53
53
|
"@tanstack/react-query": "^5.102.2",
|
|
54
54
|
"@types/node": "^26.2.0",
|
|
55
55
|
"@types/react": "^19.2.18",
|
|
56
|
+
"bonjour-service": "^1.4.4",
|
|
56
57
|
"react": "^19.2.8",
|
|
58
|
+
"tsx": "^4.23.13",
|
|
57
59
|
"typescript": "^7.0.2",
|
|
58
60
|
"vitest": "^3.1.0"
|
|
59
61
|
},
|
|
60
62
|
"license": "MIT",
|
|
61
63
|
"publishConfig": {
|
|
62
64
|
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@tanstack/query-async-storage-persister": "^5.102.8"
|
|
63
68
|
}
|
|
64
69
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-parity tests for the pricing engine.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the backend ExUnit vectors in
|
|
5
|
-
* pos_controller_test.exs (inclusive / exclusive / mixed) and the
|
|
6
|
-
* rounding formulas in Orders.calculate_tax_amount. Golden values below come
|
|
7
|
-
* directly from the Elixir implementation so the two runtimes never diverge.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect } from "vitest";
|
|
10
|
-
import { calculatePricing, calculateTaxLines, calculateExclusiveTax, calculateInclusiveTaxShare, } from "./engine";
|
|
11
|
-
function cart(unitPriceMinor, quantity = 1, addonsMinor = []) {
|
|
12
|
-
return [
|
|
13
|
-
{
|
|
14
|
-
id: "cart-1",
|
|
15
|
-
menu_item: {
|
|
16
|
-
id: "item-1",
|
|
17
|
-
name: "Test Item",
|
|
18
|
-
description: null,
|
|
19
|
-
basePrice: { amountMinor: unitPriceMinor, currencyCode: "GHS" },
|
|
20
|
-
image_url: null,
|
|
21
|
-
is_available: true,
|
|
22
|
-
dietary_tags: [],
|
|
23
|
-
category_id: "cat-1",
|
|
24
|
-
business_id: "biz-1",
|
|
25
|
-
category: null,
|
|
26
|
-
variants: [],
|
|
27
|
-
extras: [],
|
|
28
|
-
inserted_at: "",
|
|
29
|
-
updated_at: "",
|
|
30
|
-
},
|
|
31
|
-
quantity,
|
|
32
|
-
selected_variant: null,
|
|
33
|
-
selected_addons: addonsMinor.map((price_minor, i) => ({
|
|
34
|
-
addon_id: `addon-${i}`,
|
|
35
|
-
name: `Addon ${i}`,
|
|
36
|
-
price_minor,
|
|
37
|
-
})),
|
|
38
|
-
notes: "",
|
|
39
|
-
// unit_price_minor already includes variant + addons (matches pos.store)
|
|
40
|
-
unit_price_minor: unitPriceMinor,
|
|
41
|
-
},
|
|
42
|
-
];
|
|
43
|
-
}
|
|
44
|
-
function taxRule(overrides) {
|
|
45
|
-
return {
|
|
46
|
-
id: "tax-1",
|
|
47
|
-
business_id: "biz-1",
|
|
48
|
-
country_code: "GH",
|
|
49
|
-
applies_to: "subtotal",
|
|
50
|
-
is_inclusive: false,
|
|
51
|
-
is_active: true,
|
|
52
|
-
display_order: 0,
|
|
53
|
-
inserted_at: "",
|
|
54
|
-
updated_at: "",
|
|
55
|
-
...overrides,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
describe("calculateExclusiveTax / calculateInclusiveTaxShare (backend rounding)", () => {
|
|
59
|
-
it("computes exclusive tax = floor(amount*rate/10000)", () => {
|
|
60
|
-
expect(calculateExclusiveTax(2400, 1000)).toBe(240); // 10%
|
|
61
|
-
expect(calculateExclusiveTax(1200, 1000)).toBe(120);
|
|
62
|
-
expect(calculateExclusiveTax(2400, 0)).toBe(0);
|
|
63
|
-
expect(calculateExclusiveTax(0, 1000)).toBe(0);
|
|
64
|
-
});
|
|
65
|
-
it("computes inclusive share = floor(amount*rate/(10000+rate))", () => {
|
|
66
|
-
// Backend: div(2400 * 7500 + 5000, 107500) = 1028
|
|
67
|
-
expect(calculateInclusiveTaxShare(2400, 7500)).toBe(1028);
|
|
68
|
-
expect(calculateInclusiveTaxShare(0, 7500)).toBe(0);
|
|
69
|
-
expect(calculateInclusiveTaxShare(2400, 0)).toBe(0);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
describe("calculateTaxLines", () => {
|
|
73
|
-
it("returns exclusive-only additions for exclusive rules", () => {
|
|
74
|
-
const rules = [taxRule({ name: "Service", code: "service", rate_basis_points: 1000 })];
|
|
75
|
-
const { tax_lines, tax_minor, exclusive_tax_minor } = calculateTaxLines(2400, rules);
|
|
76
|
-
expect(tax_minor).toBe(240);
|
|
77
|
-
expect(exclusive_tax_minor).toBe(240);
|
|
78
|
-
expect(tax_lines[0].is_inclusive).toBe(false);
|
|
79
|
-
expect(tax_lines[0].tax_amount_minor).toBe(240);
|
|
80
|
-
});
|
|
81
|
-
it("returns embedded amounts for inclusive rules (not added to total)", () => {
|
|
82
|
-
const rules = [taxRule({ name: "Incl VAT", code: "incl_vat", rate_basis_points: 7500, is_inclusive: true })];
|
|
83
|
-
const { tax_lines, tax_minor, exclusive_tax_minor } = calculateTaxLines(2400, rules);
|
|
84
|
-
expect(tax_minor).toBe(1028); // display share
|
|
85
|
-
expect(exclusive_tax_minor).toBe(0); // nothing added to total
|
|
86
|
-
expect(tax_lines[0].is_inclusive).toBe(true);
|
|
87
|
-
expect(tax_lines[0].tax_amount_minor).toBe(1028);
|
|
88
|
-
});
|
|
89
|
-
it("applies tax to discounted subtotal first", () => {
|
|
90
|
-
const rules = [taxRule({ name: "Service", code: "service", rate_basis_points: 1000 })];
|
|
91
|
-
const { exclusive_tax_minor } = calculateTaxLines(2400, rules, 400, 0); // 2000 taxable
|
|
92
|
-
expect(exclusive_tax_minor).toBe(200);
|
|
93
|
-
});
|
|
94
|
-
it("honors applies_to = subtotal_plus_delivery", () => {
|
|
95
|
-
const rules = [
|
|
96
|
-
taxRule({ name: "Service", code: "service", rate_basis_points: 1000, applies_to: "subtotal_plus_delivery" }),
|
|
97
|
-
];
|
|
98
|
-
const { exclusive_tax_minor } = calculateTaxLines(2400, rules, 0, 600); // 3000 taxable
|
|
99
|
-
expect(exclusive_tax_minor).toBe(300);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
describe("calculatePricing (POS parity with backend)", () => {
|
|
103
|
-
it("no tax → total == subtotal", () => {
|
|
104
|
-
const pricing = calculatePricing({ items: cart(1200, 2), taxRules: [], currency_code: "GHS" });
|
|
105
|
-
expect(pricing.subtotal_minor).toBe(2400);
|
|
106
|
-
expect(pricing.total_minor).toBe(2400);
|
|
107
|
-
expect(pricing.exclusive_tax_minor).toBe(0);
|
|
108
|
-
});
|
|
109
|
-
it("inclusive-only tax does NOT inflate total (backend vector: 2400 → 2400)", () => {
|
|
110
|
-
const rules = [taxRule({ name: "Incl VAT", code: "incl_vat", rate_basis_points: 7500, is_inclusive: true })];
|
|
111
|
-
const pricing = calculatePricing({ items: cart(1200, 2), taxRules: rules, currency_code: "GHS" });
|
|
112
|
-
expect(pricing.subtotal_minor).toBe(2400);
|
|
113
|
-
expect(pricing.total_minor).toBe(2400); // NOT 2400 + 168
|
|
114
|
-
expect(pricing.exclusive_tax_minor).toBe(0);
|
|
115
|
-
expect(pricing.tax_minor).toBe(1028);
|
|
116
|
-
expect(pricing.tax_lines).toHaveLength(1);
|
|
117
|
-
expect(pricing.tax_lines[0].is_inclusive).toBe(true);
|
|
118
|
-
});
|
|
119
|
-
it("exclusive tax is added on top (backend vector: 2400 → 2640)", () => {
|
|
120
|
-
const rules = [taxRule({ name: "Service", code: "service", rate_basis_points: 1000 })];
|
|
121
|
-
const pricing = calculatePricing({ items: cart(1200, 2), taxRules: rules, currency_code: "GHS" });
|
|
122
|
-
expect(pricing.subtotal_minor).toBe(2400);
|
|
123
|
-
expect(pricing.exclusive_tax_minor).toBe(240);
|
|
124
|
-
expect(pricing.total_minor).toBe(2640);
|
|
125
|
-
});
|
|
126
|
-
it("mixed inclusive + exclusive adds only the exclusive portion (backend vector: 2640)", () => {
|
|
127
|
-
const rules = [
|
|
128
|
-
taxRule({ name: "Incl VAT", code: "incl_vat", rate_basis_points: 7500, is_inclusive: true }),
|
|
129
|
-
taxRule({ name: "Service", code: "service", rate_basis_points: 1000 }),
|
|
130
|
-
];
|
|
131
|
-
const pricing = calculatePricing({ items: cart(1200, 2), taxRules: rules, currency_code: "GHS" });
|
|
132
|
-
expect(pricing.total_minor).toBe(2640);
|
|
133
|
-
expect(pricing.exclusive_tax_minor).toBe(240);
|
|
134
|
-
expect(pricing.tax_minor).toBe(1028 + 240); // display breakdown
|
|
135
|
-
expect(pricing.tax_lines).toHaveLength(2);
|
|
136
|
-
});
|
|
137
|
-
it("applies tips when provided (POS total = subtotal + excl tax + tip)", () => {
|
|
138
|
-
const rules = [taxRule({ name: "Service", code: "service", rate_basis_points: 1000 })];
|
|
139
|
-
const pricing = calculatePricing({ items: cart(1200, 2), taxRules: rules, tip_minor: 500, currency_code: "GHS" });
|
|
140
|
-
expect(pricing.total_minor).toBe(2400 + 240 + 500);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { lwwMerge, toLWWMap, fromLWWMap } from "./lww";
|
|
3
|
-
describe("LWW merge", () => {
|
|
4
|
-
it("divergent fields both merge", () => {
|
|
5
|
-
const a = toLWWMap({ price: 500 }, "1000-000000-01", "01");
|
|
6
|
-
const b = toLWWMap({ name: "Spicy" }, "1001-000000-02", "02");
|
|
7
|
-
const merged = lwwMerge(lwwMerge(null, a), b);
|
|
8
|
-
expect(fromLWWMap(merged)).toEqual({ price: 500, name: "Spicy" });
|
|
9
|
-
});
|
|
10
|
-
it("same field last HLC wins", () => {
|
|
11
|
-
const a = toLWWMap({ price: 500 }, "1000-000000-01", "01");
|
|
12
|
-
const b = toLWWMap({ price: 600 }, "1001-000000-02", "02");
|
|
13
|
-
const merged = lwwMerge(a, b);
|
|
14
|
-
expect(merged.price.value).toBe(600);
|
|
15
|
-
const merged2 = lwwMerge(b, a);
|
|
16
|
-
expect(merged2.price.value).toBe(600);
|
|
17
|
-
});
|
|
18
|
-
it("idempotent", () => {
|
|
19
|
-
const a = toLWWMap({ x: 1 }, "1000-000000-01", "01");
|
|
20
|
-
const m1 = lwwMerge(a, a);
|
|
21
|
-
expect(m1).toEqual(a);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { getReferenceData, setReferenceData, mergeReferenceData } from "./operations";
|
|
3
|
-
// simple in-memory sqlite mock for operations tests
|
|
4
|
-
class MemDB {
|
|
5
|
-
constructor() {
|
|
6
|
-
this.tables = new Map();
|
|
7
|
-
}
|
|
8
|
-
async execAsync(_sql) { }
|
|
9
|
-
async runAsync(sql, ...params) {
|
|
10
|
-
if (sql.includes("INSERT INTO reference_data")) {
|
|
11
|
-
const [business_id, key, version, payload, downloaded_at, last_event_id, last_hlc] = params;
|
|
12
|
-
let m = this.tables.get("reference_data");
|
|
13
|
-
if (!m) {
|
|
14
|
-
m = new Map();
|
|
15
|
-
this.tables.set("reference_data", m);
|
|
16
|
-
}
|
|
17
|
-
const existing = m.get(`${business_id}:${key}`);
|
|
18
|
-
// mimic merge behavior via setReferenceData logic in test calling mergeReferenceData
|
|
19
|
-
m.set(`${business_id}:${key}`, JSON.stringify({ business_id, key, version, payload, downloaded_at, last_event_id, last_hlc }));
|
|
20
|
-
return { lastInsertRowId: 1, changes: 1 };
|
|
21
|
-
}
|
|
22
|
-
if (sql.includes("INSERT INTO reference_data") && sql.includes("ON CONFLICT")) { }
|
|
23
|
-
return { lastInsertRowId: 1, changes: 1 };
|
|
24
|
-
}
|
|
25
|
-
async getFirstAsync(sql, ...params) {
|
|
26
|
-
if (sql.includes("SELECT * FROM reference_data")) {
|
|
27
|
-
const [business_id, key] = params;
|
|
28
|
-
const m = this.tables.get("reference_data");
|
|
29
|
-
if (!m)
|
|
30
|
-
return null;
|
|
31
|
-
const raw = m.get(`${business_id}:${key}`);
|
|
32
|
-
if (!raw)
|
|
33
|
-
return null;
|
|
34
|
-
const j = JSON.parse(raw);
|
|
35
|
-
return { key: j.key, version: j.version, payload: j.payload, downloaded_at: j.downloaded_at, last_event_id: j.last_event_id, last_hlc: j.last_hlc };
|
|
36
|
-
}
|
|
37
|
-
if (sql.includes("SELECT version")) {
|
|
38
|
-
const [business_id, key] = params;
|
|
39
|
-
const m = this.tables.get("reference_data");
|
|
40
|
-
const raw = m?.get(`${business_id}:${key}`);
|
|
41
|
-
if (!raw)
|
|
42
|
-
return null;
|
|
43
|
-
const j = JSON.parse(raw);
|
|
44
|
-
return { version: j.version };
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
async getAllAsync() { return []; }
|
|
49
|
-
}
|
|
50
|
-
describe("reference operations LWW", () => {
|
|
51
|
-
it("merge keeps newer HLC", async () => {
|
|
52
|
-
const db = new MemDB();
|
|
53
|
-
await setReferenceData(db, "biz1", { key: "business:profile", version: "1000-000000-01", payload: { name: "A" }, downloadedAt: new Date().toISOString(), lastHlc: "1000-000000-01", lastEventId: "evt_1" });
|
|
54
|
-
let rec = await getReferenceData(db, "biz1", "business:profile");
|
|
55
|
-
expect(rec?.lastHlc).toBe("1000-000000-01");
|
|
56
|
-
// older HLC should not overwrite
|
|
57
|
-
await mergeReferenceData(db, "biz1", "business:profile", { name: "B" }, "0999-000000-01", "evt_0");
|
|
58
|
-
rec = await getReferenceData(db, "biz1", "business:profile");
|
|
59
|
-
// still A because incoming older
|
|
60
|
-
expect(rec?.payload).toEqual({ name: "A" });
|
|
61
|
-
// newer wins
|
|
62
|
-
await mergeReferenceData(db, "biz1", "business:profile", { name: "C" }, "1001-000000-02", "evt_2");
|
|
63
|
-
rec = await getReferenceData(db, "biz1", "business:profile");
|
|
64
|
-
expect(rec?.payload).toEqual({ name: "C" });
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|