@teincfood/core 0.1.2 → 0.1.4
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/adapters/image-cache.d.ts +9 -3
- package/dist/adapters/image-cache.js +12 -4
- package/dist/adapters/services.d.ts +4 -0
- package/dist/adapters/services.js +13 -1
- package/dist/index.d.ts +36 -2
- package/dist/index.js +62 -2
- package/dist/pricing/engine.d.ts +58 -0
- package/dist/pricing/engine.js +82 -0
- package/dist/pricing/engine.test.d.ts +1 -0
- package/dist/pricing/engine.test.js +142 -0
- package/dist/reference/api.d.ts +18 -1
- package/dist/reference/api.js +25 -2
- package/dist/sync/offline-capable.d.ts +22 -0
- package/dist/sync/offline-capable.js +41 -0
- package/dist/sync/order-number.test.d.ts +1 -0
- package/dist/sync/order-number.test.js +30 -0
- package/dist/sync/transport-rest.js +17 -1
- package/dist/types/pos.types.d.ts +1 -0
- package/dist/utils/constants.d.ts +6 -4
- package/dist/utils/constants.js +34 -8
- package/dist/utils/currency.test.d.ts +1 -0
- package/dist/utils/currency.test.js +17 -0
- package/dist/utils/phone.test.d.ts +1 -0
- package/dist/utils/phone.test.js +19 -0
- package/dist/utils/sync-logger.js +12 -2
- package/dist/utils/uuid.test.d.ts +1 -0
- package/dist/utils/uuid.test.js +13 -0
- package/dist/utils/validation.test.d.ts +1 -0
- package/dist/utils/validation.test.js +23 -0
- package/package.json +5 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image cache stub — no-op in core library.
|
|
3
|
-
* Host apps with file-system access inject real impl.
|
|
3
|
+
* Host apps with file-system access inject real impl via imageCacheService.
|
|
4
4
|
*/
|
|
5
|
-
export declare
|
|
5
|
+
export declare const imageCacheService: {
|
|
6
|
+
cacheImageBatch(urls: (string | null | undefined)[], opts?: {
|
|
7
|
+
skipIfCached?: boolean;
|
|
8
|
+
}): Promise<string[]>;
|
|
9
|
+
pruneImageCache(urls: (string | null | undefined)[]): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function cacheImageBatch(urls: (string | null | undefined)[], opts?: {
|
|
6
12
|
skipIfCached?: boolean;
|
|
7
13
|
}): Promise<string[]>;
|
|
8
|
-
export declare function pruneImageCache(
|
|
14
|
+
export declare function pruneImageCache(urls: (string | null | undefined)[]): Promise<void>;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image cache stub — no-op in core library.
|
|
3
|
-
* Host apps with file-system access inject real impl.
|
|
3
|
+
* Host apps with file-system access inject real impl via imageCacheService.
|
|
4
4
|
*/
|
|
5
|
-
export
|
|
6
|
-
|
|
5
|
+
export const imageCacheService = {
|
|
6
|
+
async cacheImageBatch() {
|
|
7
|
+
return [];
|
|
8
|
+
},
|
|
9
|
+
async pruneImageCache() { },
|
|
10
|
+
};
|
|
11
|
+
export async function cacheImageBatch(urls, opts) {
|
|
12
|
+
return imageCacheService.cacheImageBatch(urls, opts);
|
|
13
|
+
}
|
|
14
|
+
export async function pruneImageCache(urls) {
|
|
15
|
+
return imageCacheService.pruneImageCache(urls);
|
|
7
16
|
}
|
|
8
|
-
export async function pruneImageCache(_urls) { }
|
|
@@ -36,6 +36,10 @@ export declare const kioskService: {
|
|
|
36
36
|
export declare const menuService: {
|
|
37
37
|
fetchBusinessMenuItems: (_businessId: string, _page: number) => Promise<unknown>;
|
|
38
38
|
fetchBusinessCategories: (_businessId: string) => Promise<unknown>;
|
|
39
|
+
createMenuItem: (_businessId: string, _payload: Record<string, unknown>) => Promise<unknown>;
|
|
40
|
+
updateMenuItem: (_itemId: string, _payload: Record<string, unknown>) => Promise<unknown>;
|
|
41
|
+
deleteMenuItem: (_itemId: string) => Promise<unknown>;
|
|
42
|
+
createCategory: (_businessId: string, _payload: Record<string, unknown>) => Promise<unknown>;
|
|
39
43
|
};
|
|
40
44
|
export declare const businessService: {
|
|
41
45
|
fetchBusinessMembers: (_businessId: string) => Promise<unknown>;
|
|
@@ -47,7 +47,7 @@ export const kioskService = {
|
|
|
47
47
|
notImpl("kioskService.assignKioskOrderToRider");
|
|
48
48
|
},
|
|
49
49
|
};
|
|
50
|
-
// menu.service etc. for reference fallback
|
|
50
|
+
// menu.service etc. for reference fallback + command dispatch
|
|
51
51
|
export const menuService = {
|
|
52
52
|
fetchBusinessMenuItems: async (_businessId, _page) => {
|
|
53
53
|
notImpl("menuService.fetchBusinessMenuItems");
|
|
@@ -55,6 +55,18 @@ export const menuService = {
|
|
|
55
55
|
fetchBusinessCategories: async (_businessId) => {
|
|
56
56
|
notImpl("menuService.fetchBusinessCategories");
|
|
57
57
|
},
|
|
58
|
+
createMenuItem: async (_businessId, _payload) => {
|
|
59
|
+
notImpl("menuService.createMenuItem");
|
|
60
|
+
},
|
|
61
|
+
updateMenuItem: async (_itemId, _payload) => {
|
|
62
|
+
notImpl("menuService.updateMenuItem");
|
|
63
|
+
},
|
|
64
|
+
deleteMenuItem: async (_itemId) => {
|
|
65
|
+
notImpl("menuService.deleteMenuItem");
|
|
66
|
+
},
|
|
67
|
+
createCategory: async (_businessId, _payload) => {
|
|
68
|
+
notImpl("menuService.createCategory");
|
|
69
|
+
},
|
|
58
70
|
};
|
|
59
71
|
export const businessService = {
|
|
60
72
|
fetchBusinessMembers: async (_businessId) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,30 +6,62 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export * from "./types/api";
|
|
8
8
|
export * from "./types/pos.types";
|
|
9
|
+
export { calculatePricing, calculateTaxLines, calculateExclusiveTax, calculateInclusiveTaxShare, } from "./pricing/engine";
|
|
10
|
+
export type { PricingInput, PricingResult } from "./pricing/engine";
|
|
9
11
|
export * from "./utils/constants";
|
|
10
12
|
export * from "./utils/currency";
|
|
11
13
|
export * from "./utils/error";
|
|
12
14
|
export * from "./utils/uuid";
|
|
13
15
|
export * from "./utils/sync-logger";
|
|
16
|
+
export { PHONE_COUNTRIES, getDialCodeFromCountry, getCountryOption, normalizePhoneForCountry, isLikelyE164 } from "./utils/phone";
|
|
17
|
+
export type { PhoneCountryOption } from "./utils/phone";
|
|
18
|
+
export * from "./utils/formatCurrency";
|
|
19
|
+
export { formatDate, formatRelativeTime } from "./utils/formatDate";
|
|
20
|
+
export * from "./utils/validation";
|
|
21
|
+
export * from "./utils/responsive";
|
|
22
|
+
export * from "./utils/copyToClipBoard";
|
|
14
23
|
export * from "./db/migrations";
|
|
24
|
+
export * from "./db/operations";
|
|
15
25
|
export * as dbOps from "./db/operations";
|
|
16
|
-
export { openSyncDatabase, getSyncDatabase } from "./db/connection";
|
|
26
|
+
export { openSyncDatabase, getSyncDatabase, closeSyncDatabase } from "./db/connection";
|
|
17
27
|
export * from "./sync/types";
|
|
18
|
-
export
|
|
28
|
+
export * from "./sync/use-sync-reconciliation";
|
|
29
|
+
export { SyncEngine, syncEngine } from "./sync/engine";
|
|
30
|
+
export * from "./sync/command-builder";
|
|
31
|
+
export * from "./sync/command-queue.service";
|
|
19
32
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
20
33
|
export { buildCommand } from "./sync/command-builder";
|
|
21
34
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber } from "./sync/order-number";
|
|
22
35
|
export { orderSnapshotsService } from "./sync/order-snapshots.service";
|
|
23
36
|
export * from "./sync/pending-orders.service";
|
|
37
|
+
export { restTransport } from "./sync/transport-rest";
|
|
38
|
+
export * from "./sync/transport-rest";
|
|
39
|
+
export { localNodeTransport } from "./sync/transport-local-node";
|
|
40
|
+
export * from "./sync/transport-local-node";
|
|
41
|
+
export * from "./sync/order-snapshots.service";
|
|
42
|
+
export { hydratePendingOrders, applyPendingOrderEvent, applyOrderStateChangeEvent } from "./sync/pending-orders.service";
|
|
43
|
+
export { runOfflineCapableMutation } from "./sync/offline-capable";
|
|
44
|
+
export type { OfflineCapableOptions } from "./sync/offline-capable";
|
|
24
45
|
export { orderRepository } from "./repositories/order.repository";
|
|
46
|
+
export type { CreateOrderInput, AssignRiderInput, OrderActionInput } from "./repositories/order.repository";
|
|
25
47
|
export { posRepository } from "./repositories/pos.repository";
|
|
48
|
+
export type { POSOrderPreviewInput } from "./repositories/pos.repository";
|
|
26
49
|
export * from "./reference/types";
|
|
50
|
+
export * from "./reference/operations";
|
|
51
|
+
export { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, setReferenceLocalNodeFetcher } from "./reference/api";
|
|
52
|
+
export type { ReferenceLocalNodeFetcher } from "./reference/api";
|
|
53
|
+
export { emitReferenceChanged, applyReferenceChangeEvent, syncReferenceDataOnReconnect, registerReferenceEventEngine, registerReferenceInvalidator } from "./reference/events";
|
|
54
|
+
export * from "./reference/events";
|
|
55
|
+
export * from "./reference/service";
|
|
27
56
|
export { referenceSyncService } from "./reference/service";
|
|
28
57
|
export * from "./adapters/sqlite";
|
|
29
58
|
export * from "./adapters/kv";
|
|
30
59
|
export * from "./adapters/connectivity";
|
|
31
60
|
export * from "./adapters/http";
|
|
32
61
|
export * from "./adapters/local-node";
|
|
62
|
+
export * from "./adapters/services";
|
|
63
|
+
export * from "./adapters/devices";
|
|
64
|
+
export * from "./adapters/image-cache";
|
|
33
65
|
import { type SqliteDriver } from "./adapters/sqlite";
|
|
34
66
|
import { type KVDriver } from "./adapters/kv";
|
|
35
67
|
import { type HttpClient } from "./adapters/http";
|
|
@@ -38,6 +70,8 @@ export interface CoreConfig {
|
|
|
38
70
|
sqliteDriver?: SqliteDriver;
|
|
39
71
|
kvDriver?: KVDriver;
|
|
40
72
|
httpClient?: HttpClient;
|
|
73
|
+
apiBaseUrl?: string;
|
|
74
|
+
wsBaseUrl?: string;
|
|
41
75
|
/** Optional initial connectivity override */
|
|
42
76
|
initialConnectivity?: {
|
|
43
77
|
isOnline?: boolean;
|
package/dist/index.js
CHANGED
|
@@ -7,29 +7,53 @@
|
|
|
7
7
|
// ── Types ──
|
|
8
8
|
export * from "./types/api";
|
|
9
9
|
export * from "./types/pos.types";
|
|
10
|
+
// ── Pricing (server-parity) ──
|
|
11
|
+
export { calculatePricing, calculateTaxLines, calculateExclusiveTax, calculateInclusiveTaxShare, } from "./pricing/engine";
|
|
10
12
|
// ── Utils ──
|
|
11
13
|
export * from "./utils/constants";
|
|
12
14
|
export * from "./utils/currency";
|
|
13
15
|
export * from "./utils/error";
|
|
14
16
|
export * from "./utils/uuid";
|
|
15
17
|
export * from "./utils/sync-logger";
|
|
18
|
+
export { PHONE_COUNTRIES, getDialCodeFromCountry, getCountryOption, normalizePhoneForCountry, isLikelyE164 } from "./utils/phone";
|
|
19
|
+
export * from "./utils/formatCurrency";
|
|
20
|
+
export { formatDate, formatRelativeTime } from "./utils/formatDate";
|
|
21
|
+
export * from "./utils/validation";
|
|
22
|
+
export * from "./utils/responsive";
|
|
23
|
+
export * from "./utils/copyToClipBoard";
|
|
16
24
|
// ── DB ──
|
|
17
25
|
export * from "./db/migrations";
|
|
26
|
+
export * from "./db/operations";
|
|
18
27
|
export * as dbOps from "./db/operations";
|
|
19
|
-
export { openSyncDatabase, getSyncDatabase } from "./db/connection";
|
|
28
|
+
export { openSyncDatabase, getSyncDatabase, closeSyncDatabase } from "./db/connection";
|
|
20
29
|
// ── Sync ──
|
|
21
30
|
export * from "./sync/types";
|
|
22
|
-
export
|
|
31
|
+
export * from "./sync/use-sync-reconciliation";
|
|
32
|
+
export { SyncEngine, syncEngine } from "./sync/engine";
|
|
33
|
+
export * from "./sync/command-builder";
|
|
34
|
+
export * from "./sync/command-queue.service";
|
|
23
35
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
24
36
|
export { buildCommand } from "./sync/command-builder";
|
|
25
37
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber } from "./sync/order-number";
|
|
26
38
|
export { orderSnapshotsService } from "./sync/order-snapshots.service";
|
|
27
39
|
export * from "./sync/pending-orders.service";
|
|
40
|
+
export { restTransport } from "./sync/transport-rest";
|
|
41
|
+
export * from "./sync/transport-rest";
|
|
42
|
+
export { localNodeTransport } from "./sync/transport-local-node";
|
|
43
|
+
export * from "./sync/transport-local-node";
|
|
44
|
+
export * from "./sync/order-snapshots.service";
|
|
45
|
+
export { hydratePendingOrders, applyPendingOrderEvent, applyOrderStateChangeEvent } from "./sync/pending-orders.service";
|
|
46
|
+
export { runOfflineCapableMutation } from "./sync/offline-capable";
|
|
28
47
|
// ── Repositories ──
|
|
29
48
|
export { orderRepository } from "./repositories/order.repository";
|
|
30
49
|
export { posRepository } from "./repositories/pos.repository";
|
|
31
50
|
// ── Reference ──
|
|
32
51
|
export * from "./reference/types";
|
|
52
|
+
export * from "./reference/operations";
|
|
53
|
+
export { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, setReferenceLocalNodeFetcher } from "./reference/api";
|
|
54
|
+
export { emitReferenceChanged, applyReferenceChangeEvent, syncReferenceDataOnReconnect, registerReferenceEventEngine, registerReferenceInvalidator } from "./reference/events";
|
|
55
|
+
export * from "./reference/events";
|
|
56
|
+
export * from "./reference/service";
|
|
33
57
|
export { referenceSyncService } from "./reference/service";
|
|
34
58
|
// ── Adapters (for host apps to supply) ──
|
|
35
59
|
export * from "./adapters/sqlite";
|
|
@@ -37,24 +61,60 @@ export * from "./adapters/kv";
|
|
|
37
61
|
export * from "./adapters/connectivity";
|
|
38
62
|
export * from "./adapters/http";
|
|
39
63
|
export * from "./adapters/local-node";
|
|
64
|
+
export * from "./adapters/services";
|
|
65
|
+
export * from "./adapters/devices";
|
|
66
|
+
export * from "./adapters/image-cache";
|
|
40
67
|
// ── Init helper ──
|
|
41
68
|
import { setSqliteDriver } from "./adapters/sqlite";
|
|
42
69
|
import { setKVDriver } from "./adapters/kv";
|
|
43
70
|
import { setConnectivityState } from "./adapters/connectivity";
|
|
44
71
|
import { setHttpClient } from "./adapters/http";
|
|
72
|
+
import { API_BASE_URL, WS_BASE_URL, setApiBaseUrl, setWsBaseUrl } from "./utils/constants";
|
|
45
73
|
import { SyncEngine } from "./sync/engine";
|
|
46
74
|
import { restTransport } from "./sync/transport-rest";
|
|
47
75
|
import { localNodeTransport } from "./sync/transport-local-node";
|
|
48
76
|
let engine = null;
|
|
49
77
|
export function createTeincCore(config = {}) {
|
|
78
|
+
try {
|
|
79
|
+
console.debug("[core] createTeincCore called", {
|
|
80
|
+
hasSqliteDriver: !!config.sqliteDriver,
|
|
81
|
+
hasKvDriver: !!config.kvDriver,
|
|
82
|
+
hasHttpClient: !!config.httpClient,
|
|
83
|
+
apiBaseUrl: config.apiBaseUrl ?? "(default)",
|
|
84
|
+
wsBaseUrl: config.wsBaseUrl ?? "(default)",
|
|
85
|
+
initialConnectivity: config.initialConnectivity ?? null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
50
89
|
if (config.sqliteDriver)
|
|
51
90
|
setSqliteDriver(config.sqliteDriver);
|
|
52
91
|
if (config.kvDriver)
|
|
53
92
|
setKVDriver(config.kvDriver);
|
|
54
93
|
if (config.httpClient)
|
|
55
94
|
setHttpClient(config.httpClient);
|
|
95
|
+
if (config.apiBaseUrl)
|
|
96
|
+
setApiBaseUrl(config.apiBaseUrl);
|
|
97
|
+
else {
|
|
98
|
+
try {
|
|
99
|
+
console.debug("[core] apiBaseUrl not provided by app, using default", API_BASE_URL);
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
}
|
|
103
|
+
if (config.wsBaseUrl)
|
|
104
|
+
setWsBaseUrl(config.wsBaseUrl);
|
|
105
|
+
else {
|
|
106
|
+
try {
|
|
107
|
+
console.debug("[core] wsBaseUrl not provided by app, using default", WS_BASE_URL);
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
}
|
|
56
111
|
if (config.initialConnectivity)
|
|
57
112
|
setConnectivityState(config.initialConnectivity);
|
|
113
|
+
// Debug: confirm env vars as seen by core after injection
|
|
114
|
+
try {
|
|
115
|
+
console.debug("[core] final URLs", { API_BASE_URL, WS_BASE_URL });
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
58
118
|
engine = new SyncEngine({ transports: [restTransport, localNodeTransport] });
|
|
59
119
|
return { engine };
|
|
60
120
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing engine — server-parity POS pricing.
|
|
3
|
+
*
|
|
4
|
+
* Pure port of the backend `calculate_items` / `calculate_tax_lines` /
|
|
5
|
+
* `calculate_pricing` (TeincFoodBackend.Orders). Used as the instant local
|
|
6
|
+
* preview and offline fallback so terminals agree on pricing even when the
|
|
7
|
+
* cloud preview endpoint is unreachable. The cloud preview remains authoritative
|
|
8
|
+
* when it lands; this engine reproduces the same math exactly.
|
|
9
|
+
*
|
|
10
|
+
* Tax semantics (matching backend):
|
|
11
|
+
* - inclusive taxes are EMBEDDED in the menu price → never added to the total,
|
|
12
|
+
* only shown in `tax_lines[].is_inclusive` for the breakdown.
|
|
13
|
+
* - exclusive taxes are ADDED on top of the (discounted) subtotal.
|
|
14
|
+
* - `tax_minor` is the sum of ALL lines (display breakdown); `total_minor`
|
|
15
|
+
* adds only `exclusive_tax_minor`.
|
|
16
|
+
*/
|
|
17
|
+
import type { POSCartItem } from "../types/pos.types";
|
|
18
|
+
import type { TaxRule } from "../types/api/business.api";
|
|
19
|
+
import type { OrderTaxLine } from "../types/api/seller.api";
|
|
20
|
+
export interface PricingInput {
|
|
21
|
+
/** Cart items — `unit_price_minor` already includes variant + addons. */
|
|
22
|
+
items: POSCartItem[];
|
|
23
|
+
/** Active tax rules from cached reference data. */
|
|
24
|
+
taxRules: TaxRule[];
|
|
25
|
+
/** Optional promo discount in minor units. */
|
|
26
|
+
discount_minor?: number;
|
|
27
|
+
/** Delivery fee in minor units (POS is 0, but kept for parity). */
|
|
28
|
+
delivery_fee_minor?: number;
|
|
29
|
+
/** Tip in minor units. */
|
|
30
|
+
tip_minor?: number;
|
|
31
|
+
currency_code?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PricingResult {
|
|
34
|
+
subtotal_minor: number;
|
|
35
|
+
discounted_subtotal_minor: number;
|
|
36
|
+
delivery_fee_minor: number;
|
|
37
|
+
discount_minor: number;
|
|
38
|
+
tax_minor: number;
|
|
39
|
+
exclusive_tax_minor: number;
|
|
40
|
+
tip_minor: number;
|
|
41
|
+
total_minor: number;
|
|
42
|
+
currency_code: string;
|
|
43
|
+
tax_lines: OrderTaxLine[];
|
|
44
|
+
}
|
|
45
|
+
/** Backend parity: div((amount * rate + 5000), 10000) with integer math. */
|
|
46
|
+
export declare function calculateExclusiveTax(taxableAmountMinor: number, rateBasisPoints: number): number;
|
|
47
|
+
/** Backend parity: inclusive tax share = div(amount*rate + 5000, 10000 + rate). */
|
|
48
|
+
export declare function calculateInclusiveTaxShare(taxableAmountMinor: number, rateBasisPoints: number): number;
|
|
49
|
+
export declare function calculateTaxLines(subtotal_minor: number, taxRules: TaxRule[], discount_minor?: number, delivery_fee_minor?: number): {
|
|
50
|
+
tax_lines: OrderTaxLine[];
|
|
51
|
+
tax_minor: number;
|
|
52
|
+
exclusive_tax_minor: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Compute POS pricing mirrors backend `calculate_pos_pricing`:
|
|
56
|
+
* total = discounted_subtotal + exclusive_tax_minor (+ tip when provided).
|
|
57
|
+
*/
|
|
58
|
+
export declare function calculatePricing(input: PricingInput): PricingResult;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing engine — server-parity POS pricing.
|
|
3
|
+
*
|
|
4
|
+
* Pure port of the backend `calculate_items` / `calculate_tax_lines` /
|
|
5
|
+
* `calculate_pricing` (TeincFoodBackend.Orders). Used as the instant local
|
|
6
|
+
* preview and offline fallback so terminals agree on pricing even when the
|
|
7
|
+
* cloud preview endpoint is unreachable. The cloud preview remains authoritative
|
|
8
|
+
* when it lands; this engine reproduces the same math exactly.
|
|
9
|
+
*
|
|
10
|
+
* Tax semantics (matching backend):
|
|
11
|
+
* - inclusive taxes are EMBEDDED in the menu price → never added to the total,
|
|
12
|
+
* only shown in `tax_lines[].is_inclusive` for the breakdown.
|
|
13
|
+
* - exclusive taxes are ADDED on top of the (discounted) subtotal.
|
|
14
|
+
* - `tax_minor` is the sum of ALL lines (display breakdown); `total_minor`
|
|
15
|
+
* adds only `exclusive_tax_minor`.
|
|
16
|
+
*/
|
|
17
|
+
/** Backend parity: div((amount * rate + 5000), 10000) with integer math. */
|
|
18
|
+
export function calculateExclusiveTax(taxableAmountMinor, rateBasisPoints) {
|
|
19
|
+
if (taxableAmountMinor <= 0 || rateBasisPoints <= 0)
|
|
20
|
+
return 0;
|
|
21
|
+
return Math.floor((taxableAmountMinor * rateBasisPoints + 5000) / 10000);
|
|
22
|
+
}
|
|
23
|
+
/** Backend parity: inclusive tax share = div(amount*rate + 5000, 10000 + rate). */
|
|
24
|
+
export function calculateInclusiveTaxShare(taxableAmountMinor, rateBasisPoints) {
|
|
25
|
+
if (taxableAmountMinor <= 0 || rateBasisPoints <= 0)
|
|
26
|
+
return 0;
|
|
27
|
+
return Math.floor((taxableAmountMinor * rateBasisPoints + 5000) / (10000 + rateBasisPoints));
|
|
28
|
+
}
|
|
29
|
+
export function calculateTaxLines(subtotal_minor, taxRules, discount_minor = 0, delivery_fee_minor = 0) {
|
|
30
|
+
const discounted = Math.max(subtotal_minor - discount_minor, 0);
|
|
31
|
+
let tax_minor = 0;
|
|
32
|
+
let exclusive_tax_minor = 0;
|
|
33
|
+
const tax_lines = taxRules.map((rule) => {
|
|
34
|
+
const taxableAmount = rule.applies_to === "subtotal_plus_delivery"
|
|
35
|
+
? discounted + delivery_fee_minor
|
|
36
|
+
: discounted;
|
|
37
|
+
const taxAmount = rule.is_inclusive
|
|
38
|
+
? calculateInclusiveTaxShare(taxableAmount, rule.rate_basis_points)
|
|
39
|
+
: calculateExclusiveTax(taxableAmount, rule.rate_basis_points);
|
|
40
|
+
tax_minor += taxAmount;
|
|
41
|
+
if (!rule.is_inclusive)
|
|
42
|
+
exclusive_tax_minor += taxAmount;
|
|
43
|
+
return {
|
|
44
|
+
id: rule.id,
|
|
45
|
+
business_tax_rule_id: rule.id,
|
|
46
|
+
name: rule.name,
|
|
47
|
+
code: rule.code,
|
|
48
|
+
country_code: rule.country_code,
|
|
49
|
+
rate_basis_points: rule.rate_basis_points,
|
|
50
|
+
taxable_amount_minor: taxableAmount,
|
|
51
|
+
tax_amount_minor: taxAmount,
|
|
52
|
+
applies_to: rule.applies_to,
|
|
53
|
+
is_inclusive: rule.is_inclusive,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
return { tax_lines, tax_minor, exclusive_tax_minor };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compute POS pricing mirrors backend `calculate_pos_pricing`:
|
|
60
|
+
* total = discounted_subtotal + exclusive_tax_minor (+ tip when provided).
|
|
61
|
+
*/
|
|
62
|
+
export function calculatePricing(input) {
|
|
63
|
+
const subtotal_minor = input.items.reduce((sum, item) => sum + item.unit_price_minor * item.quantity, 0);
|
|
64
|
+
const discount_minor = input.discount_minor ?? 0;
|
|
65
|
+
const delivery_fee_minor = input.delivery_fee_minor ?? 0;
|
|
66
|
+
const tip_minor = input.tip_minor ?? 0;
|
|
67
|
+
const currency_code = input.currency_code ?? "GHS";
|
|
68
|
+
const { tax_lines, tax_minor, exclusive_tax_minor } = calculateTaxLines(subtotal_minor, input.taxRules, discount_minor, delivery_fee_minor);
|
|
69
|
+
const discounted = Math.max(subtotal_minor - discount_minor, 0);
|
|
70
|
+
return {
|
|
71
|
+
subtotal_minor,
|
|
72
|
+
discounted_subtotal_minor: discounted,
|
|
73
|
+
delivery_fee_minor,
|
|
74
|
+
discount_minor,
|
|
75
|
+
tax_minor,
|
|
76
|
+
exclusive_tax_minor,
|
|
77
|
+
tip_minor,
|
|
78
|
+
total_minor: discounted + delivery_fee_minor + exclusive_tax_minor + tip_minor,
|
|
79
|
+
currency_code,
|
|
80
|
+
tax_lines,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
});
|
package/dist/reference/api.d.ts
CHANGED
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
* These endpoints are expected on the backend. If they are not available,
|
|
5
5
|
* the ReferenceSyncService falls back to legacy menu endpoints.
|
|
6
6
|
*/
|
|
7
|
-
import type { ReferenceDataKey, ReferenceVersionInfo, ReferenceChangeSet } from "./types";
|
|
7
|
+
import type { ReferenceDataKey, ReferenceVersionInfo, ReferenceChangeSet, ReferenceDataRecord } from "./types";
|
|
8
|
+
/**
|
|
9
|
+
* Optional LAN supplier: when the cloud is unreachable (offline + connected to
|
|
10
|
+
* a Local Node), the host app can serve cached reference data from the node via
|
|
11
|
+
* the LAN instead of failing. Injected by the host (mobile/desktop) once at
|
|
12
|
+
* bootstrap; null means no LAN reference path.
|
|
13
|
+
*/
|
|
14
|
+
export type ReferenceLocalNodeFetcher = (options: {
|
|
15
|
+
businessId: string;
|
|
16
|
+
keys: ReferenceDataKey[];
|
|
17
|
+
}) => Promise<Partial<Record<ReferenceDataKey, ReferenceDataRecord | null>>>;
|
|
18
|
+
export declare function setReferenceLocalNodeFetcher(fetcher: ReferenceLocalNodeFetcher | null): void;
|
|
8
19
|
export declare function fetchReferenceVersions(businessId: string): Promise<ReferenceVersionInfo[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Fetch reference data for the requested keys. Falls back to the Local Node
|
|
22
|
+
* (LAN) when the cloud call fails and a LAN fetcher is configured — so a
|
|
23
|
+
* no-internet terminal can still converge after a `reference_data.changed`
|
|
24
|
+
* event relayed over the LAN.
|
|
25
|
+
*/
|
|
9
26
|
export declare function fetchReferenceData(businessId: string, keys: ReferenceDataKey[]): Promise<Record<ReferenceDataKey, unknown>>;
|
|
10
27
|
export declare function fetchReferenceChanges<T>(businessId: string, key: ReferenceDataKey, fromVersion: string): Promise<ReferenceChangeSet<T>>;
|
package/dist/reference/api.js
CHANGED
|
@@ -5,13 +5,36 @@
|
|
|
5
5
|
* the ReferenceSyncService falls back to legacy menu endpoints.
|
|
6
6
|
*/
|
|
7
7
|
import { api } from "../adapters/http";
|
|
8
|
+
let localNodeFetcher = null;
|
|
9
|
+
export function setReferenceLocalNodeFetcher(fetcher) {
|
|
10
|
+
localNodeFetcher = fetcher;
|
|
11
|
+
}
|
|
8
12
|
export async function fetchReferenceVersions(businessId) {
|
|
9
13
|
const response = await api.get(`/businesses/${businessId}/reference-versions`);
|
|
10
14
|
return response.data.data;
|
|
11
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Fetch reference data for the requested keys. Falls back to the Local Node
|
|
18
|
+
* (LAN) when the cloud call fails and a LAN fetcher is configured — so a
|
|
19
|
+
* no-internet terminal can still converge after a `reference_data.changed`
|
|
20
|
+
* event relayed over the LAN.
|
|
21
|
+
*/
|
|
12
22
|
export async function fetchReferenceData(businessId, keys) {
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
try {
|
|
24
|
+
const response = await api.get(`/businesses/${businessId}/reference-data`, { params: { keys: keys.join(",") } });
|
|
25
|
+
return response.data.data;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!localNodeFetcher)
|
|
29
|
+
throw error;
|
|
30
|
+
const cached = await localNodeFetcher({ businessId, keys });
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (cached[key]?.payload != null)
|
|
34
|
+
out[key] = cached[key].payload;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
15
38
|
}
|
|
16
39
|
export async function fetchReferenceChanges(businessId, key, fromVersion) {
|
|
17
40
|
const response = await api.get(`/businesses/${businessId}/reference-changes`, { params: { key, from_version: fromVersion } });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-capable mutation runner.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a direct REST service call (used by menu edits etc.) so that when the
|
|
5
|
+
* request fails with a NETWORK error (offline / cloud unreachable), the
|
|
6
|
+
* operation is retried through the Sync Engine instead — which routes it to
|
|
7
|
+
* the Local Node, or persists it in the durable outbox for replay when
|
|
8
|
+
* connectivity returns. Definitive server rejections (4xx) are NOT touched:
|
|
9
|
+
* they are real business decisions and must surface to the caller.
|
|
10
|
+
*
|
|
11
|
+
* Keeps the online path and its result shape unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export interface OfflineCapableOptions<TService, TCommandPayload> {
|
|
14
|
+
/** Direct REST call — used online. */
|
|
15
|
+
run: () => Promise<TService>;
|
|
16
|
+
/** Sync-engine command fallback (Local Node / outbox). */
|
|
17
|
+
commandType: string;
|
|
18
|
+
businessId: string;
|
|
19
|
+
entityId?: string;
|
|
20
|
+
payload: TCommandPayload;
|
|
21
|
+
}
|
|
22
|
+
export declare function runOfflineCapableMutation<TService, TCommandPayload>(options: OfflineCapableOptions<TService, TCommandPayload>): Promise<TService>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-capable mutation runner.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a direct REST service call (used by menu edits etc.) so that when the
|
|
5
|
+
* request fails with a NETWORK error (offline / cloud unreachable), the
|
|
6
|
+
* operation is retried through the Sync Engine instead — which routes it to
|
|
7
|
+
* the Local Node, or persists it in the durable outbox for replay when
|
|
8
|
+
* connectivity returns. Definitive server rejections (4xx) are NOT touched:
|
|
9
|
+
* they are real business decisions and must surface to the caller.
|
|
10
|
+
*
|
|
11
|
+
* Keeps the online path and its result shape unchanged.
|
|
12
|
+
*/
|
|
13
|
+
import { syncEngine } from "./engine";
|
|
14
|
+
import { buildCommand } from "./command-builder";
|
|
15
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
16
|
+
import { ApiError } from "../adapters/http";
|
|
17
|
+
export async function runOfflineCapableMutation(options) {
|
|
18
|
+
try {
|
|
19
|
+
return await options.run();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
|
23
|
+
// Definitive rejection — surface it, do not queue.
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
// Network error (no response), timeout, or transport failure: route through
|
|
27
|
+
// the Sync Engine (LAN node → outbox). The engine itself decides.
|
|
28
|
+
syncLogger.warn("OfflineCapable", "Direct call failed, routing via Sync Engine", {
|
|
29
|
+
command_type: options.commandType,
|
|
30
|
+
error: error instanceof Error ? error.message : String(error),
|
|
31
|
+
});
|
|
32
|
+
const command = buildCommand({
|
|
33
|
+
commandType: options.commandType,
|
|
34
|
+
businessId: options.businessId,
|
|
35
|
+
entityId: options.entityId,
|
|
36
|
+
payload: options.payload,
|
|
37
|
+
});
|
|
38
|
+
const events = await syncEngine.execute(command);
|
|
39
|
+
return events[0]?.payload;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber } from "./order-number";
|
|
3
|
+
import { removeItemSync } from "../adapters/kv";
|
|
4
|
+
// Use in-memory KV for tests (default driver is memory)
|
|
5
|
+
describe("order-number", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
removeItemSync("local_order_sequence");
|
|
8
|
+
removeItemSync("local_callout_sequence");
|
|
9
|
+
});
|
|
10
|
+
it("generates TFL order numbers with date and sequence", () => {
|
|
11
|
+
const d = new Date("2026-08-24T10:00:00.000Z");
|
|
12
|
+
const a = generateLocalOrderNumber(d);
|
|
13
|
+
const b = generateLocalOrderNumber(d);
|
|
14
|
+
expect(a).toBe("TFL-20260824-0001");
|
|
15
|
+
expect(b).toBe("TFL-20260824-0002");
|
|
16
|
+
expect(isLocalOrderNumber(a)).toBe(true);
|
|
17
|
+
expect(isLocalOrderNumber("KF-260701-0042")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it("generates incremental callout numbers per day", () => {
|
|
20
|
+
const d = new Date("2026-08-24T10:00:00.000Z");
|
|
21
|
+
expect(generateLocalCalloutNumber(d)).toBe(1);
|
|
22
|
+
expect(generateLocalCalloutNumber(d)).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
it("isolates sequences per day", () => {
|
|
25
|
+
const d1 = new Date("2026-08-24T10:00:00.000Z");
|
|
26
|
+
const d2 = new Date("2026-08-25T10:00:00.000Z");
|
|
27
|
+
expect(generateLocalOrderNumber(d1)).toBe("TFL-20260824-0001");
|
|
28
|
+
expect(generateLocalOrderNumber(d2)).toBe("TFL-20260825-0001");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* resulting domain events. This transport is used when the cloud API is
|
|
6
6
|
* reachable.
|
|
7
7
|
*/
|
|
8
|
-
import { posService, sellerService, kioskService } from "../adapters/services";
|
|
8
|
+
import { posService, sellerService, kioskService, menuService } from "../adapters/services";
|
|
9
9
|
import { syncLogger } from "../utils/sync-logger";
|
|
10
10
|
import { generateEventId } from "../utils/uuid";
|
|
11
11
|
export class RestTransport {
|
|
@@ -88,6 +88,22 @@ export class RestTransport {
|
|
|
88
88
|
const response = await sellerService.confirmHandover(entity_id);
|
|
89
89
|
return this.wrapResponse("delivery.status_changed", response, entity_id, timestamp, command);
|
|
90
90
|
}
|
|
91
|
+
case "menu:create_item": {
|
|
92
|
+
const response = await menuService.createMenuItem(business_id, payload);
|
|
93
|
+
return this.wrapResponse("menu.item_created", response, entity_id, timestamp, command);
|
|
94
|
+
}
|
|
95
|
+
case "menu:update_item": {
|
|
96
|
+
const response = await menuService.updateMenuItem(entity_id, payload);
|
|
97
|
+
return this.wrapResponse("menu.item_updated", response, entity_id, timestamp, command);
|
|
98
|
+
}
|
|
99
|
+
case "menu:delete_item": {
|
|
100
|
+
const response = await menuService.deleteMenuItem(entity_id);
|
|
101
|
+
return this.wrapResponse("menu.item_deleted", response, entity_id, timestamp, command);
|
|
102
|
+
}
|
|
103
|
+
case "menu:create_category": {
|
|
104
|
+
const response = await menuService.createCategory(business_id, payload);
|
|
105
|
+
return this.wrapResponse("menu.category_created", response, entity_id, timestamp, command);
|
|
106
|
+
}
|
|
91
107
|
default:
|
|
92
108
|
throw new Error(`RestTransport: unsupported command type ${command_type}`);
|
|
93
109
|
}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Application-wide constants — plain config, no expo dependencies.
|
|
3
3
|
* Values can be overridden via createTeincCore config (future).
|
|
4
4
|
*/
|
|
5
|
-
/** Base URL for the API —
|
|
6
|
-
export declare
|
|
7
|
-
/** WebSocket URL for Phoenix Channels */
|
|
8
|
-
export declare
|
|
5
|
+
/** Base URL for the API — set at runtime via createTeincCore({ apiBaseUrl }) from the host app's .env */
|
|
6
|
+
export declare let API_BASE_URL: string;
|
|
7
|
+
/** WebSocket URL for Phoenix Channels — set at runtime via createTeincCore({ wsBaseUrl }) */
|
|
8
|
+
export declare let WS_BASE_URL: string;
|
|
9
|
+
export declare function setApiBaseUrl(url: string | undefined): void;
|
|
10
|
+
export declare function setWsBaseUrl(url: string | undefined): void;
|
|
9
11
|
/** App metadata */
|
|
10
12
|
export declare const APP_NAME = "Teinc Food Business";
|
|
11
13
|
export declare const APP_VERSION = "1.0.0";
|
package/dist/utils/constants.js
CHANGED
|
@@ -2,14 +2,40 @@
|
|
|
2
2
|
* Application-wide constants — plain config, no expo dependencies.
|
|
3
3
|
* Values can be overridden via createTeincCore config (future).
|
|
4
4
|
*/
|
|
5
|
-
/** Base URL for the API —
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
/** Base URL for the API — set at runtime via createTeincCore({ apiBaseUrl }) from the host app's .env */
|
|
6
|
+
export let API_BASE_URL = "http://localhost:4000/api/v1";
|
|
7
|
+
/** WebSocket URL for Phoenix Channels — set at runtime via createTeincCore({ wsBaseUrl }) */
|
|
8
|
+
export let WS_BASE_URL = "ws://localhost:4000";
|
|
9
|
+
export function setApiBaseUrl(url) {
|
|
10
|
+
if (url) {
|
|
11
|
+
API_BASE_URL = url;
|
|
12
|
+
try {
|
|
13
|
+
console.debug("[core:constants] API_BASE_URL set", url);
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
try {
|
|
19
|
+
console.debug("[core:constants] API_BASE_URL using default", API_BASE_URL);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function setWsBaseUrl(url) {
|
|
25
|
+
if (url) {
|
|
26
|
+
WS_BASE_URL = url;
|
|
27
|
+
try {
|
|
28
|
+
console.debug("[core:constants] WS_BASE_URL set", url);
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
try {
|
|
34
|
+
console.debug("[core:constants] WS_BASE_URL using default", WS_BASE_URL);
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
13
39
|
/** App metadata */
|
|
14
40
|
export const APP_NAME = "Teinc Food Business";
|
|
15
41
|
export const APP_VERSION = "1.0.0";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatPrice, formatPriceFromMinor, getCurrencySymbol, DEFAULT_CURRENCY } from "./currency";
|
|
3
|
+
describe("currency", () => {
|
|
4
|
+
it("returns symbol for known currencies", () => {
|
|
5
|
+
expect(getCurrencySymbol("GHS")).toBe("GH₵");
|
|
6
|
+
expect(getCurrencySymbol("USD")).toBe("$");
|
|
7
|
+
expect(getCurrencySymbol("UNKNOWN")).toBe("UNKNOWN");
|
|
8
|
+
});
|
|
9
|
+
it("formats price with default currency", () => {
|
|
10
|
+
expect(formatPrice(10.5)).toBe(`${getCurrencySymbol(DEFAULT_CURRENCY)}10.50`);
|
|
11
|
+
});
|
|
12
|
+
it("formats price from minor units", () => {
|
|
13
|
+
expect(formatPriceFromMinor(1050, "GHS")).toBe("GH₵10.50");
|
|
14
|
+
expect(formatPriceFromMinor(100, "USD")).toBe("$1.00");
|
|
15
|
+
expect(formatPriceFromMinor(0)).toBe(`${getCurrencySymbol(DEFAULT_CURRENCY)}0.00`);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizePhoneForCountry, isLikelyE164, getDialCodeFromCountry } from "./phone";
|
|
3
|
+
describe("phone", () => {
|
|
4
|
+
it("normalizes GH numbers", () => {
|
|
5
|
+
expect(normalizePhoneForCountry("0501234567", "GH")).toBe("+233501234567");
|
|
6
|
+
expect(normalizePhoneForCountry("+233501234567", "GH")).toBe("+233501234567");
|
|
7
|
+
expect(normalizePhoneForCountry(" ", "GH")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
it("validates E.164", () => {
|
|
10
|
+
expect(isLikelyE164("+233501234567")).toBe(true);
|
|
11
|
+
expect(isLikelyE164("0501234567")).toBe(false);
|
|
12
|
+
expect(isLikelyE164("+1abc")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
it("resolves dial codes", () => {
|
|
15
|
+
expect(getDialCodeFromCountry("GH")).toBe("+233");
|
|
16
|
+
expect(getDialCodeFromCountry("US")).toBe("+1");
|
|
17
|
+
expect(getDialCodeFromCountry("XX")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -29,10 +29,20 @@ class SyncLogger {
|
|
|
29
29
|
this.warn = (namespace, message, data) => this.log("warn", namespace, message, data);
|
|
30
30
|
this.error = (namespace, message, data) => this.log("error", namespace, message, data);
|
|
31
31
|
try {
|
|
32
|
-
|
|
32
|
+
const isDev = typeof __DEV__ !== "undefined" ? __DEV__ : false;
|
|
33
|
+
if (typeof isDev !== "undefined") {
|
|
34
|
+
this.enabled = isDev;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
try {
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
const nodeEnv = typeof process !== "undefined" ? process?.env?.NODE_ENV : undefined;
|
|
42
|
+
this.enabled = nodeEnv !== "production";
|
|
33
43
|
}
|
|
34
44
|
catch {
|
|
35
|
-
this.enabled =
|
|
45
|
+
this.enabled = true;
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
setEnabled(enabled) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateUUID, generateCommandId, generateEventId } from "./uuid";
|
|
3
|
+
describe("uuid", () => {
|
|
4
|
+
it("generates valid v4 UUIDs", () => {
|
|
5
|
+
const id = generateUUID();
|
|
6
|
+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
7
|
+
});
|
|
8
|
+
it("generates prefixed ids", () => {
|
|
9
|
+
expect(generateCommandId()).toMatch(/^cmd_/);
|
|
10
|
+
expect(generateEventId()).toMatch(/^evt_/);
|
|
11
|
+
expect(generateCommandId()).not.toBe(generateEventId());
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateEmail, validatePhone, validatePassword, validatePromoCode } from "./validation";
|
|
3
|
+
describe("validation", () => {
|
|
4
|
+
it("validates email", () => {
|
|
5
|
+
expect(validateEmail("a@b.com").valid).toBe(true);
|
|
6
|
+
expect(validateEmail("bad").valid).toBe(false);
|
|
7
|
+
expect(validateEmail("").errors).toContain("Email is required");
|
|
8
|
+
});
|
|
9
|
+
it("validates phone", () => {
|
|
10
|
+
expect(validatePhone("+233501234567").valid).toBe(true);
|
|
11
|
+
expect(validatePhone("123").valid).toBe(true); // minimal valid per regex
|
|
12
|
+
expect(validatePhone("").valid).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
it("validates password strength", () => {
|
|
15
|
+
expect(validatePassword("StrongPass1").valid).toBe(true);
|
|
16
|
+
expect(validatePassword("short").valid).toBe(false);
|
|
17
|
+
expect(validatePassword("nouppercase1").valid).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it("validates promo code", () => {
|
|
20
|
+
expect(validatePromoCode("SAVE10").valid).toBe(true);
|
|
21
|
+
expect(validatePromoCode("ab").valid).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teincfood/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./dist/*.js": {
|
|
14
|
+
"types": "./dist/*.d.ts",
|
|
15
|
+
"import": "./dist/*.js"
|
|
16
|
+
},
|
|
13
17
|
"./dist/*": {
|
|
14
18
|
"types": "./dist/*.d.ts",
|
|
15
19
|
"import": "./dist/*.js"
|