@teincfood/core 0.1.3 → 0.1.5

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.
@@ -36,12 +36,17 @@ 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>;
42
46
  fetchBusinessTaxRules: (_businessId: string) => Promise<unknown>;
43
47
  fetchBusinessById: (_businessId: string) => Promise<unknown>;
44
48
  fetchOperatingHours: (_businessId: string) => Promise<unknown>;
49
+ fetchMyBusinessMembership: (_businessId: string) => Promise<unknown>;
45
50
  };
46
51
  export declare const rolesService: {
47
52
  fetchBusinessRoles: () => 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) => {
@@ -69,6 +81,9 @@ export const businessService = {
69
81
  fetchOperatingHours: async (_businessId) => {
70
82
  notImpl("businessService.fetchOperatingHours");
71
83
  },
84
+ fetchMyBusinessMembership: async (_businessId) => {
85
+ notImpl("businessService.fetchMyBusinessMembership");
86
+ },
72
87
  };
73
88
  export const rolesService = {
74
89
  fetchBusinessRoles: async () => {
package/dist/index.d.ts CHANGED
@@ -6,25 +6,55 @@
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 { SyncEngine } from "./sync/engine";
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";
57
+ export * from "./reference/capabilities";
28
58
  export * from "./adapters/sqlite";
29
59
  export * from "./adapters/kv";
30
60
  export * from "./adapters/connectivity";
@@ -41,6 +71,8 @@ export interface CoreConfig {
41
71
  sqliteDriver?: SqliteDriver;
42
72
  kvDriver?: KVDriver;
43
73
  httpClient?: HttpClient;
74
+ apiBaseUrl?: string;
75
+ wsBaseUrl?: string;
44
76
  /** Optional initial connectivity override */
45
77
  initialConnectivity?: {
46
78
  isOnline?: boolean;
package/dist/index.js CHANGED
@@ -7,30 +7,55 @@
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 { SyncEngine } from "./sync/engine";
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";
58
+ export * from "./reference/capabilities";
34
59
  // ── Adapters (for host apps to supply) ──
35
60
  export * from "./adapters/sqlite";
36
61
  export * from "./adapters/kv";
@@ -45,19 +70,52 @@ import { setSqliteDriver } from "./adapters/sqlite";
45
70
  import { setKVDriver } from "./adapters/kv";
46
71
  import { setConnectivityState } from "./adapters/connectivity";
47
72
  import { setHttpClient } from "./adapters/http";
73
+ import { API_BASE_URL, WS_BASE_URL, setApiBaseUrl, setWsBaseUrl } from "./utils/constants";
48
74
  import { SyncEngine } from "./sync/engine";
49
75
  import { restTransport } from "./sync/transport-rest";
50
76
  import { localNodeTransport } from "./sync/transport-local-node";
51
77
  let engine = null;
52
78
  export function createTeincCore(config = {}) {
79
+ try {
80
+ console.debug("[core] createTeincCore called", {
81
+ hasSqliteDriver: !!config.sqliteDriver,
82
+ hasKvDriver: !!config.kvDriver,
83
+ hasHttpClient: !!config.httpClient,
84
+ apiBaseUrl: config.apiBaseUrl ?? "(default)",
85
+ wsBaseUrl: config.wsBaseUrl ?? "(default)",
86
+ initialConnectivity: config.initialConnectivity ?? null,
87
+ });
88
+ }
89
+ catch { }
53
90
  if (config.sqliteDriver)
54
91
  setSqliteDriver(config.sqliteDriver);
55
92
  if (config.kvDriver)
56
93
  setKVDriver(config.kvDriver);
57
94
  if (config.httpClient)
58
95
  setHttpClient(config.httpClient);
96
+ if (config.apiBaseUrl)
97
+ setApiBaseUrl(config.apiBaseUrl);
98
+ else {
99
+ try {
100
+ console.debug("[core] apiBaseUrl not provided by app, using default", API_BASE_URL);
101
+ }
102
+ catch { }
103
+ }
104
+ if (config.wsBaseUrl)
105
+ setWsBaseUrl(config.wsBaseUrl);
106
+ else {
107
+ try {
108
+ console.debug("[core] wsBaseUrl not provided by app, using default", WS_BASE_URL);
109
+ }
110
+ catch { }
111
+ }
59
112
  if (config.initialConnectivity)
60
113
  setConnectivityState(config.initialConnectivity);
114
+ // Debug: confirm env vars as seen by core after injection
115
+ try {
116
+ console.debug("[core] final URLs", { API_BASE_URL, WS_BASE_URL });
117
+ }
118
+ catch { }
61
119
  engine = new SyncEngine({ transports: [restTransport, localNodeTransport] });
62
120
  return { engine };
63
121
  }
@@ -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
+ });
@@ -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>>;
@@ -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
- const response = await api.get(`/businesses/${businessId}/reference-data`, { params: { keys: keys.join(",") } });
14
- return response.data.data;
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,21 @@
1
+ /**
2
+ * Capabilities reference — offline-first.
3
+ *
4
+ * Standardises business-membership capabilities for both mobile and desktop.
5
+ * The backend `GET /businesses/:id/my-membership` is the source of truth;
6
+ * the result is mirrored to `reference_data` key `capabilities` so drawer/nav
7
+ * filtering works offline and survives restarts.
8
+ *
9
+ * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
+ */
11
+ /**
12
+ * Standard hook for both apps — businessId comes from the host's active business store.
13
+ * Returns the same shape as the previous app-level useMyBusinessCapabilities.
14
+ */
15
+ export declare function useBusinessCapabilities(businessId?: string | null): import("@tanstack/react-query").UseQueryResult<{
16
+ role: string;
17
+ source: string;
18
+ capabilities: string[];
19
+ status: string;
20
+ }, Error>;
21
+ export declare function useHasCapabilityFor(businessId: string | undefined, capability: string): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Capabilities reference — offline-first.
3
+ *
4
+ * Standardises business-membership capabilities for both mobile and desktop.
5
+ * The backend `GET /businesses/:id/my-membership` is the source of truth;
6
+ * the result is mirrored to `reference_data` key `capabilities` so drawer/nav
7
+ * filtering works offline and survives restarts.
8
+ *
9
+ * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
+ */
11
+ import { useEffect, useState } from "react";
12
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
13
+ import { openSyncDatabase } from "../db/connection";
14
+ import { getReferenceData, setReferenceData } from "./operations";
15
+ import { businessService } from "../adapters/services";
16
+ const MEMBERSHIP_KEYS = {
17
+ all: ["my-membership"],
18
+ byBusiness: (id) => [...MEMBERSHIP_KEYS.all, id],
19
+ };
20
+ function useCapabilitiesPlaceholder(businessId) {
21
+ const [cached, setCached] = useState(null);
22
+ useEffect(() => {
23
+ if (!businessId) {
24
+ setCached(null);
25
+ return;
26
+ }
27
+ let cancelled = false;
28
+ (async () => {
29
+ try {
30
+ const db = await openSyncDatabase();
31
+ const rec = await getReferenceData(db, businessId, "capabilities");
32
+ if (!cancelled && rec?.payload)
33
+ setCached(rec.payload);
34
+ }
35
+ catch { }
36
+ })();
37
+ return () => {
38
+ cancelled = true;
39
+ };
40
+ }, [businessId]);
41
+ return cached;
42
+ }
43
+ /**
44
+ * Standard hook for both apps — businessId comes from the host's active business store.
45
+ * Returns the same shape as the previous app-level useMyBusinessCapabilities.
46
+ */
47
+ export function useBusinessCapabilities(businessId) {
48
+ const queryClient = useQueryClient();
49
+ void queryClient;
50
+ const cached = useCapabilitiesPlaceholder(businessId ?? null);
51
+ return useQuery({
52
+ queryKey: MEMBERSHIP_KEYS.byBusiness(businessId ?? ""),
53
+ queryFn: async () => {
54
+ const res = (await businessService.fetchMyBusinessMembership(businessId));
55
+ try {
56
+ const db = await openSyncDatabase();
57
+ await setReferenceData(db, businessId, {
58
+ key: "capabilities",
59
+ version: `membership-${Date.now()}`,
60
+ payload: res,
61
+ downloadedAt: new Date().toISOString(),
62
+ });
63
+ }
64
+ catch { }
65
+ return res;
66
+ },
67
+ enabled: !!businessId,
68
+ staleTime: Infinity,
69
+ placeholderData: cached ?? undefined,
70
+ select: (data) => ({
71
+ role: data.data.role,
72
+ source: data.data.source,
73
+ capabilities: data.data.capabilities,
74
+ status: data.data.status,
75
+ }),
76
+ });
77
+ }
78
+ export function useHasCapabilityFor(businessId, capability) {
79
+ const { data } = useBusinessCapabilities(businessId);
80
+ return data?.capabilities.includes(capability) ?? false;
81
+ }
@@ -23,6 +23,7 @@ const DEFAULT_SYNC_KEYS = [
23
23
  "business:profile",
24
24
  "operating-hours",
25
25
  "subscription:entitlements",
26
+ "capabilities",
26
27
  ];
27
28
  class ReferenceSyncService {
28
29
  constructor() {
@@ -204,6 +205,13 @@ class ReferenceSyncService {
204
205
  version: this.hashPayload(response),
205
206
  };
206
207
  }
208
+ case "capabilities": {
209
+ const response = await businessService.fetchMyBusinessMembership(businessId);
210
+ return {
211
+ payload: response,
212
+ version: this.hashPayload(response),
213
+ };
214
+ }
207
215
  default:
208
216
  throw new Error(`No fallback fetch available for ${key}`);
209
217
  }
@@ -8,7 +8,7 @@
8
8
  * It is synchronized separately from transactional data through the
9
9
  * ReferenceSyncService.
10
10
  */
11
- export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "subscription:entitlements";
11
+ export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "subscription:entitlements" | "capabilities";
12
12
  export interface ReferenceDataRecord<T = unknown> {
13
13
  key: ReferenceDataKey;
14
14
  version: string;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { Command, DomainEvent, PersistedCommand } from "./types";
12
12
  import type { SyncEngine } from "./engine";
13
+ export declare function getQueuedStatus(commandType: string): string | null;
13
14
  declare class CommandQueueService {
14
15
  private replaying;
15
16
  private syncEngine;
@@ -14,8 +14,34 @@ import { syncLogger } from "../utils/sync-logger";
14
14
  import { generateEventId } from "../utils/uuid";
15
15
  import { generateLocalOrderNumber, generateLocalCalloutNumber, } from "./order-number";
16
16
  const MAX_RETRIES = 5;
17
+ const QUEUED_STATUS_BY_COMMAND = {
18
+ "order:accept": "accepted",
19
+ "order:reject": "cancelled",
20
+ "order:start_preparing": "preparing",
21
+ "order:mark_ready": "ready",
22
+ "order:complete_pickup": "delivered",
23
+ "order:cancel": "cancelled",
24
+ "delivery:assign": "assigned",
25
+ "delivery:confirm_handover": "delivered",
26
+ };
27
+ export function getQueuedStatus(commandType) {
28
+ return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
29
+ }
17
30
  function buildOptimisticEvent(command) {
18
31
  const payload = command.payload;
32
+ if (command.command_type === "order:create") {
33
+ return {
34
+ event_id: generateEventId(),
35
+ event_type: `${command.command_type.replace(":", ".")}_queued`,
36
+ event_version: "1.0",
37
+ entity_id: command.entity_id ?? command.command_id,
38
+ user_id: command.user_id,
39
+ device_id: command.device_id,
40
+ payload: buildOptimisticOrder(command, payload),
41
+ timestamp: new Date().toISOString(),
42
+ };
43
+ }
44
+ const queuedStatus = getQueuedStatus(command.command_type);
19
45
  return {
20
46
  event_id: generateEventId(),
21
47
  event_type: `${command.command_type.replace(":", ".")}_queued`,
@@ -23,13 +49,15 @@ function buildOptimisticEvent(command) {
23
49
  entity_id: command.entity_id ?? command.command_id,
24
50
  user_id: command.user_id,
25
51
  device_id: command.device_id,
26
- payload: command.command_type === "order:create"
27
- ? buildOptimisticOrder(command, payload)
28
- : {
29
- ...payload,
30
- command_id: command.command_id,
31
- status: "pending",
32
- },
52
+ payload: {
53
+ ...payload,
54
+ command_id: command.command_id,
55
+ status: "pending",
56
+ // Used by offline read-model patching so the list reflects the queued transition.
57
+ to_status: queuedStatus,
58
+ order_id: command.entity_id,
59
+ delivery_id: command.entity_id,
60
+ },
33
61
  timestamp: new Date().toISOString(),
34
62
  };
35
63
  }
@@ -136,17 +164,17 @@ class CommandQueueService {
136
164
  order_number: event.payload.order_number ?? null,
137
165
  status: event.payload.status,
138
166
  });
139
- // Persist the optimistic order so it survives app restarts and can be
140
- // rehydrated (and shown) while still offline.
141
- if (preparedCommand.command_type === "order:create") {
142
- try {
143
- await insertEvent(db, { event, origin: "local" });
144
- }
145
- catch (error) {
146
- syncLogger.error("CommandQueueService", "Failed to persist optimistic event", {
147
- error: String(error),
148
- });
149
- }
167
+ // Persist optimistic events so they survive restarts and can be
168
+ // rehydrated offline. For order:create this is the full order; for
169
+ // transitions it carries to_status so the read model can reflect the
170
+ // queued status immediately.
171
+ try {
172
+ await insertEvent(db, { event, origin: "local" });
173
+ }
174
+ catch (error) {
175
+ syncLogger.error("CommandQueueService", "Failed to persist optimistic event", {
176
+ error: String(error),
177
+ });
150
178
  }
151
179
  return event;
152
180
  }
@@ -188,6 +216,24 @@ class CommandQueueService {
188
216
  }
189
217
  catch (error) {
190
218
  const message = error instanceof Error ? error.message : String(error);
219
+ // Distinguish retryable (network/5xx/timeout) vs non-retryable (4xx validation)
220
+ // so a rejected order does not block the entire outbox.
221
+ const apiErr = error;
222
+ const isNonRetryable = apiErr?.name === "ApiError" &&
223
+ typeof apiErr.status === "number" &&
224
+ apiErr.status >= 400 &&
225
+ apiErr.status < 500;
226
+ if (isNonRetryable) {
227
+ await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
228
+ syncLogger.warn("CommandQueueService", "Queued command rejected by server (not retrying)", {
229
+ command_id: command.command_id,
230
+ command_type: command.command_type,
231
+ status: apiErr.status,
232
+ error: message,
233
+ });
234
+ // Do not throw — allow replay to continue to next command.
235
+ return;
236
+ }
191
237
  await updateCommandStatus(db, command.command_id, "failed", message);
192
238
  syncLogger.command(parsedCommand, "failed", { error: message });
193
239
  throw error;
@@ -218,8 +264,17 @@ class CommandQueueService {
218
264
  try {
219
265
  await this.processCommand(command);
220
266
  }
221
- catch {
222
- // Stop replay on first failure; retry will happen on next trigger.
267
+ catch (e) {
268
+ const apiErr = e;
269
+ const isNonRetryable = apiErr?.name === "ApiError" &&
270
+ typeof apiErr.status === "number" &&
271
+ apiErr.status >= 400 &&
272
+ apiErr.status < 500;
273
+ if (isNonRetryable) {
274
+ // Already marked permanent in processCommand — continue to next.
275
+ continue;
276
+ }
277
+ // Network/5xx — stop to preserve ordering; retry later.
223
278
  break;
224
279
  }
225
280
  }
@@ -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
+ }
@@ -22,6 +22,13 @@ declare class OrderSnapshotsService {
22
22
  * duplicates).
23
23
  */
24
24
  persistOrderEvent(event: DomainEvent, origin: "local" | "lan" | "cloud"): Promise<boolean>;
25
+ /**
26
+ * Patch the latest status for a queued transition (e.g. accept_queued)
27
+ * carrying to_status/order_id. Used so offline devices see the new status
28
+ * immediately and after restart via the durable snapshot.
29
+ */
30
+ applyQueuedStatusEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
31
+ private applyOrderStatusToReadModelInternal;
25
32
  /**
26
33
  * Patch the latest status of a known order inside every list snapshot for the
27
34
  * business, so offline devices see live status changes without a refetch.
@@ -50,17 +50,29 @@ class OrderSnapshotsService {
50
50
  }
51
51
  }
52
52
  /**
53
- * Patch the latest status of a known order inside every list snapshot for the
54
- * business, so offline devices see live status changes without a refetch.
53
+ * Patch the latest status for a queued transition (e.g. accept_queued)
54
+ * carrying to_status/order_id. Used so offline devices see the new status
55
+ * immediately and after restart via the durable snapshot.
55
56
  */
56
- async applyOrderStatusToReadModel(db, event) {
57
+ async applyQueuedStatusEvent(event, origin = "local") {
57
58
  const payload = event.payload;
58
- const orderId = payload?.order_id;
59
- const toStatus = payload?.to_status ??
60
- payload?.status;
61
- if (!orderId || !toStatus || event.event_type !== "order.status_changed") {
59
+ const toStatus = payload?.to_status;
60
+ const orderId = payload?.order_id ?? event.entity_id;
61
+ if (!toStatus || !orderId)
62
62
  return;
63
+ try {
64
+ const db = await openSyncDatabase();
65
+ await insertEvent(db, { event, origin });
66
+ await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
67
+ }
68
+ catch (error) {
69
+ syncLogger.error("OrderSnapshots", "Failed to apply queued status", {
70
+ error: String(error),
71
+ event_type: event.event_type,
72
+ });
63
73
  }
74
+ }
75
+ async applyOrderStatusToReadModelInternal(db, orderId, toStatus, event) {
64
76
  try {
65
77
  const lists = await getEntitySnapshotsByType(db, "seller_orders");
66
78
  for (const list of lists) {
@@ -85,6 +97,20 @@ class OrderSnapshotsService {
85
97
  });
86
98
  }
87
99
  }
100
+ /**
101
+ * Patch the latest status of a known order inside every list snapshot for the
102
+ * business, so offline devices see live status changes without a refetch.
103
+ */
104
+ async applyOrderStatusToReadModel(db, event) {
105
+ const payload = event.payload;
106
+ const orderId = payload?.order_id;
107
+ const toStatus = payload?.to_status ??
108
+ payload?.status;
109
+ if (!orderId || !toStatus || event.event_type !== "order.status_changed") {
110
+ return;
111
+ }
112
+ await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
113
+ }
88
114
  /**
89
115
  * Write through a fetched order list so it is available offline. Used by the
90
116
  * seller and POS order queries. `scope` distinguishes complementary lists
@@ -26,6 +26,16 @@ const ORDER_STATE_EVENT_TYPES = new Set([
26
26
  "delivery.status_changed",
27
27
  "delivery.assigned",
28
28
  ]);
29
+ const QUEUED_STATUS_EVENT_TYPES = new Set([
30
+ "order.accept_queued",
31
+ "order.reject_queued",
32
+ "order.start_preparing_queued",
33
+ "order.mark_ready_queued",
34
+ "order.complete_pickup_queued",
35
+ "order.cancel_queued",
36
+ "delivery.assign_queued",
37
+ "delivery.confirm_handover_queued",
38
+ ]);
29
39
  let invalidator = null;
30
40
  /**
31
41
  * Register a callback used to invalidate TanStack Query caches when a pending
@@ -88,7 +98,8 @@ export async function hydratePendingOrders() {
88
98
  */
89
99
  export async function applyOrderStateChangeEvent(event, origin = "local") {
90
100
  try {
91
- if (!ORDER_STATE_EVENT_TYPES.has(event.event_type))
101
+ const isQueued = QUEUED_STATUS_EVENT_TYPES.has(event.event_type);
102
+ if (!ORDER_STATE_EVENT_TYPES.has(event.event_type) && !isQueued)
92
103
  return;
93
104
  syncLogger.debug("OrderState", "Order state changed", {
94
105
  origin,
@@ -97,6 +108,12 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
97
108
  });
98
109
  // Persist the event into the durable journal and patch the offline read
99
110
  // model so every terminal keeps the same order states after a restart.
111
+ // Queued transitions carry to_status/order_id so they patch instantly offline.
112
+ if (isQueued) {
113
+ await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
114
+ invalidator?.();
115
+ return;
116
+ }
100
117
  await orderSnapshotsService.persistOrderEvent(event, origin);
101
118
  invalidator?.();
102
119
  }
@@ -118,6 +135,14 @@ export async function applyPendingOrderEvent(event, origin = "local") {
118
135
  await insertEvent(db, { event, origin });
119
136
  return;
120
137
  }
138
+ if (QUEUED_STATUS_EVENT_TYPES.has(event.event_type)) {
139
+ // Queued transitions: patch the read model so listing reflects new status offline.
140
+ await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
141
+ const db = await openSyncDatabase();
142
+ await insertEvent(db, { event, origin });
143
+ invalidator?.();
144
+ return;
145
+ }
121
146
  if (event.event_type === "order.created") {
122
147
  const payload = event.payload;
123
148
  const localOrderNumber = payload?.local_order_number;
@@ -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
  }
@@ -59,6 +59,7 @@ export interface POSPricing {
59
59
  discounted_subtotal_minor: number;
60
60
  delivery_fee_minor: number;
61
61
  tax_minor: number;
62
+ exclusive_tax_minor?: number;
62
63
  tip_minor: number;
63
64
  discount_minor: number;
64
65
  total_minor: number;
@@ -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 — will be swapped per environment */
6
- export declare const API_BASE_URL: string;
7
- /** WebSocket URL for Phoenix Channels */
8
- export declare const WS_BASE_URL: string;
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";
@@ -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 — will be swapped per environment */
6
- export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ??
7
- process.env.API_URL ??
8
- "http://localhost:4000/api/v1";
9
- /** WebSocket URL for Phoenix Channels */
10
- export const WS_BASE_URL = process.env.EXPO_PUBLIC_WS_URL ??
11
- process.env.WS_URL ??
12
- "ws://localhost:4000";
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";
@@ -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
- this.enabled = typeof __DEV__ !== "undefined" ? __DEV__ : process.env.NODE_ENV !== "production";
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 = process.env.NODE_ENV !== "production";
45
+ this.enabled = true;
36
46
  }
37
47
  }
38
48
  setEnabled(enabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",