@teincfood/core 0.7.7 → 0.7.9

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 (48) hide show
  1. package/dist/adapters/auth.d.ts +2 -0
  2. package/dist/adapters/auth.js +7 -0
  3. package/dist/adapters/filesystem.d.ts +22 -0
  4. package/dist/adapters/filesystem.js +6 -0
  5. package/dist/adapters/print.d.ts +29 -0
  6. package/dist/adapters/print.js +12 -0
  7. package/dist/adapters/services.d.ts +3 -0
  8. package/dist/adapters/services.js +3 -0
  9. package/dist/cart/hooks.d.ts +36 -0
  10. package/dist/cart/hooks.js +45 -0
  11. package/dist/cart/store.d.ts +33 -0
  12. package/dist/cart/store.js +122 -0
  13. package/dist/catalog/hooks.d.ts +25 -0
  14. package/dist/catalog/hooks.js +160 -0
  15. package/dist/hooks/orders.hooks.d.ts +17 -0
  16. package/dist/hooks/orders.hooks.js +117 -0
  17. package/dist/image-cache/service.d.ts +29 -0
  18. package/dist/image-cache/service.js +125 -0
  19. package/dist/index.d.ts +24 -2
  20. package/dist/index.js +33 -2
  21. package/dist/printer/hooks.d.ts +32 -0
  22. package/dist/printer/hooks.js +53 -0
  23. package/dist/printer/receipt.d.ts +12 -0
  24. package/dist/printer/receipt.js +315 -0
  25. package/dist/printer/service.d.ts +31 -0
  26. package/dist/printer/service.js +119 -0
  27. package/dist/printer/store.d.ts +24 -0
  28. package/dist/printer/store.js +94 -0
  29. package/dist/printer/types.d.ts +12 -0
  30. package/dist/printer/types.js +1 -0
  31. package/dist/realtime/events.service.d.ts +18 -0
  32. package/dist/realtime/events.service.js +76 -0
  33. package/dist/realtime/order-events.service.d.ts +6 -0
  34. package/dist/realtime/order-events.service.js +17 -0
  35. package/dist/realtime/phoenix-socket.d.ts +55 -0
  36. package/dist/realtime/phoenix-socket.js +240 -0
  37. package/dist/realtime/useEventsConnection.d.ts +1 -0
  38. package/dist/realtime/useEventsConnection.js +41 -0
  39. package/dist/reference/capabilities.js +1 -0
  40. package/dist/repositories/order.repository.d.ts +1 -0
  41. package/dist/repositories/order.repository.js +9 -0
  42. package/dist/sync/command-queue.service.js +34 -2
  43. package/dist/sync/order-snapshots.service.d.ts +1 -0
  44. package/dist/sync/order-snapshots.service.js +30 -0
  45. package/dist/sync/pending-orders.service.js +81 -2
  46. package/dist/sync/transport-rest.js +12 -5
  47. package/dist/sync/use-sync-reconciliation.js +43 -15
  48. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ import { phoenixSocket } from "./phoenix-socket";
2
+ class EventsService {
3
+ constructor() {
4
+ this.refCount = 0;
5
+ this.topic = null;
6
+ this.joined = false;
7
+ this.lastSequence = 0;
8
+ this.activeBusinessId = null;
9
+ this.listeners = new Map();
10
+ this.wildcardListeners = new Set();
11
+ this.frameHandlerUnsub = null;
12
+ }
13
+ connect(userId, businessId) {
14
+ this.refCount++;
15
+ const topic = `events:${userId}`;
16
+ if (businessId !== undefined)
17
+ this.activeBusinessId = businessId ?? null;
18
+ if (this.refCount === 1 && !this.frameHandlerUnsub) {
19
+ this.topic = topic;
20
+ this.frameHandlerUnsub = phoenixSocket.onFrame(topic, ([_joinRef, _ref, _topic, event, payload]) => {
21
+ const data = payload;
22
+ const payloadBid = data?.business_id;
23
+ if (this.activeBusinessId && payloadBid && payloadBid !== this.activeBusinessId)
24
+ return;
25
+ const sequence = data?._sequence;
26
+ if (typeof sequence === "number" && sequence <= this.lastSequence)
27
+ return;
28
+ if (typeof sequence === "number" && sequence > this.lastSequence)
29
+ this.lastSequence = sequence;
30
+ else if (typeof data?._sequence === "number")
31
+ this.lastSequence = data._sequence;
32
+ const typeListeners = this.listeners.get(event);
33
+ if (typeListeners)
34
+ typeListeners.forEach((cb) => cb(data));
35
+ this.wildcardListeners.forEach((cb) => cb(data));
36
+ });
37
+ const joinPayload = {};
38
+ if (this.lastSequence > 0)
39
+ joinPayload.last_sequence = this.lastSequence;
40
+ if (this.activeBusinessId)
41
+ joinPayload.business_id = this.activeBusinessId;
42
+ phoenixSocket.connect().then(() => phoenixSocket.joinChannel(topic, joinPayload)).then(() => { this.joined = true; }).catch(() => { });
43
+ }
44
+ else if (businessId !== undefined) {
45
+ // business switched while already joined — update filter without rejoin (next events filtered). If backend requires business:{id} subscription, re-join could be forced — not needed as fallback business_authorized check still passes.
46
+ this.activeBusinessId = businessId ?? null;
47
+ }
48
+ let disconnected = false;
49
+ return () => {
50
+ if (disconnected)
51
+ return;
52
+ disconnected = true;
53
+ this.refCount = Math.max(0, this.refCount - 1);
54
+ if (this.refCount === 0) {
55
+ if (this.topic && this.joined)
56
+ phoenixSocket.leaveChannel(this.topic);
57
+ this.frameHandlerUnsub?.();
58
+ this.frameHandlerUnsub = null;
59
+ this.joined = false;
60
+ this.topic = null;
61
+ // reset sequence on full disconnect so next user doesn't dedup? Keep until next connect with same user - if user changes, lastSequence should reset. Caller should ensure new userId -> new instance or reset.
62
+ // Keep for replay.
63
+ }
64
+ };
65
+ }
66
+ on(eventType, callback) {
67
+ if (!this.listeners.has(eventType))
68
+ this.listeners.set(eventType, new Set());
69
+ this.listeners.get(eventType).add(callback);
70
+ return () => { this.listeners.get(eventType)?.delete(callback); };
71
+ }
72
+ onAny(callback) { this.wildcardListeners.add(callback); return () => this.wildcardListeners.delete(callback); }
73
+ isConnected() { return this.joined && phoenixSocket.isConnected(); }
74
+ resetSequence() { this.lastSequence = 0; }
75
+ }
76
+ export const eventsService = new EventsService();
@@ -0,0 +1,6 @@
1
+ export declare const orderEventsService: {
2
+ onOrderUpdates(callback: (event: Record<string, unknown> & {
3
+ type: string;
4
+ order_id?: string;
5
+ }) => void): () => void;
6
+ };
@@ -0,0 +1,17 @@
1
+ import { eventsService } from "./events.service";
2
+ const ORDER_EVENT_TYPES = [
3
+ "order.created",
4
+ "order.status_changed",
5
+ "delivery.assigned",
6
+ "delivery.status_changed",
7
+ "delivery.pickup_confirmed",
8
+ "payment.completed",
9
+ "payment.failed",
10
+ "reference_data.changed",
11
+ ];
12
+ export const orderEventsService = {
13
+ onOrderUpdates(callback) {
14
+ const unsubs = ORDER_EVENT_TYPES.map((type) => eventsService.on(type, (data) => callback({ ...data, type })));
15
+ return () => unsubs.forEach((fn) => fn());
16
+ },
17
+ };
@@ -0,0 +1,55 @@
1
+ export type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnecting";
2
+ export type PhoenixV2Frame = [string | null, string | null, string, string, Record<string, unknown>];
3
+ export type PhoenixOutbound = {
4
+ topic: string;
5
+ event: string;
6
+ payload: Record<string, unknown>;
7
+ ref?: string | null;
8
+ joinRef?: string | null;
9
+ };
10
+ type FrameHandler = (frame: PhoenixV2Frame) => void;
11
+ declare class PhoenixSocket {
12
+ socket: WebSocket | null;
13
+ private refCounter;
14
+ private reconnectAttempt;
15
+ private reconnectTimer;
16
+ private heartbeatTimer;
17
+ private heartbeatPend;
18
+ private heartbeatMissed;
19
+ private static readonly MAX_MISSED;
20
+ private intentionalClose;
21
+ private connectGen;
22
+ private connectPromise;
23
+ private connectionState;
24
+ private connectionListeners;
25
+ private pendingJoins;
26
+ private pendingPushes;
27
+ joinedChannels: Set<string>;
28
+ private pendingRejoin;
29
+ private topicJoinRefs;
30
+ private frameHandlers;
31
+ private getRef;
32
+ private buildSocketUrl;
33
+ private setState;
34
+ onConnectionStateChange(fn: (s: ConnectionState) => void): () => void;
35
+ getConnectionState(): ConnectionState;
36
+ connect(): Promise<void>;
37
+ disconnect(): void;
38
+ retryConnection(): void;
39
+ private scheduleReconnect;
40
+ private cancelReconnect;
41
+ private startHeartbeat;
42
+ private stopHeartbeat;
43
+ private sendRaw;
44
+ push(topic: string, event: string, payload: Record<string, unknown>): void;
45
+ pushWithReply(topic: string, event: string, payload: Record<string, unknown>): Promise<Record<string, unknown>>;
46
+ private handleFrame;
47
+ onFrame(topic: string, handler: FrameHandler): () => void;
48
+ joinChannel(topic: string, payload?: Record<string, unknown>): Promise<Record<string, unknown>>;
49
+ leaveChannel(topic: string): void;
50
+ private rejoinPending;
51
+ isConnected(): boolean;
52
+ hasJoined(topic: string): boolean;
53
+ }
54
+ export declare const phoenixSocket: PhoenixSocket;
55
+ export {};
@@ -0,0 +1,240 @@
1
+ import { WS_BASE_URL } from "../utils/constants";
2
+ import { getAuthToken } from "../adapters/auth";
3
+ const RECONNECT_BASE = 1000;
4
+ const RECONNECT_MAX = 10000;
5
+ const HEARTBEAT_MS = 30000;
6
+ class PhoenixSocket {
7
+ constructor() {
8
+ this.socket = null;
9
+ this.refCounter = 0;
10
+ this.reconnectAttempt = 0;
11
+ this.reconnectTimer = null;
12
+ this.heartbeatTimer = null;
13
+ this.heartbeatPend = false;
14
+ this.heartbeatMissed = 0;
15
+ this.intentionalClose = false;
16
+ this.connectGen = 0;
17
+ this.connectPromise = null;
18
+ this.connectionState = "disconnected";
19
+ this.connectionListeners = new Set();
20
+ this.pendingJoins = new Map();
21
+ this.pendingPushes = new Map();
22
+ this.joinedChannels = new Set();
23
+ this.pendingRejoin = new Set();
24
+ this.topicJoinRefs = new Map();
25
+ this.frameHandlers = new Map();
26
+ }
27
+ getRef() { return String(++this.refCounter); }
28
+ buildSocketUrl(token) {
29
+ const base = WS_BASE_URL.replace(/\/+$/, "");
30
+ const path = base.endsWith("/ws") ? "/websocket" : "/ws/websocket";
31
+ return `${base}${path}?token=${encodeURIComponent(token)}&vsn=2.0.0`;
32
+ }
33
+ setState(s) {
34
+ this.connectionState = s;
35
+ this.connectionListeners.forEach((fn) => fn(s));
36
+ }
37
+ onConnectionStateChange(fn) {
38
+ this.connectionListeners.add(fn);
39
+ return () => this.connectionListeners.delete(fn);
40
+ }
41
+ getConnectionState() { return this.connectionState; }
42
+ connect() {
43
+ if (this.socket?.readyState === WebSocket.OPEN)
44
+ return Promise.resolve();
45
+ if (this.connectPromise)
46
+ return this.connectPromise;
47
+ this.cancelReconnect();
48
+ this.intentionalClose = false;
49
+ this.setState("connecting");
50
+ this.connectPromise = new Promise((resolve, reject) => {
51
+ const token = getAuthToken();
52
+ if (!token) {
53
+ this.setState("disconnected");
54
+ this.connectPromise = null;
55
+ reject(new Error("Not authenticated"));
56
+ return;
57
+ }
58
+ const myGen = ++this.connectGen;
59
+ const url = this.buildSocketUrl(token);
60
+ this.socket = new WebSocket(url);
61
+ this.socket.onopen = () => {
62
+ if (myGen !== this.connectGen)
63
+ return;
64
+ this.reconnectAttempt = 0;
65
+ this.setState("connected");
66
+ this.startHeartbeat();
67
+ this.rejoinPending().catch(() => { });
68
+ this.connectPromise = null;
69
+ resolve();
70
+ };
71
+ this.socket.onerror = () => { if (myGen !== this.connectGen)
72
+ return; if (this.connectionState === "connecting") {
73
+ this.setState("disconnected");
74
+ this.connectPromise = null;
75
+ } };
76
+ this.socket.onmessage = (ev) => { try {
77
+ const frame = JSON.parse(ev.data);
78
+ this.handleFrame(frame);
79
+ }
80
+ catch { } };
81
+ this.socket.onclose = () => {
82
+ if (myGen !== this.connectGen)
83
+ return;
84
+ this.stopHeartbeat();
85
+ if (!this.intentionalClose) {
86
+ this.joinedChannels.forEach((t) => this.pendingRejoin.add(t));
87
+ this.joinedChannels.clear();
88
+ this.scheduleReconnect();
89
+ }
90
+ else {
91
+ this.joinedChannels.clear();
92
+ this.pendingRejoin.clear();
93
+ this.setState("disconnected");
94
+ }
95
+ this.connectPromise = null;
96
+ };
97
+ });
98
+ return this.connectPromise;
99
+ }
100
+ disconnect() {
101
+ this.intentionalClose = true;
102
+ this.cancelReconnect();
103
+ this.stopHeartbeat();
104
+ this.joinedChannels.clear();
105
+ this.pendingRejoin.clear();
106
+ this.pendingJoins.clear();
107
+ this.pendingPushes.clear();
108
+ this.topicJoinRefs.clear();
109
+ this.frameHandlers.clear();
110
+ if (this.socket) {
111
+ this.socket.close();
112
+ this.socket = null;
113
+ }
114
+ this.setState("disconnected");
115
+ this.connectPromise = null;
116
+ }
117
+ retryConnection() { this.cancelReconnect(); this.reconnectAttempt = 0; this.connect().catch(() => { }); }
118
+ scheduleReconnect() {
119
+ if (this.intentionalClose)
120
+ return;
121
+ this.setState("reconnecting");
122
+ const delay = Math.min(RECONNECT_BASE * Math.pow(2, this.reconnectAttempt), RECONNECT_MAX);
123
+ this.reconnectAttempt++;
124
+ this.reconnectTimer = setTimeout(() => this.connect().catch(() => { }), delay);
125
+ }
126
+ cancelReconnect() { if (this.reconnectTimer) {
127
+ clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ } }
130
+ startHeartbeat() {
131
+ this.heartbeatPend = false;
132
+ this.heartbeatMissed = 0;
133
+ this.heartbeatTimer = setInterval(() => {
134
+ if (this.heartbeatPend) {
135
+ this.heartbeatMissed++;
136
+ if (this.heartbeatMissed >= PhoenixSocket.MAX_MISSED)
137
+ this.socket?.close();
138
+ return;
139
+ }
140
+ this.heartbeatPend = true;
141
+ this.sendRaw({ topic: "phoenix", event: "heartbeat", payload: {} });
142
+ }, HEARTBEAT_MS);
143
+ }
144
+ stopHeartbeat() { if (this.heartbeatTimer) {
145
+ clearInterval(this.heartbeatTimer);
146
+ this.heartbeatTimer = null;
147
+ } this.heartbeatPend = false; this.heartbeatMissed = 0; }
148
+ sendRaw(msg) {
149
+ if (this.socket?.readyState === WebSocket.OPEN) {
150
+ const joinRef = msg.topic && msg.topic !== "phoenix" ? (msg.joinRef ?? this.topicJoinRefs.get(msg.topic) ?? null) : (msg.joinRef ?? null);
151
+ const frame = [joinRef, msg.ref ?? null, msg.topic, msg.event, msg.payload];
152
+ this.socket.send(JSON.stringify(frame));
153
+ }
154
+ }
155
+ push(topic, event, payload) { const ref = this.getRef(); this.sendRaw({ topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) }); }
156
+ pushWithReply(topic, event, payload) {
157
+ const ref = this.getRef();
158
+ const msg = { topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) };
159
+ return new Promise((resolve, reject) => {
160
+ this.pendingPushes.set(ref, { resolve, reject });
161
+ this.sendRaw(msg);
162
+ setTimeout(() => { if (this.pendingPushes.has(ref)) {
163
+ this.pendingPushes.delete(ref);
164
+ reject(new Error(`Request timed out: ${event} on ${topic}`));
165
+ } }, 5000);
166
+ });
167
+ }
168
+ handleFrame([joinRef, ref, topic, event, payload]) {
169
+ if (event === "phx_reply" && topic === "phoenix") {
170
+ this.heartbeatPend = false;
171
+ this.heartbeatMissed = 0;
172
+ return;
173
+ }
174
+ if (event === "phx_reply" && ref) {
175
+ const pending = this.pendingPushes.get(ref);
176
+ if (pending) {
177
+ const status = payload?.status;
178
+ const response = (payload?.response ?? {});
179
+ if (status === "ok")
180
+ pending.resolve(response);
181
+ else
182
+ pending.reject(new Error(String(response.reason ?? "Request failed")));
183
+ this.pendingPushes.delete(ref);
184
+ return;
185
+ }
186
+ }
187
+ if (event === "phx_reply" && payload?.status === "ok") {
188
+ const pending = this.pendingJoins.get(topic);
189
+ if (pending) {
190
+ const response = (payload?.response ?? {});
191
+ pending.resolve(response);
192
+ this.pendingJoins.delete(topic);
193
+ this.joinedChannels.add(topic);
194
+ if (joinRef)
195
+ this.topicJoinRefs.set(topic, joinRef);
196
+ }
197
+ return;
198
+ }
199
+ if (event === "phx_reply" && payload?.status === "error") {
200
+ const pending = this.pendingJoins.get(topic);
201
+ if (pending) {
202
+ const response = (payload?.response ?? {});
203
+ pending.reject(new Error(String(response.reason ?? "Join failed")));
204
+ this.pendingJoins.delete(topic);
205
+ }
206
+ return;
207
+ }
208
+ const handlers = this.frameHandlers.get(topic);
209
+ if (handlers)
210
+ handlers.forEach((cb) => cb([joinRef, ref, topic, event, payload]));
211
+ }
212
+ onFrame(topic, handler) {
213
+ if (!this.frameHandlers.has(topic))
214
+ this.frameHandlers.set(topic, new Set());
215
+ this.frameHandlers.get(topic).add(handler);
216
+ return () => { this.frameHandlers.get(topic)?.delete(handler); };
217
+ }
218
+ async joinChannel(topic, payload = {}) {
219
+ if (this.joinedChannels.has(topic))
220
+ return Promise.resolve({});
221
+ if (this.pendingJoins.has(topic))
222
+ return new Promise((resolve, reject) => { this.pendingJoins.set(topic, { resolve, reject }); });
223
+ const ref = this.getRef();
224
+ const msg = { topic, event: "phx_join", payload, ref };
225
+ return new Promise((resolve, reject) => {
226
+ this.pendingJoins.set(topic, { resolve, reject });
227
+ this.sendRaw(msg);
228
+ setTimeout(() => { if (this.pendingJoins.has(topic)) {
229
+ this.pendingJoins.delete(topic);
230
+ reject(new Error(`Failed to join channel ${topic} - timeout`));
231
+ } }, 5000);
232
+ });
233
+ }
234
+ leaveChannel(topic) { this.joinedChannels.delete(topic); this.pendingRejoin.delete(topic); this.topicJoinRefs.delete(topic); }
235
+ async rejoinPending() { const topics = Array.from(this.pendingRejoin); this.pendingRejoin.clear(); await Promise.allSettled(topics.map((t) => this.joinChannel(t))); }
236
+ isConnected() { return this.socket?.readyState === WebSocket.OPEN; }
237
+ hasJoined(topic) { return this.joinedChannels.has(topic); }
238
+ }
239
+ PhoenixSocket.MAX_MISSED = 3;
240
+ export const phoenixSocket = new PhoenixSocket();
@@ -0,0 +1 @@
1
+ export declare function useEventsConnection(): void;
@@ -0,0 +1,41 @@
1
+ import { useEffect } from "react";
2
+ import { eventsService } from "./events.service";
3
+ import { useSyncExternalStore } from "react";
4
+ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
5
+ import { getAuthToken } from "../adapters/auth";
6
+ function useBusinessId() {
7
+ return useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
8
+ }
9
+ function useIsAuthenticated() {
10
+ // poll auth token presence; host should setAuthProvider — if no provider, assume authenticated when token exists
11
+ return !!getAuthToken();
12
+ }
13
+ export function useEventsConnection() {
14
+ const activeBusinessId = useBusinessId();
15
+ // derive userId from auth token? Host must provide via setAuthProvider that encodes user, but we need userId for topic.
16
+ // For now, read from global if host set it via setAuthUserId adapter. Fallback: token payload decode.
17
+ // Minimal: require host to call eventsService.connect with userId directly via wrapper — this hook is convenience that reads business only.
18
+ // To keep zero-config, we attempt to read from window localStorage token decode — fallback no-op.
19
+ const userId = deriveUserId();
20
+ useEffect(() => {
21
+ if (!userId)
22
+ return;
23
+ const disconnect = eventsService.connect(userId, activeBusinessId);
24
+ return () => disconnect();
25
+ }, [userId, activeBusinessId]);
26
+ }
27
+ function deriveUserId() {
28
+ try {
29
+ const token = getAuthToken();
30
+ if (!token)
31
+ return null;
32
+ const parts = token.split(".");
33
+ if (parts.length < 2)
34
+ return null;
35
+ const payload = JSON.parse(atob(parts[1]));
36
+ return payload.sub || payload.user_id || payload.id || null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
@@ -93,6 +93,7 @@ export const CAPABILITY_BY_COMMAND = {
93
93
  "menu:update_item": "menu_manage",
94
94
  "menu:delete_item": "menu_manage",
95
95
  "menu:create_category": "menu_manage",
96
+ "payment:confirm_cash": "orders_accept",
96
97
  };
97
98
  export async function hasCapabilitySync(businessId, commandType) {
98
99
  const required = CAPABILITY_BY_COMMAND[commandType];
@@ -41,4 +41,5 @@ export declare const orderRepository: {
41
41
  assignRider(input: AssignRiderInput & {
42
42
  businessId: string;
43
43
  }): Promise<unknown>;
44
+ confirmCashPayment(input: OrderActionInput): Promise<SellerOrder>;
44
45
  };
@@ -126,4 +126,13 @@ export const orderRepository = {
126
126
  }
127
127
  return event.payload;
128
128
  },
129
+ async confirmCashPayment(input) {
130
+ const command = buildCommand({
131
+ commandType: "payment:confirm_cash",
132
+ businessId: input.businessId,
133
+ entityId: input.orderId,
134
+ payload: {},
135
+ });
136
+ return executeCommand(command);
137
+ },
129
138
  };
@@ -25,6 +25,10 @@ const QUEUED_STATUS_BY_COMMAND = {
25
25
  "delivery:assign": "assigned",
26
26
  "delivery:confirm_handover": "delivered",
27
27
  };
28
+ const PAYMENT_QUEUED_PAYLOAD = {
29
+ is_paid: true,
30
+ to_is_paid: true,
31
+ };
28
32
  export function getQueuedStatus(commandType) {
29
33
  return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
30
34
  }
@@ -42,6 +46,24 @@ function buildOptimisticEvent(command) {
42
46
  timestamp: new Date().toISOString(),
43
47
  };
44
48
  }
49
+ if (command.command_type === "payment:confirm_cash") {
50
+ return {
51
+ event_id: generateEventId(),
52
+ event_type: "payment.completed_queued",
53
+ event_version: "1.0",
54
+ entity_id: command.entity_id ?? command.command_id,
55
+ user_id: command.user_id,
56
+ device_id: command.device_id,
57
+ payload: {
58
+ ...payload,
59
+ command_id: command.command_id,
60
+ is_paid: true,
61
+ paid_at: new Date().toISOString(),
62
+ order_id: command.entity_id,
63
+ },
64
+ timestamp: new Date().toISOString(),
65
+ };
66
+ }
45
67
  const queuedStatus = getQueuedStatus(command.command_type);
46
68
  return {
47
69
  event_id: generateEventId(),
@@ -58,11 +80,13 @@ function buildOptimisticEvent(command) {
58
80
  to_status: queuedStatus,
59
81
  order_id: command.entity_id,
60
82
  delivery_id: command.entity_id,
83
+ ...(queuedStatus ? {} : {}),
61
84
  },
62
85
  timestamp: new Date().toISOString(),
63
86
  };
64
87
  }
65
88
  function buildOptimisticOrder(command, payload) {
89
+ const snapshots = payload.item_snapshots ?? null;
66
90
  const items = payload.items ?? [];
67
91
  const totalMinor = payload.total_minor ?? 0;
68
92
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
@@ -109,14 +133,22 @@ function buildOptimisticOrder(command, payload) {
109
133
  delivery_lat: null,
110
134
  delivery_lng: null,
111
135
  user: null,
112
- items: items.map((item, index) => ({
136
+ items: (snapshots ? snapshots.map((s, index) => ({
137
+ id: `tmp-${command.command_id}-${index}`,
138
+ menu_item: { id: s.menu_item_id, name: s.name },
139
+ quantity: s.quantity,
140
+ unit_price: s.unit_price_minor,
141
+ subtotal: s.unit_price_minor * s.quantity,
142
+ variant_name: s.variant_name,
143
+ addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
144
+ })) : items.map((item, index) => ({
113
145
  id: `tmp-${command.command_id}-${index}`,
114
146
  menu_item: { id: item.menu_item_id, name: "" },
115
147
  quantity: item.quantity,
116
148
  unit_price: 0,
117
149
  subtotal: 0,
118
150
  addons: [],
119
- })),
151
+ }))),
120
152
  inserted_at: now,
121
153
  };
122
154
  }
@@ -29,6 +29,7 @@ declare class OrderSnapshotsService {
29
29
  */
30
30
  applyQueuedStatusEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
31
31
  private applyOrderStatusToReadModelInternal;
32
+ applyPaymentEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
32
33
  /**
33
34
  * Patch the latest status of a known order inside every list snapshot for the
34
35
  * business, so offline devices see live status changes without a refetch.
@@ -97,6 +97,36 @@ class OrderSnapshotsService {
97
97
  });
98
98
  }
99
99
  }
100
+ async applyPaymentEvent(event, origin = "local") {
101
+ const payload = event.payload;
102
+ const orderId = payload?.order_id ?? event.entity_id;
103
+ if (!orderId)
104
+ return;
105
+ const paidAt = payload?.paid_at || new Date().toISOString();
106
+ try {
107
+ const db = await openSyncDatabase();
108
+ await insertEvent(db, { event, origin });
109
+ const lists = await getEntitySnapshotsByType(db, "seller_orders");
110
+ for (const list of lists) {
111
+ const data = list.payload.data ?? [];
112
+ const idx = data.findIndex((o) => o.id === orderId || o.order_number === payload?.order_number);
113
+ if (idx === -1)
114
+ continue;
115
+ const updated = data.map((o, i) => i === idx ? { ...o, is_paid: true, paid_at: paidAt, payment: { ...o.payment, is_paid: true, paid_at: paidAt } } : o);
116
+ await upsertEntitySnapshot(db, {
117
+ entityType: "seller_orders",
118
+ entityId: list.entity_id,
119
+ businessId: list.business_id,
120
+ lastEventId: event.event_id,
121
+ lastEventTimestamp: event.timestamp,
122
+ payload: { ...list.payload, data: updated },
123
+ });
124
+ }
125
+ }
126
+ catch (error) {
127
+ syncLogger.error("OrderSnapshots", "Failed to apply payment", { error: String(error) });
128
+ }
129
+ }
100
130
  /**
101
131
  * Patch the latest status of a known order inside every list snapshot for the
102
132
  * business, so offline devices see live status changes without a refetch.