@teincfood/core 0.1.6 → 0.7.1

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.
Files changed (44) hide show
  1. package/dist/adapters/kv.js +37 -2
  2. package/dist/db/migrations.js +10 -0
  3. package/dist/index.d.ts +5 -1
  4. package/dist/index.js +5 -1
  5. package/dist/reference/capabilities.d.ts +3 -0
  6. package/dist/reference/capabilities.js +36 -0
  7. package/dist/reference/lww.d.ts +14 -0
  8. package/dist/reference/lww.js +45 -0
  9. package/dist/reference/lww.test.d.ts +1 -0
  10. package/dist/reference/lww.test.js +23 -0
  11. package/dist/reference/operations.d.ts +1 -0
  12. package/dist/reference/operations.js +36 -4
  13. package/dist/reference/operations.test.d.ts +1 -0
  14. package/dist/reference/operations.test.js +66 -0
  15. package/dist/reference/service.js +2 -3
  16. package/dist/reference/types.d.ts +4 -0
  17. package/dist/standalone.integration.test.d.ts +1 -0
  18. package/dist/standalone.integration.test.js +235 -0
  19. package/dist/sync/command-queue.service.js +29 -5
  20. package/dist/sync/device-lease.service.d.ts +19 -0
  21. package/dist/sync/device-lease.service.js +90 -0
  22. package/dist/sync/device-lease.test.d.ts +1 -0
  23. package/dist/sync/device-lease.test.js +38 -0
  24. package/dist/sync/engine.js +21 -0
  25. package/dist/sync/offline-capable.js +19 -0
  26. package/dist/sync/offline-integration.test.d.ts +1 -0
  27. package/dist/sync/offline-integration.test.js +52 -0
  28. package/dist/sync/order-number.d.ts +21 -17
  29. package/dist/sync/order-number.js +69 -43
  30. package/dist/sync/order-number.test.js +44 -23
  31. package/dist/sync/perms.test.d.ts +1 -0
  32. package/dist/sync/perms.test.js +15 -0
  33. package/dist/sync/use-device-lease.d.ts +11 -0
  34. package/dist/sync/use-device-lease.js +40 -0
  35. package/dist/sync/use-sync-reconciliation.js +1 -1
  36. package/dist/test-helpers/memory-driver.d.ts +8 -0
  37. package/dist/test-helpers/memory-driver.js +242 -0
  38. package/dist/utils/constants.d.ts +3 -3
  39. package/dist/utils/constants.js +1 -1
  40. package/dist/utils/hlc.d.ts +18 -0
  41. package/dist/utils/hlc.js +60 -0
  42. package/dist/utils/hlc.test.d.ts +1 -0
  43. package/dist/utils/hlc.test.js +29 -0
  44. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
12
  import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { generateEventId } from "../utils/uuid";
15
- import { generateLocalOrderNumber, generateLocalCalloutNumber, } from "./order-number";
15
+ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
16
16
  const MAX_RETRIES = 5;
17
17
  const QUEUED_STATUS_BY_COMMAND = {
18
18
  "order:accept": "accepted",
@@ -65,7 +65,7 @@ function buildOptimisticOrder(command, payload) {
65
65
  const items = payload.items ?? [];
66
66
  const totalMinor = payload.total_minor ?? 0;
67
67
  const now = new Date().toISOString();
68
- const orderNumber = payload.order_number ?? generateLocalOrderNumber();
68
+ const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
69
69
  return {
70
70
  id: command.entity_id ?? command.command_id,
71
71
  order_number: orderNumber,
@@ -125,11 +125,13 @@ function injectLocalOrderNumber(command) {
125
125
  const payload = command.payload;
126
126
  if (payload.order_number)
127
127
  return command;
128
+ // 0.7.0: business-scoped DD-RRR-CCC; requires leased device_number
129
+ const orderNumber = generateOrderNumberForBusiness(command.business_id);
128
130
  return {
129
131
  ...command,
130
132
  payload: {
131
133
  ...payload,
132
- order_number: generateLocalOrderNumber(),
134
+ order_number: orderNumber,
133
135
  },
134
136
  };
135
137
  }
@@ -216,9 +218,31 @@ class CommandQueueService {
216
218
  }
217
219
  catch (error) {
218
220
  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
221
  const apiErr = error;
222
+ // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR
223
+ const code = apiErr?.code ?? apiErr?.data?.code;
224
+ if (apiErr?.status === 409 && String(code).includes("ORDER_NUMBER_TAKEN")) {
225
+ // Regenerate random part of order_number if this was order:create
226
+ if (command.command_type === "order:create") {
227
+ try {
228
+ const payload = command.payload;
229
+ const on = payload.order_number;
230
+ const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
231
+ if (parsed) {
232
+ const deviceNumber = parsed[1];
233
+ const { generateOrderNumber } = await import("./order-number");
234
+ const { orderNumber: newOn } = generateOrderNumber(deviceNumber);
235
+ const newPayload = { ...payload, order_number: newOn };
236
+ await updateCommandStatus(db, command.command_id, "failed", `retry_random:${message}`);
237
+ // Update stored payload for retry
238
+ await db.runAsync(`UPDATE commands SET payload = ?, retry_count = retry_count WHERE command_id = ?;`, JSON.stringify(newPayload), command.command_id);
239
+ }
240
+ }
241
+ catch { }
242
+ }
243
+ await updateCommandStatus(db, command.command_id, "failed", `order_number_taken:${message}`);
244
+ throw error;
245
+ }
222
246
  const isNonRetryable = apiErr?.name === "ApiError" &&
223
247
  typeof apiErr.status === "number" &&
224
248
  apiErr.status >= 400 &&
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Device lease service: DD 01-99 per business.
3
+ * Backend authority: POST /businesses/:id/devices/lease
4
+ * KV persists device_number per businessId.
5
+ */
6
+ declare function getAppInstanceId(): string;
7
+ export declare function getDeviceNumber(businessId: string): string | null;
8
+ export declare function isOnlineOnly(businessId: string): boolean;
9
+ export declare function ensureLease(businessId: string): Promise<string | null>;
10
+ export declare function refreshLease(businessId: string): Promise<void>;
11
+ export declare function clearLeaseForTests(businessId: string): void;
12
+ export declare const deviceLeaseService: {
13
+ getDeviceNumber: typeof getDeviceNumber;
14
+ isOnlineOnly: typeof isOnlineOnly;
15
+ ensureLease: typeof ensureLease;
16
+ refreshLease: typeof refreshLease;
17
+ getAppInstanceId: typeof getAppInstanceId;
18
+ };
19
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Device lease service: DD 01-99 per business.
3
+ * Backend authority: POST /businesses/:id/devices/lease
4
+ * KV persists device_number per businessId.
5
+ */
6
+ import { getItemSync as getSync, setItemSync as setSync } from "../adapters/kv";
7
+ import { getHttpClient } from "../adapters/http";
8
+ import { generateUUID } from "../utils/uuid";
9
+ import { syncLogger } from "../utils/sync-logger";
10
+ const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
11
+ const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
12
+ function getAppInstanceId() {
13
+ let id = getSync("teinc_app_instance_id");
14
+ if (!id) {
15
+ id = generateUUID();
16
+ setSync("teinc_app_instance_id", id);
17
+ }
18
+ return id;
19
+ }
20
+ export function getDeviceNumber(businessId) {
21
+ const v = getSync(LEASE_KEY(businessId));
22
+ if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
23
+ return v;
24
+ return null;
25
+ }
26
+ export function isOnlineOnly(businessId) {
27
+ return getDeviceNumber(businessId) == null;
28
+ }
29
+ export async function ensureLease(businessId) {
30
+ const existing = getDeviceNumber(businessId);
31
+ if (existing)
32
+ return existing;
33
+ try {
34
+ const http = getHttpClient();
35
+ const deviceUuid = getAppInstanceId();
36
+ const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
37
+ const data = res.data?.data ?? res.data;
38
+ const dn = data.device_number;
39
+ if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
40
+ setSync(LEASE_KEY(businessId), dn);
41
+ setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
42
+ syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
43
+ return dn;
44
+ }
45
+ }
46
+ catch (e) {
47
+ const apiErr = e;
48
+ if (apiErr?.status === 429 || e?.message?.includes("DEVICE_LIMIT_REACHED")) {
49
+ syncLogger.warn("DeviceLease", "Device limit reached — online only", { businessId });
50
+ return null;
51
+ }
52
+ syncLogger.warn("DeviceLease", "Lease failed — will operate online-only until next boot", {
53
+ businessId,
54
+ error: String(e),
55
+ });
56
+ }
57
+ return null;
58
+ }
59
+ export async function refreshLease(businessId) {
60
+ const dn = getDeviceNumber(businessId);
61
+ if (!dn) {
62
+ await ensureLease(businessId);
63
+ return;
64
+ }
65
+ const leasedAt = getSync(`${LEASE_KEY(businessId)}:leased_at`);
66
+ if (leasedAt && Date.now() - new Date(leasedAt).getTime() < LEASE_TTL_MS - 24 * 60 * 60 * 1000) {
67
+ return;
68
+ }
69
+ try {
70
+ const http = getHttpClient();
71
+ await http.patch(`/businesses/${businessId}/devices/${getAppInstanceId()}/touch`, {});
72
+ setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
73
+ }
74
+ catch {
75
+ // ignore — will retry next boot
76
+ }
77
+ }
78
+ export function clearLeaseForTests(businessId) {
79
+ // test helper
80
+ const { removeItemSync } = require("../adapters/kv");
81
+ removeItemSync(LEASE_KEY(businessId));
82
+ removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
83
+ }
84
+ export const deviceLeaseService = {
85
+ getDeviceNumber,
86
+ isOnlineOnly,
87
+ ensureLease,
88
+ refreshLease,
89
+ getAppInstanceId,
90
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { getDeviceNumber, isOnlineOnly } from "./device-lease.service";
3
+ import { removeItemSync, setItemSync } from "../adapters/kv";
4
+ import { setHttpClient } from "../adapters/http";
5
+ describe("device-lease", () => {
6
+ beforeEach(() => {
7
+ removeItemSync("teinc_device_number:biz1");
8
+ removeItemSync("teinc_device_number:biz1:leased_at");
9
+ removeItemSync("teinc_app_instance_id");
10
+ });
11
+ it("returns null when no lease", () => {
12
+ expect(getDeviceNumber("biz1")).toBeNull();
13
+ expect(isOnlineOnly("biz1")).toBe(true);
14
+ });
15
+ it("returns leased number after set", () => {
16
+ setItemSync("teinc_device_number:biz1", "05");
17
+ expect(getDeviceNumber("biz1")).toBe("05");
18
+ expect(isOnlineOnly("biz1")).toBe(false);
19
+ });
20
+ it("rejects invalid DD 00", () => {
21
+ setItemSync("teinc_device_number:biz1", "00");
22
+ expect(getDeviceNumber("biz1")).toBeNull();
23
+ });
24
+ it("ensureLease handles 429 limit", async () => {
25
+ // mock http client that returns 429
26
+ setHttpClient({
27
+ get: async () => ({ data: null }),
28
+ post: async () => { throw Object.assign(new Error("DEVICE_LIMIT_REACHED"), { status: 429, name: "ApiError" }); },
29
+ put: async () => ({ data: null }),
30
+ patch: async () => ({ data: null }),
31
+ delete: async () => ({ data: null }),
32
+ });
33
+ const { ensureLease } = await import("./device-lease.service");
34
+ const res = await ensureLease("biz1");
35
+ expect(res).toBeNull();
36
+ expect(isOnlineOnly("biz1")).toBe(true);
37
+ });
38
+ });
@@ -51,6 +51,27 @@ export class SyncEngine {
51
51
  */
52
52
  async execute(command) {
53
53
  syncLogger.command(command, "issued");
54
+ // Perms guard (offline-capable): block before routing
55
+ const { CAPABILITY_BY_COMMAND } = await import("../reference/capabilities");
56
+ const required = CAPABILITY_BY_COMMAND[command.command_type];
57
+ if (required) {
58
+ try {
59
+ const { getReferenceData } = await import("../reference/operations");
60
+ const { openSyncDatabase } = await import("../db/connection");
61
+ const db = await openSyncDatabase();
62
+ const rec = await getReferenceData(db, command.business_id, "capabilities");
63
+ const caps = rec?.payload?.data?.capabilities ?? [];
64
+ // If capabilities cached and missing required, block locally
65
+ if (rec && caps.length > 0 && !caps.includes(required)) {
66
+ throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
67
+ }
68
+ }
69
+ catch (e) {
70
+ if (e instanceof ApiError && e.status === 403)
71
+ throw e;
72
+ // otherwise fail open
73
+ }
74
+ }
54
75
  let strategy = this.resolveStrategy(command);
55
76
  const { isCloudReachable, isOnline } = getConnectivityState();
56
77
  syncLogger.info("SyncEngine", "Command strategy resolved", {
@@ -14,7 +14,26 @@ import { syncEngine } from "./engine";
14
14
  import { buildCommand } from "./command-builder";
15
15
  import { syncLogger } from "../utils/sync-logger";
16
16
  import { ApiError } from "../adapters/http";
17
+ import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
18
+ import { openSyncDatabase } from "../db/connection";
19
+ import { getReferenceData } from "../reference/operations";
17
20
  export async function runOfflineCapableMutation(options) {
21
+ // Per-command capability guard before even attempting direct call
22
+ const required = CAPABILITY_BY_COMMAND[options.commandType];
23
+ if (required) {
24
+ try {
25
+ const db = await openSyncDatabase();
26
+ const rec = await getReferenceData(db, options.businessId, "capabilities");
27
+ const caps = rec?.payload?.data?.capabilities ?? [];
28
+ if (rec && caps.length > 0 && !caps.includes(required)) {
29
+ throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
30
+ }
31
+ }
32
+ catch (e) {
33
+ if (e instanceof ApiError && e.status === 403)
34
+ throw e;
35
+ }
36
+ }
18
37
  try {
19
38
  return await options.run();
20
39
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { generateOrderNumber, resetCalloutForTests } from "./order-number";
3
+ import { removeItemSync, setItemSync } from "../adapters/kv";
4
+ // minimal in-memory DB for outbox
5
+ class MemDB {
6
+ constructor() {
7
+ this.commands = [];
8
+ this.events = [];
9
+ }
10
+ async execAsync() { }
11
+ async runAsync(sql, ...params) {
12
+ if (sql.includes("INSERT INTO commands")) {
13
+ this.commands.push({ command_id: params[0], status: params[8] });
14
+ return { lastInsertRowId: this.commands.length };
15
+ }
16
+ if (sql.includes("INSERT INTO events")) {
17
+ this.events.push({ event_id: params[0] });
18
+ return { lastInsertRowId: 1 };
19
+ }
20
+ if (sql.includes("SELECT * FROM commands WHERE id")) {
21
+ return null;
22
+ }
23
+ return { lastInsertRowId: 1 };
24
+ }
25
+ async getFirstAsync() { return null; }
26
+ async getAllAsync() { return []; }
27
+ }
28
+ describe("offline integration DD-RRR-CCC", () => {
29
+ beforeEach(() => {
30
+ removeItemSync("teinc_callout_seq");
31
+ removeItemSync("teinc_last_random");
32
+ resetCalloutForTests();
33
+ });
34
+ it("queue uses DD-RRR-CCC and survives wrap", () => {
35
+ setItemSync("teinc_device_number:biz1", "01");
36
+ // generate two orders
37
+ const a = generateOrderNumber("01");
38
+ const b = generateOrderNumber("01");
39
+ expect(a.orderNumber).toMatch(/^[0-9]{2}-[0-9]{3}-[0-9]{3}$/);
40
+ expect(b.callout).toBe(a.callout + 1);
41
+ // ensure per-order random different 100-999
42
+ expect(a.random).toBeGreaterThanOrEqual(100);
43
+ expect(b.random).toBeGreaterThanOrEqual(100);
44
+ });
45
+ it("offline vs online no difference - order_number adopted", async () => {
46
+ setItemSync("teinc_callout_seq", 0);
47
+ const { orderNumber } = generateOrderNumber("05");
48
+ // backend would adopt this; ensure format
49
+ expect(orderNumber).toBe("05-" + orderNumber.split("-")[1] + "-" + orderNumber.split("-")[2]);
50
+ expect(orderNumber.length).toBe(10);
51
+ });
52
+ });
@@ -1,20 +1,24 @@
1
1
  /**
2
- * Local order number allocation for offline/LAN operation.
3
- *
4
- * When the cloud is unreachable, the Local Node (or the local device if there
5
- * is no Local Node) assigns temporary order numbers with the prefix
6
- * `TFL-YYYYMMDD-NNNN`. These are reconciled with permanent cloud order numbers
7
- * once connectivity returns.
8
- */
9
- /**
10
- * Generate a local order number for offline-created orders.
11
- */
12
- export declare function generateLocalOrderNumber(date?: Date): string;
13
- /**
14
- * Generate a local callout number for offline-created orders.
15
- */
16
- export declare function generateLocalCalloutNumber(date?: Date): number;
17
- /**
18
- * Check whether an order number was locally allocated.
2
+ * Universal order number: DD-RRR-CCC (10 chars)
3
+ * DD = device number 01-99 (leased per business)
4
+ * RRR = random 100-999 per order (crypto)
5
+ * CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
6
+ * Example: "10-123-001"
19
7
  */
8
+ export declare function peekCallout(): number;
9
+ export declare function generateOrderNumber(deviceNumber: string): {
10
+ orderNumber: string;
11
+ callout: number;
12
+ random: number;
13
+ };
14
+ export declare function generateLocalOrderNumber(): string;
15
+ export declare function generateOrderNumberForBusiness(businessId: string): string;
16
+ export declare function generateLocalCalloutNumber(): number;
20
17
  export declare function isLocalOrderNumber(orderNumber: string): boolean;
18
+ export declare function isOfflineOrderNumber(orderNumber: string): boolean;
19
+ export declare function parseOrderNumber(orderNumber: string): {
20
+ deviceNumber: string;
21
+ random: string;
22
+ callout: string;
23
+ } | null;
24
+ export declare function resetCalloutForTests(): void;
@@ -1,53 +1,79 @@
1
1
  /**
2
- * Local order number allocation for offline/LAN operation.
3
- *
4
- * When the cloud is unreachable, the Local Node (or the local device if there
5
- * is no Local Node) assigns temporary order numbers with the prefix
6
- * `TFL-YYYYMMDD-NNNN`. These are reconciled with permanent cloud order numbers
7
- * once connectivity returns.
2
+ * Universal order number: DD-RRR-CCC (10 chars)
3
+ * DD = device number 01-99 (leased per business)
4
+ * RRR = random 100-999 per order (crypto)
5
+ * CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
6
+ * Example: "10-123-001"
8
7
  */
9
8
  import { getItemSync as getItem, setItemSync as setItem } from "../adapters/kv";
10
- const LOCAL_ORDER_SEQ_KEY = "local_order_sequence";
11
- const LOCAL_CALLOUT_SEQ_KEY = "local_callout_sequence";
12
- function formatDatePart(date) {
13
- const year = date.getFullYear();
14
- const month = String(date.getMonth() + 1).padStart(2, "0");
15
- const day = String(date.getDate()).padStart(2, "0");
16
- return `${year}${month}${day}`;
17
- }
18
- function getNextSequence(datePart, key) {
19
- const stored = getItem(key);
20
- const map = stored ?? {};
21
- const next = (map[datePart] ?? 0) + 1;
22
- map[datePart] = next;
23
- // Keep only the last 7 days to avoid unbounded growth.
24
- const cutoff = String(Number(datePart) - 7);
25
- for (const mapKey of Object.keys(map)) {
26
- if (mapKey < cutoff) {
27
- delete map[mapKey];
28
- }
9
+ const CALLOUT_SEQ_KEY = "teinc_callout_seq";
10
+ const ORDER_RANDOM_KEY = "teinc_last_random"; // last RRR for wrap logic debug
11
+ function cryptoRandom100to999() {
12
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
13
+ const arr = new Uint32Array(1);
14
+ crypto.getRandomValues(arr);
15
+ return 100 + (arr[0] % 900);
29
16
  }
30
- setItem(key, map);
17
+ return 100 + Math.floor(Math.random() * 900);
18
+ }
19
+ function getNextCallout() {
20
+ const stored = getItem(CALLOUT_SEQ_KEY);
21
+ let next = (stored ?? 0) + 1;
22
+ if (next > 999)
23
+ next = 1;
24
+ setItem(CALLOUT_SEQ_KEY, next);
31
25
  return next;
32
26
  }
33
- /**
34
- * Generate a local order number for offline-created orders.
35
- */
36
- export function generateLocalOrderNumber(date = new Date()) {
37
- const datePart = formatDatePart(date);
38
- const seq = getNextSequence(datePart, LOCAL_ORDER_SEQ_KEY);
39
- return `TFL-${datePart}-${String(seq).padStart(4, "0")}`;
27
+ export function peekCallout() {
28
+ const stored = getItem(CALLOUT_SEQ_KEY);
29
+ return stored ?? 0;
40
30
  }
41
- /**
42
- * Generate a local callout number for offline-created orders.
43
- */
44
- export function generateLocalCalloutNumber(date = new Date()) {
45
- const datePart = formatDatePart(date);
46
- return getNextSequence(datePart, LOCAL_CALLOUT_SEQ_KEY);
31
+ export function generateOrderNumber(deviceNumber) {
32
+ if (!/^(0[1-9]|[1-9][0-9])$/.test(deviceNumber)) {
33
+ throw new Error(`Invalid deviceNumber ${deviceNumber} — must be 01-99`);
34
+ }
35
+ const callout = getNextCallout();
36
+ let random = cryptoRandom100to999();
37
+ // On wrap to 001, ensure new random (already new per order, but keep hook)
38
+ if (callout === 1) {
39
+ random = cryptoRandom100to999();
40
+ }
41
+ setItem(ORDER_RANDOM_KEY, random);
42
+ const orderNumber = `${deviceNumber}-${String(random).padStart(3, "0")}-${String(callout).padStart(3, "0")}`;
43
+ return { orderNumber, callout, random };
47
44
  }
48
- /**
49
- * Check whether an order number was locally allocated.
50
- */
45
+ // No legacy fallback — 0.7.0 requires business-scoped device lease via device-lease.service.
46
+ // Kept for internal compat only; callers must supply businessId via generateOrderNumberForBusiness.
47
+ export function generateLocalOrderNumber() {
48
+ // 0.7.0: do not silently use 99. Throw so caller wires businessId.
49
+ throw new Error("generateLocalOrderNumber removed in 0.7.0 — use generateOrderNumberForBusiness(businessId)");
50
+ }
51
+ export function generateOrderNumberForBusiness(businessId) {
52
+ const v = getItem(`teinc_device_number:${businessId}`);
53
+ if (!v || !/^(0[1-9]|[1-9][0-9])$/.test(v)) {
54
+ throw new Error(`No device lease for business ${businessId} — call ensureLease(businessId) first (online-only until leased)`);
55
+ }
56
+ return generateOrderNumber(v).orderNumber;
57
+ }
58
+ export function generateLocalCalloutNumber() {
59
+ // kept for SellerOrder.callout_number compatibility — returns CCC
60
+ const next = peekCallout() + 1;
61
+ return next > 999 ? 1 : next;
62
+ }
63
+ // New canonical check — unified for DD-RRR-CCC
51
64
  export function isLocalOrderNumber(orderNumber) {
52
- return orderNumber.startsWith("TFL-");
65
+ return /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(orderNumber);
66
+ }
67
+ export function isOfflineOrderNumber(orderNumber) {
68
+ return isLocalOrderNumber(orderNumber);
69
+ }
70
+ export function parseOrderNumber(orderNumber) {
71
+ const m = orderNumber.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/);
72
+ if (!m)
73
+ return null;
74
+ return { deviceNumber: m[1], random: m[2], callout: m[3] };
75
+ }
76
+ // Test helpers
77
+ export function resetCalloutForTests() {
78
+ setItem(CALLOUT_SEQ_KEY, 0);
53
79
  }
@@ -1,30 +1,51 @@
1
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", () => {
2
+ import { generateOrderNumber, isLocalOrderNumber, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./order-number";
3
+ import { removeItemSync, setItemSync } from "../adapters/kv";
4
+ describe("order-number DD-RRR-CCC", () => {
6
5
  beforeEach(() => {
7
- removeItemSync("local_order_sequence");
8
- removeItemSync("local_callout_sequence");
6
+ removeItemSync("teinc_callout_seq");
7
+ removeItemSync("teinc_last_random");
8
+ resetCalloutForTests();
9
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);
10
+ it("generates 10-char DD-RRR-CCC", () => {
11
+ const { orderNumber, callout } = generateOrderNumber("10");
12
+ expect(orderNumber).toMatch(/^[0-9]{2}-[0-9]{3}-[0-9]{3}$/);
13
+ expect(orderNumber.length).toBe(10);
14
+ expect(callout).toBe(1);
15
+ expect(isLocalOrderNumber(orderNumber)).toBe(true);
16
+ expect(isOfflineOrderNumber(orderNumber)).toBe(true);
17
+ expect(parseOrderNumber(orderNumber)?.deviceNumber).toBe("10");
18
+ });
19
+ it("rejects invalid DD 00 and 0a", () => {
20
+ expect(() => generateOrderNumber("00")).toThrow();
21
+ expect(() => generateOrderNumber("0a")).toThrow();
22
+ expect(() => generateOrderNumber("100")).toThrow();
18
23
  });
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);
24
+ it("RRR 100-999 and per-order random", () => {
25
+ const a = generateOrderNumber("01");
26
+ const b = generateOrderNumber("01");
27
+ const ra = parseOrderNumber(a.orderNumber).random;
28
+ const rb = parseOrderNumber(b.orderNumber).random;
29
+ expect(Number(ra)).toBeGreaterThanOrEqual(100);
30
+ expect(Number(ra)).toBeLessThanOrEqual(999);
31
+ // random may collide once, but generally different
32
+ // at least not all equal across 5
33
+ const rs = Array.from({ length: 5 }, () => generateOrderNumber("01").random);
34
+ // all in range
35
+ for (const r of [a.random, b.random, ...rs])
36
+ expect(r).toBeGreaterThanOrEqual(100);
23
37
  });
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");
38
+ it("CCC wraps 999->001 and new RRR", () => {
39
+ setItemSync("teinc_callout_seq", 998);
40
+ const a = generateOrderNumber("10");
41
+ expect(a.callout).toBe(999);
42
+ const b = generateOrderNumber("10");
43
+ expect(b.callout).toBe(1);
44
+ // both valid format
45
+ expect(isLocalOrderNumber(b.orderNumber)).toBe(true);
46
+ });
47
+ it("isLocalOrderNumber false for legacy", () => {
48
+ expect(isLocalOrderNumber("TFL-20260824-0001")).toBe(false);
49
+ expect(isLocalOrderNumber("KF-260701-0042")).toBe(false);
29
50
  });
30
51
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
3
+ describe("perms guard", () => {
4
+ it("maps order:create to pos:create_order", () => {
5
+ expect(CAPABILITY_BY_COMMAND["order:create"]).toBe("pos:create_order");
6
+ expect(CAPABILITY_BY_COMMAND["menu:update_item"]).toBe("menu:manage");
7
+ });
8
+ it("hasCapabilitySyncSync fail open when no cache", async () => {
9
+ const { hasCapabilitySync } = await import("../reference/capabilities");
10
+ // without DB, should return true (fail open)
11
+ // we can't easily test without DB, just ensure it resolves
12
+ const res = await hasCapabilitySync("biz-no-cache", "order:create");
13
+ expect(res).toBe(true);
14
+ });
15
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 0.7.0 — auto-lease hook. Call once per active business.
3
+ * Ensures device lease before any offline order is queued.
4
+ * Apps should call ensureDeviceLease(businessId) after business selection/login.
5
+ */
6
+ export declare function useDeviceLease(businessId: string | null | undefined): {
7
+ deviceNumber: string | null;
8
+ onlineOnly: boolean;
9
+ loading: boolean;
10
+ };
11
+ export declare function ensureDeviceLease(businessId: string): Promise<string | null>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 0.7.0 — auto-lease hook. Call once per active business.
3
+ * Ensures device lease before any offline order is queued.
4
+ * Apps should call ensureDeviceLease(businessId) after business selection/login.
5
+ */
6
+ import { useEffect, useState } from "react";
7
+ import { ensureLease, getDeviceNumber } from "./device-lease.service";
8
+ export function useDeviceLease(businessId) {
9
+ const [deviceNumber, setDeviceNumber] = useState(null);
10
+ const [onlineOnly, setOnlineOnly] = useState(false);
11
+ const [loading, setLoading] = useState(false);
12
+ useEffect(() => {
13
+ if (!businessId)
14
+ return;
15
+ let cancelled = false;
16
+ setLoading(true);
17
+ const cached = getDeviceNumber(businessId);
18
+ if (cached) {
19
+ setDeviceNumber(cached);
20
+ setOnlineOnly(false);
21
+ setLoading(false);
22
+ return;
23
+ }
24
+ ensureLease(businessId).then((dn) => {
25
+ if (cancelled)
26
+ return;
27
+ setDeviceNumber(dn);
28
+ setOnlineOnly(dn == null);
29
+ setLoading(false);
30
+ });
31
+ return () => { cancelled = true; };
32
+ }, [businessId]);
33
+ return { deviceNumber, onlineOnly, loading };
34
+ }
35
+ export async function ensureDeviceLease(businessId) {
36
+ const cached = getDeviceNumber(businessId);
37
+ if (cached)
38
+ return cached;
39
+ return ensureLease(businessId);
40
+ }