@porulle/plugin-channel-connector 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,1098 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ CommerceInvalidTransitionError,
4
+ CommerceValidationError,
5
+ Ok,
6
+ PluginErr,
7
+ createSystemActor,
8
+ } from "@porulle/core";
9
+ import type {
10
+ Actor,
11
+ ChannelCatalogItem,
12
+ ChannelConnector,
13
+ ChannelInventoryLevel,
14
+ ChannelOrderSlice,
15
+ ChannelStore,
16
+ PluginDb,
17
+ PluginResult,
18
+ PluginTxFn,
19
+ } from "@porulle/core";
20
+ import type { JobsAdapter } from "@porulle/core";
21
+ import { and, eq, inArray } from "@porulle/core/drizzle";
22
+ import { customerAddresses, customers, inventoryLevels, orderLineItems, orders, sellableEntities } from "@porulle/core/schema";
23
+ import {
24
+ channelEntityMap,
25
+ channelExportEvents,
26
+ channelOrderExports,
27
+ connectedStores,
28
+ channelRefundEvents,
29
+ channelRefundRequests,
30
+ type ChannelOrderExport,
31
+ type ChannelRefundRequest,
32
+ type ConnectedStore,
33
+ } from "./schema.js";
34
+
35
+ export type ExportState = ChannelOrderExport["state"];
36
+
37
+ export interface ReconcileReport extends Record<string, unknown> {
38
+ imported: number;
39
+ converged: number;
40
+ archived: number;
41
+ inventoryUpdated: number;
42
+ driftAlert: boolean;
43
+ }
44
+
45
+ export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
46
+ credentials: "[REDACTED]";
47
+ webhookSecret: "[REDACTED]";
48
+ };
49
+
50
+ export interface ChannelComplianceData {
51
+ customer: { id?: string; email?: string };
52
+ exports: Array<{
53
+ exportId: string;
54
+ orderId: string;
55
+ customerData: NonNullable<ChannelOrderExport["customerData"]>;
56
+ }>;
57
+ }
58
+
59
+ export interface ChannelConnectorPluginOptions {
60
+ connectors?: ChannelConnector[];
61
+ oauth?: { stateSecret: string; postConnectRedirect: string };
62
+ inventoryTimeoutMs?: number;
63
+ jobs?: JobsAdapter;
64
+ exportSla?: { definitiveMs?: number; transientMs?: number };
65
+ refundAutoMax?: number;
66
+ newStoreDays?: number;
67
+ driftAlertThreshold?: number;
68
+ reconcileJitterWindowMs?: number;
69
+ }
70
+
71
+ export interface ChannelStockLine {
72
+ entityId: string;
73
+ variantId?: string;
74
+ title?: string;
75
+ quantity: number;
76
+ }
77
+
78
+ interface CatalogService {
79
+ update(
80
+ id: string,
81
+ input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
82
+ actor: Actor,
83
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
84
+ archive(id: string, actor: Actor): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
85
+ create(
86
+ input: {
87
+ type: string;
88
+ slug: string;
89
+ sourceStoreId: string;
90
+ metadata: Record<string, unknown>;
91
+ },
92
+ actor: Actor,
93
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
94
+ createVariant(
95
+ input: { entityId: string; options: Record<string, string>; sku?: string; barcode?: string },
96
+ actor: Actor,
97
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
98
+ }
99
+
100
+ const exportTransitions: Record<ExportState, readonly ExportState[]> = {
101
+ pending: ["exported", "abandoned"],
102
+ exported: ["confirmed", "failed", "abandoned"],
103
+ confirmed: ["abandoned"],
104
+ failed: ["exported", "abandoned"],
105
+ abandoned: [],
106
+ };
107
+
108
+ export function canExportTransition(from: ExportState, to: ExportState): boolean {
109
+ return exportTransitions[from].includes(to);
110
+ }
111
+
112
+ function hash(value: unknown): string {
113
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
114
+ }
115
+
116
+ function stockFailure(line: ChannelStockLine, reason: string): string {
117
+ return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
118
+ }
119
+
120
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
121
+ let timer: ReturnType<typeof setTimeout> | undefined;
122
+ try {
123
+ return await Promise.race([
124
+ promise,
125
+ new Promise<T>((_, reject) => {
126
+ timer = setTimeout(() => reject(new Error("Inventory lookup timed out.")), timeoutMs);
127
+ }),
128
+ ]);
129
+ } finally {
130
+ if (timer !== undefined) clearTimeout(timer);
131
+ }
132
+ }
133
+
134
+ function redactStore(store: ConnectedStore): PublicConnectedStore {
135
+ return {
136
+ id: store.id,
137
+ organizationId: store.organizationId,
138
+ provider: store.provider,
139
+ credentials: "[REDACTED]",
140
+ storeDomain: store.storeDomain,
141
+ status: store.status,
142
+ catalogCursor: store.catalogCursor,
143
+ inventoryCursor: store.inventoryCursor,
144
+ lastSyncAt: store.lastSyncAt,
145
+ lastReconcileAt: store.lastReconcileAt,
146
+ lastReconcileReport: store.lastReconcileReport,
147
+ webhookSecret: "[REDACTED]",
148
+ breakerState: store.breakerState,
149
+ createdAt: store.createdAt,
150
+ updatedAt: store.updatedAt,
151
+ };
152
+ }
153
+
154
+ export class ChannelConnectorService {
155
+ private readonly connectors = new Map<string, ChannelConnector>();
156
+ private readonly transact: PluginTxFn;
157
+ private readonly jobs: JobsAdapter | undefined;
158
+ private readonly options: ChannelConnectorPluginOptions;
159
+
160
+ constructor(
161
+ private readonly db: PluginDb,
162
+ private readonly services: Record<string, unknown>,
163
+ options: ChannelConnectorPluginOptions = {},
164
+ transaction?: PluginTxFn,
165
+ ) {
166
+ this.options = options;
167
+ for (const connector of options.connectors ?? []) {
168
+ if (this.connectors.has(connector.providerId)) {
169
+ throw new Error(`Duplicate channel connector providerId: ${connector.providerId}`);
170
+ }
171
+ this.connectors.set(connector.providerId, connector);
172
+ }
173
+ this.jobs = options.jobs ?? (services.jobs as JobsAdapter | undefined);
174
+ this.transact = transaction ?? ((fn) => this.db.transaction(fn));
175
+ }
176
+
177
+ getConnector(providerId: string): ChannelConnector | undefined {
178
+ return this.connectors.get(providerId);
179
+ }
180
+
181
+ private get catalog(): CatalogService {
182
+ return this.services.catalog as CatalogService;
183
+ }
184
+
185
+ private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
186
+ const rows = await this.db
187
+ .select()
188
+ .from(connectedStores)
189
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
190
+ return rows[0] as ConnectedStore | undefined;
191
+ }
192
+
193
+ async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
194
+ const rows = await this.db
195
+ .select()
196
+ .from(connectedStores)
197
+ .where(eq(connectedStores.storeDomain, shopDomain));
198
+ return rows[0] as ConnectedStore | undefined;
199
+ }
200
+
201
+ // A shop_domain can map to more than one connected store (reconnect, or the same
202
+ // shop under two orgs). Compliance webhooks must fan out to all of them.
203
+ async getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]> {
204
+ const rows = await this.db
205
+ .select()
206
+ .from(connectedStores)
207
+ .where(eq(connectedStores.storeDomain, shopDomain));
208
+ return rows as ConnectedStore[];
209
+ }
210
+
211
+ async connectStore(
212
+ orgId: string,
213
+ input: {
214
+ provider: string;
215
+ credentials: Record<string, unknown>;
216
+ storeDomain: string;
217
+ webhookSecret?: string;
218
+ },
219
+ ): Promise<PluginResult<PublicConnectedStore>> {
220
+ if (!this.connectors.has(input.provider)) {
221
+ return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
222
+ }
223
+ const rows = await this.db
224
+ .insert(connectedStores)
225
+ .values({
226
+ organizationId: orgId,
227
+ provider: input.provider,
228
+ credentials: input.credentials,
229
+ storeDomain: input.storeDomain,
230
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
231
+ })
232
+ .returning();
233
+ const connector = this.connectors.get(input.provider)!;
234
+ const store = rows[0] as ConnectedStore;
235
+ if (connector.registerWebhooks) {
236
+ const registration = await connector.registerWebhooks(store as ChannelStore, [
237
+ "products/update",
238
+ "products/delete",
239
+ "inventory_levels/update",
240
+ "orders/fulfilled",
241
+ "orders/cancelled",
242
+ "refunds/create",
243
+ "app/uninstalled",
244
+ ], `/api/channels/webhooks/${store.id}`);
245
+ if (!registration.ok) {
246
+ await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
247
+ return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
248
+ }
249
+ }
250
+ const jobs = this.optionsJobs;
251
+ if (jobs) {
252
+ await jobs.enqueue("channel/import-catalog", { orgId, storeId: (rows[0] as ConnectedStore).id }, {
253
+ organizationId: orgId,
254
+ concurrencyKey: (rows[0] as ConnectedStore).id,
255
+ supersedes: true,
256
+ });
257
+ }
258
+ return Ok(redactStore(store));
259
+ }
260
+
261
+ private get optionsJobs(): JobsAdapter | undefined {
262
+ return this.jobs;
263
+ }
264
+
265
+ async disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
266
+ return this.disconnectStoreSystem(orgId, id);
267
+ }
268
+
269
+ async disconnectStoreSystem(orgId: string, id: string, redactDomain = false): Promise<PluginResult<PublicConnectedStore>> {
270
+ const rows = await this.db
271
+ .update(connectedStores)
272
+ .set({
273
+ status: "disconnected",
274
+ credentials: {},
275
+ webhookSecret: null,
276
+ ...(redactDomain ? { storeDomain: "[REDACTED]" } : {}),
277
+ updatedAt: new Date(),
278
+ })
279
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)))
280
+ .returning();
281
+ const store = rows[0] as ConnectedStore | undefined;
282
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
283
+ return Ok(redactStore(store));
284
+ }
285
+
286
+ async getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
287
+ const store = await this.getStoreRecord(orgId, id);
288
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
289
+ return Ok(redactStore(store));
290
+ }
291
+
292
+ async listStores(orgId: string): Promise<PluginResult<PublicConnectedStore[]>> {
293
+ const rows = await this.db
294
+ .select()
295
+ .from(connectedStores)
296
+ .where(eq(connectedStores.organizationId, orgId));
297
+ return Ok((rows as ConnectedStore[]).map(redactStore));
298
+ }
299
+
300
+ async validateLineStock(
301
+ orgId: string,
302
+ lines: ChannelStockLine[],
303
+ timeoutMs = 3_000,
304
+ ): Promise<void> {
305
+ const entities = await this.db
306
+ .select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId })
307
+ .from(sellableEntities)
308
+ .where(and(
309
+ eq(sellableEntities.organizationId, orgId),
310
+ inArray(sellableEntities.id, lines.map((line) => line.entityId)),
311
+ ));
312
+ const sourceByEntity = new Map(entities.map((entity) => [entity.id, entity.sourceStoreId]));
313
+ const channelLines = lines.filter((line) => sourceByEntity.get(line.entityId) != null);
314
+ const byStore = new Map<string, ChannelStockLine[]>();
315
+ for (const line of channelLines) {
316
+ const storeId = sourceByEntity.get(line.entityId)!;
317
+ const storeLines = byStore.get(storeId) ?? [];
318
+ storeLines.push(line);
319
+ byStore.set(storeId, storeLines);
320
+ }
321
+
322
+ await Promise.all([...byStore].map(async ([storeId, storeLines]) => {
323
+ const store = await this.getStoreRecord(orgId, storeId);
324
+ if (!store || store.status !== "connected") {
325
+ throw new CommerceValidationError(stockFailure(storeLines[0]!, "connected store is unavailable"));
326
+ }
327
+ const connector = this.connectors.get(store.provider);
328
+ if (!connector) {
329
+ throw new CommerceValidationError(stockFailure(storeLines[0]!, `no connector is registered for provider "${store.provider}"`));
330
+ }
331
+
332
+ const mappings = await this.db
333
+ .select()
334
+ .from(channelEntityMap)
335
+ .where(and(
336
+ eq(channelEntityMap.organizationId, orgId),
337
+ eq(channelEntityMap.storeId, storeId),
338
+ ));
339
+ const inventoryIds = storeLines.map((line) => {
340
+ const mapping = line.variantId
341
+ ? mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)
342
+ : undefined;
343
+ return mapping ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
344
+ });
345
+ const missing = storeLines.find((line, index) => !inventoryIds[index]);
346
+ if (missing) {
347
+ throw new CommerceValidationError(stockFailure(missing, "external inventory mapping is missing"));
348
+ }
349
+
350
+ let inventory: Awaited<ReturnType<ChannelConnector["fetchInventory"]>>;
351
+ try {
352
+ inventory = await withTimeout(
353
+ connector.fetchInventory(store as ChannelStore, inventoryIds.map((mapping) => mapping!.externalId)),
354
+ timeoutMs,
355
+ );
356
+ } catch {
357
+ throw new CommerceValidationError(stockFailure(storeLines[0]!, "inventory could not be confirmed"));
358
+ }
359
+ if (!inventory.ok) {
360
+ throw new CommerceValidationError(stockFailure(storeLines[0]!, "inventory could not be confirmed"));
361
+ }
362
+ for (const [index, line] of storeLines.entries()) {
363
+ const available = inventory.value.find((item) => item.externalId === inventoryIds[index]!.externalId)?.available;
364
+ if (available === undefined || available < line.quantity) {
365
+ throw new CommerceValidationError(stockFailure(line, `only ${available ?? 0} available for ${line.quantity} requested`));
366
+ }
367
+ }
368
+ }));
369
+ }
370
+
371
+ async importCatalog(
372
+ orgId: string,
373
+ storeId: string,
374
+ actor: Actor,
375
+ ): Promise<PluginResult<{ imported: number; cursor: string | null }>> {
376
+ const store = await this.getStoreRecord(orgId, storeId);
377
+ if (!store || store.status !== "connected") {
378
+ return PluginErr("Connected store not found.", "NOT_FOUND");
379
+ }
380
+ const connector = this.connectors.get(store.provider);
381
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
382
+
383
+ const items: ChannelCatalogItem[] = [];
384
+ let cursor: string | undefined = store.catalogCursor ?? undefined;
385
+ do {
386
+ const page = await connector.importCatalog(store as ChannelStore, cursor);
387
+ if (!page.ok) return PluginErr(page.error.message);
388
+ items.push(...page.value.items);
389
+ cursor = page.value.nextCursor ?? undefined;
390
+ } while (cursor);
391
+
392
+ const result = await this.convergeCatalogItems(orgId, storeId, items, actor);
393
+ if (!result.ok) return result;
394
+
395
+ await this.db
396
+ .update(connectedStores)
397
+ .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
398
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
399
+ return Ok({ imported: result.value.imported, cursor: null });
400
+ }
401
+
402
+ private async convergeCatalogItems(
403
+ orgId: string,
404
+ storeId: string,
405
+ items: ChannelCatalogItem[],
406
+ actor: Actor,
407
+ ): Promise<PluginResult<{ imported: number; converged: number }>> {
408
+ let imported = 0;
409
+ let converged = 0;
410
+ for (const item of items) {
411
+ const existing = await this.db
412
+ .select()
413
+ .from(channelEntityMap)
414
+ .where(and(
415
+ eq(channelEntityMap.organizationId, orgId),
416
+ eq(channelEntityMap.storeId, storeId),
417
+ eq(channelEntityMap.kind, "entity"),
418
+ eq(channelEntityMap.externalId, item.externalId),
419
+ ));
420
+ const entityMapping = existing.find((entry) => entry.kind === "entity");
421
+ if (entityMapping) {
422
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
423
+ eq(sellableEntities.organizationId, orgId),
424
+ eq(sellableEntities.id, entityMapping.entityId),
425
+ ));
426
+ if (entityMapping.syncHash !== hash(item) || entity?.status === "archived") {
427
+ const updated = await this.catalog.update(entityMapping.entityId, {
428
+ slug: item.slug,
429
+ metadata: {
430
+ ...(item.metadata ?? {}),
431
+ title: item.title,
432
+ ...(item.description !== undefined ? { description: item.description } : {}),
433
+ },
434
+ ...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
435
+ }, actor);
436
+ if (!updated.ok) return PluginErr(updated.error.message);
437
+ await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
438
+ converged += 1;
439
+ }
440
+ continue;
441
+ }
442
+
443
+ const entity = await this.catalog.create(
444
+ {
445
+ type: "product",
446
+ slug: item.slug,
447
+ sourceStoreId: storeId,
448
+ metadata: {
449
+ ...(item.metadata ?? {}),
450
+ title: item.title,
451
+ ...(item.description !== undefined ? { description: item.description } : {}),
452
+ },
453
+ },
454
+ actor,
455
+ );
456
+ if (!entity.ok) return PluginErr(entity.error.message);
457
+
458
+ await this.db.insert(channelEntityMap).values({
459
+ organizationId: orgId,
460
+ storeId,
461
+ kind: "entity",
462
+ externalId: item.externalId,
463
+ entityId: entity.value.id,
464
+ syncHash: hash(item),
465
+ });
466
+
467
+ for (const sourceVariant of item.variants) {
468
+ const variant = await this.catalog.createVariant(
469
+ {
470
+ entityId: entity.value.id,
471
+ options: {},
472
+ ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
473
+ ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
474
+ },
475
+ actor,
476
+ );
477
+ if (!variant.ok) return PluginErr(variant.error.message);
478
+ await this.db.insert(channelEntityMap).values({
479
+ organizationId: orgId,
480
+ storeId,
481
+ kind: "variant",
482
+ externalId: sourceVariant.externalId,
483
+ entityId: entity.value.id,
484
+ variantId: variant.value.id,
485
+ syncHash: hash(sourceVariant),
486
+ });
487
+ }
488
+ imported += 1;
489
+ }
490
+ return Ok({ imported, converged });
491
+ }
492
+
493
+ async reconcile(
494
+ orgId: string,
495
+ storeId: string,
496
+ actor: Actor,
497
+ ): Promise<PluginResult<ReconcileReport>> {
498
+ const store = await this.getStoreRecord(orgId, storeId);
499
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
500
+ const connector = this.connectors.get(store.provider);
501
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
502
+
503
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)));
504
+ const entityMappings = mappings.filter((mapping) => mapping.kind === "entity");
505
+ const items: ChannelCatalogItem[] = [];
506
+ let cursor: string | undefined;
507
+ do {
508
+ const page = await connector.importCatalog(store as ChannelStore, cursor);
509
+ if (!page.ok) return PluginErr(page.error.message);
510
+ items.push(...page.value.items);
511
+ cursor = page.value.nextCursor ?? undefined;
512
+ } while (cursor);
513
+
514
+ const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
515
+ if (!converged.ok) return converged;
516
+ const present = new Set(items.map((item) => item.externalId));
517
+ let archived = 0;
518
+ for (const mapping of entityMappings) {
519
+ if (present.has(mapping.externalId)) continue;
520
+ const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
521
+ eq(sellableEntities.organizationId, orgId),
522
+ eq(sellableEntities.id, mapping.entityId),
523
+ ));
524
+ if (entity?.status !== "archived") {
525
+ const result = await this.catalog.archive(mapping.entityId, actor);
526
+ if (!result.ok) return PluginErr(result.error.message);
527
+ archived += 1;
528
+ }
529
+ }
530
+
531
+ const inventory = await connector.fetchInventory(store as ChannelStore, mappings.map((mapping) => mapping.externalId));
532
+ if (!inventory.ok) return PluginErr(inventory.error.message);
533
+ const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
534
+ const inventoryService = this.services.inventory as {
535
+ setAbsolute(input: { entityId: string; variantId?: string; quantity: number; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>;
536
+ };
537
+ let inventoryUpdated = 0;
538
+ for (const level of inventory.value) {
539
+ const mapping = mappings.find((entry) => entry.externalId === level.externalId);
540
+ if (!mapping) continue;
541
+ const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
542
+ if (current?.quantityOnHand === level.available) continue;
543
+ const result = await inventoryService.setAbsolute({
544
+ entityId: mapping.entityId,
545
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
546
+ quantity: level.available,
547
+ reason: `Inventory reconciliation from ${store.provider}`,
548
+ }, actor);
549
+ if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
550
+ inventoryUpdated += 1;
551
+ }
552
+ const threshold = this.options.driftAlertThreshold ?? 25;
553
+ const report: ReconcileReport = {
554
+ imported: converged.value.imported,
555
+ converged: converged.value.converged,
556
+ archived,
557
+ inventoryUpdated,
558
+ driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
559
+ };
560
+ await this.db.update(connectedStores).set({
561
+ lastReconcileAt: new Date(),
562
+ lastReconcileReport: report,
563
+ lastSyncAt: new Date(),
564
+ updatedAt: new Date(),
565
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
566
+ return Ok(report);
567
+ }
568
+
569
+ async getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{ lastReconcileAt: Date | null; report: ReconcileReport | null; driftAlert: boolean }>> {
570
+ const store = await this.getStoreRecord(orgId, storeId);
571
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
572
+ const report = store.lastReconcileReport as ReconcileReport | null;
573
+ return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
574
+ }
575
+
576
+ async syncInventory(
577
+ orgId: string,
578
+ storeId: string,
579
+ actor: Actor,
580
+ ): Promise<PluginResult<{ synced: number }>> {
581
+ const store = await this.getStoreRecord(orgId, storeId);
582
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
583
+ const connector = this.connectors.get(store.provider);
584
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
585
+ const inventory = await connector.fetchInventory(store as ChannelStore);
586
+ if (!inventory.ok) return PluginErr(inventory.error.message);
587
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
588
+ eq(channelEntityMap.organizationId, orgId),
589
+ eq(channelEntityMap.storeId, storeId),
590
+ ));
591
+ const inventoryService = this.services.inventory as {
592
+ setAbsolute(input: { entityId: string; variantId?: string; quantity: number; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>;
593
+ };
594
+ let synced = 0;
595
+ for (const level of inventory.value) {
596
+ const mapping = mappings.find((entry) => entry.externalId === level.externalId);
597
+ if (!mapping) continue;
598
+ const result = await inventoryService.setAbsolute({
599
+ entityId: mapping.entityId,
600
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
601
+ quantity: level.available,
602
+ reason: `Inventory sync from ${store.provider}`,
603
+ }, actor);
604
+ if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
605
+ synced += 1;
606
+ }
607
+ await this.db.update(connectedStores).set({ inventoryCursor: new Date().toISOString(), lastSyncAt: new Date(), updatedAt: new Date() }).where(and(
608
+ eq(connectedStores.organizationId, orgId),
609
+ eq(connectedStores.id, storeId),
610
+ ));
611
+ return Ok({ synced });
612
+ }
613
+
614
+ async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
615
+ const store = await this.getStoreRecord(orgId, storeId);
616
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
617
+ const actor = createSystemActor(orgId);
618
+ const data = event.data as Record<string, unknown>;
619
+ if (event.type === "products/update") {
620
+ const productId = String(data.id ?? data.product_id ?? "");
621
+ const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
622
+ if (mapping[0]) await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
623
+ } else if (event.type === "products/delete") {
624
+ const productId = String(data.id ?? data.product_id ?? "");
625
+ const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
626
+ if (mapping[0]) {
627
+ const archived = await this.catalog.archive(mapping[0].entityId, actor);
628
+ if (!archived.ok) return PluginErr(archived.error.message);
629
+ }
630
+ } else if (event.type === "inventory_levels/update") {
631
+ const externalId = String(data.inventory_item_id ?? data.variation_id ?? data.product_id ?? "");
632
+ const available = Number(data.available ?? data.stock_quantity ?? 0);
633
+ await this.setMappedInventory(orgId, storeId, externalId, available, actor);
634
+ } else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
635
+ const orderId = await this.resolveOrderId(orgId, storeId, data);
636
+ if (orderId) {
637
+ const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
638
+ const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
639
+ if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
640
+ if (event.type === "orders/fulfilled") {
641
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
642
+ if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
643
+ const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
644
+ if (after?.status === "processing") await ordersService.changeStatus({ orderId, newStatus: "fulfilled", reason: "channel_order_fulfilled" }, actor);
645
+ }
646
+ }
647
+ } else if (event.type === "refunds/create") {
648
+ const refund = await this.createRefundRequest(orgId, store, data, actor);
649
+ if (!refund.ok) return refund;
650
+ } else if (event.type === "customers/data_request") {
651
+ const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, data);
652
+ if (!dataRequest.ok) return dataRequest;
653
+ return Ok({ processed: true, data: dataRequest.value });
654
+ } else if (event.type === "customers/redact") {
655
+ const redacted = await this.redactCustomerData(orgId, storeId, data);
656
+ if (!redacted.ok) return redacted;
657
+ return Ok({ processed: true, redacted: redacted.value });
658
+ } else if (event.type === "shop/redact") {
659
+ const redacted = await this.redactShopData(orgId, storeId);
660
+ if (!redacted.ok) return redacted;
661
+ return Ok({ processed: true, redacted: redacted.value });
662
+ } else if (event.type === "app/uninstalled") {
663
+ const disconnected = await this.disconnectStoreSystem(orgId, storeId);
664
+ if (!disconnected.ok) return disconnected;
665
+ return Ok({ processed: true });
666
+ }
667
+ return Ok({ processed: true });
668
+ }
669
+
670
+ private complianceEmail(data: Record<string, unknown>): string | undefined {
671
+ const customer = data.customer && typeof data.customer === "object" ? data.customer as Record<string, unknown> : undefined;
672
+ const email = data.email ?? customer?.email;
673
+ return typeof email === "string" && email ? email.toLowerCase() : undefined;
674
+ }
675
+
676
+ private async channelCustomerExports(orgId: string, storeId: string): Promise<ChannelOrderExport[]> {
677
+ return await this.db.select().from(channelOrderExports).where(and(
678
+ eq(channelOrderExports.organizationId, orgId),
679
+ eq(channelOrderExports.storeId, storeId),
680
+ )) as ChannelOrderExport[];
681
+ }
682
+
683
+ private async channelCustomerDataRequest(orgId: string, storeId: string, data: Record<string, unknown>): Promise<PluginResult<ChannelComplianceData>> {
684
+ const rows = await this.channelCustomerExports(orgId, storeId);
685
+ const email = this.complianceEmail(data);
686
+ const matches = rows.filter((row) => email && row.customerData?.email.toLowerCase() === email && row.customerData !== null);
687
+ return Ok({
688
+ customer: {
689
+ ...(typeof data.customer_id === "string" ? { id: data.customer_id } : {}),
690
+ ...(email ? { email } : {}),
691
+ },
692
+ exports: matches.map((row) => ({
693
+ exportId: row.id,
694
+ orderId: row.orderId,
695
+ customerData: row.customerData!,
696
+ })),
697
+ });
698
+ }
699
+
700
+ private async redactCustomerData(orgId: string, storeId: string, data: Record<string, unknown>): Promise<PluginResult<number>> {
701
+ const rows = await this.channelCustomerExports(orgId, storeId);
702
+ const email = this.complianceEmail(data);
703
+ const matches = rows.filter((row) => email && row.customerData?.email.toLowerCase() === email && row.customerData !== null);
704
+ for (const row of matches) {
705
+ await this.db.update(channelOrderExports).set({ customerData: null, updatedAt: new Date() }).where(and(
706
+ eq(channelOrderExports.organizationId, orgId),
707
+ eq(channelOrderExports.id, row.id),
708
+ ));
709
+ }
710
+ return Ok(matches.length);
711
+ }
712
+
713
+ private async redactShopData(orgId: string, storeId: string): Promise<PluginResult<number>> {
714
+ const rows = await this.channelCustomerExports(orgId, storeId);
715
+ await this.db.update(channelOrderExports).set({ customerData: null, updatedAt: new Date() }).where(and(
716
+ eq(channelOrderExports.organizationId, orgId),
717
+ eq(channelOrderExports.storeId, storeId),
718
+ ));
719
+ const disconnected = await this.disconnectStoreSystem(orgId, storeId, true);
720
+ if (!disconnected.ok) return PluginErr(disconnected.error, disconnected.code);
721
+ return Ok(rows.filter((row) => row.customerData !== null).length);
722
+ }
723
+
724
+ private async resolveOrderId(orgId: string, storeId: string, data: Record<string, unknown>): Promise<string | undefined> {
725
+ const nestedOrder = data.order && typeof data.order === "object" ? data.order as Record<string, unknown> : undefined;
726
+ const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? "");
727
+ const rows = await this.db.select({ orderId: channelOrderExports.orderId }).from(channelOrderExports).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.storeId, storeId), eq(channelOrderExports.remoteOrderId, remoteOrderId)));
728
+ return rows[0]?.orderId;
729
+ }
730
+
731
+ private async setMappedInventory(orgId: string, storeId: string, externalId: string, quantity: number, actor: Actor): Promise<void> {
732
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
733
+ if (!mapping) return;
734
+ const inventory = this.services.inventory as { setAbsolute(input: { entityId: string; variantId?: string; quantity: number; reason?: string }, actor: Actor): Promise<{ ok: boolean }> };
735
+ await inventory.setAbsolute({ entityId: mapping.entityId, ...(mapping.variantId ? { variantId: mapping.variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
736
+ }
737
+
738
+ private async convergeCatalogItem(orgId: string, storeId: string, entityId: string, data: Record<string, unknown>, actor: Actor): Promise<void> {
739
+ const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
740
+ const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
741
+ for (const variant of levels) {
742
+ const externalId = String(variant.id ?? variant.variation_id ?? "");
743
+ const available = variant.inventory_quantity ?? variant.stock_quantity;
744
+ if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
745
+ }
746
+ }
747
+
748
+ private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
749
+ const remoteRefundId = String(data.id ?? data.refund_id ?? "");
750
+ const orderId = await this.resolveOrderId(orgId, store.id, data);
751
+ if (!remoteRefundId || !orderId) return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
752
+ const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
753
+ if (existing[0]) return Ok(existing[0] as ChannelRefundRequest);
754
+ const lineData = Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
755
+ const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
756
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
757
+ const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
758
+ let clean = lineData.length > 0;
759
+ for (const raw of lineData) {
760
+ const line = raw as Record<string, unknown>;
761
+ const externalId = String(line.variant_id ?? line.variantId ?? line.product_id ?? "");
762
+ const quantity = Number(line.quantity ?? 0);
763
+ const mapping = mappings.find((item) => item.externalId === externalId);
764
+ const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
765
+ if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
766
+ else refundLines.push({ lineItemId: orderLine.id, quantity });
767
+ }
768
+ const amount = refundLines.reduce((sum, line) => {
769
+ const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
770
+ return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
771
+ }, 0);
772
+ const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
773
+ if (!order) return PluginErr("Order not found.", "NOT_FOUND");
774
+ const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
775
+ const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
776
+ const auto = clean && amount > 0 && ageOk && amount <= max;
777
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor.userId : null }).returning();
778
+ const request = rows[0] as ChannelRefundRequest;
779
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: actor.userId });
780
+ if (auto) {
781
+ const result = await this.executeRefund(request, refundLines, actor);
782
+ if (!result.ok) return PluginErr(result.error);
783
+ }
784
+ return Ok(request);
785
+ }
786
+
787
+ private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
788
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
789
+ const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
790
+ if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
791
+ const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
792
+ await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor.userId });
793
+ return Ok(updated as ChannelRefundRequest);
794
+ }
795
+
796
+ async listRefundRequests(orgId: string): Promise<PluginResult<ChannelRefundRequest[]>> {
797
+ return Ok(await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested"))) as ChannelRefundRequest[]);
798
+ }
799
+
800
+ async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
801
+ const [request] = await this.db.update(channelRefundRequests).set({ state: "approved", approvedBy: actor.userId, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "requested"))).returning();
802
+ if (!request) return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
803
+ const lines = await this.refundLinesForRequest(request as ChannelRefundRequest);
804
+ return this.executeRefund(request as ChannelRefundRequest, lines, createSystemActor(orgId));
805
+ }
806
+
807
+ async rejectRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
808
+ const [request] = await this.db.update(channelRefundRequests).set({ state: "rejected", approvedBy: actor.userId, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "requested"))).returning();
809
+ if (!request) return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
810
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: id, fromState: "requested", toState: "rejected", reason: "Operator rejected refund", changedBy: actor.userId });
811
+ return Ok(request as ChannelRefundRequest);
812
+ }
813
+
814
+ private async refundLinesForRequest(request: ChannelRefundRequest): Promise<Array<{ lineItemId: string; quantity: number }>> {
815
+ const rows = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, request.orderId));
816
+ let remaining = request.amount;
817
+ return rows.flatMap((line) => {
818
+ const unit = Math.round((line.totalPrice + line.taxAmount - line.discountAmount) / line.quantity);
819
+ const quantity = Math.min(line.quantity - line.refundedQuantity, Math.floor(remaining / unit));
820
+ remaining -= quantity * unit;
821
+ return quantity > 0 ? [{ lineItemId: line.id, quantity }] : [];
822
+ });
823
+ }
824
+
825
+ async createExport(
826
+ orgId: string,
827
+ storeId: string,
828
+ orderId: string,
829
+ ): Promise<PluginResult<ChannelOrderExport>> {
830
+ const store = await this.getStoreRecord(orgId, storeId);
831
+ if (!store || store.status !== "connected") {
832
+ return PluginErr("Connected store not found.", "NOT_FOUND");
833
+ }
834
+ const existing = await this.db
835
+ .select()
836
+ .from(channelOrderExports)
837
+ .where(and(
838
+ eq(channelOrderExports.organizationId, orgId),
839
+ eq(channelOrderExports.storeId, storeId),
840
+ eq(channelOrderExports.orderId, orderId),
841
+ ));
842
+ if (existing[0]) return Ok(existing[0] as ChannelOrderExport);
843
+ const rows = await this.db
844
+ .insert(channelOrderExports)
845
+ .values({ organizationId: orgId, storeId, orderId })
846
+ .returning();
847
+ return Ok(rows[0] as ChannelOrderExport);
848
+ }
849
+
850
+ async transitionExport(
851
+ orgId: string,
852
+ exportId: string,
853
+ toState: ExportState,
854
+ changedBy: string,
855
+ reason?: string,
856
+ failureKind?: "definitive" | "transient",
857
+ ): Promise<PluginResult<ChannelOrderExport>> {
858
+ return this.transact(async (tx) => {
859
+ const currentRows = await tx
860
+ .select()
861
+ .from(channelOrderExports)
862
+ .where(and(
863
+ eq(channelOrderExports.organizationId, orgId),
864
+ eq(channelOrderExports.id, exportId),
865
+ ));
866
+ const current = currentRows[0] as ChannelOrderExport | undefined;
867
+ if (!current) return PluginErr("Channel order export not found.", "NOT_FOUND");
868
+ if (!canExportTransition(current.state, toState)) {
869
+ const error = new CommerceInvalidTransitionError(
870
+ `Cannot transition channel export from ${current.state} to ${toState}.`,
871
+ );
872
+ return PluginErr(error.message, error.code);
873
+ }
874
+
875
+ const updatedRows = await tx
876
+ .update(channelOrderExports)
877
+ .set({
878
+ state: toState,
879
+ updatedAt: new Date(),
880
+ ...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
881
+ ...(toState === "failed" ? { lastError: reason ?? "Export failed." } : {}),
882
+ ...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
883
+ })
884
+ .where(and(
885
+ eq(channelOrderExports.organizationId, orgId),
886
+ eq(channelOrderExports.id, exportId),
887
+ eq(channelOrderExports.state, current.state),
888
+ ))
889
+ .returning();
890
+ const updated = updatedRows[0] as ChannelOrderExport | undefined;
891
+ if (!updated) return PluginErr("Channel order export changed concurrently.", "CONFLICT");
892
+
893
+ await tx.insert(channelExportEvents).values({
894
+ organizationId: orgId,
895
+ exportId,
896
+ fromState: current.state,
897
+ toState,
898
+ reason: reason ?? null,
899
+ changedBy,
900
+ });
901
+ return Ok(updated);
902
+ });
903
+ }
904
+
905
+ async exportOrder(
906
+ orgId: string,
907
+ storeId: string,
908
+ slice: ChannelOrderSlice,
909
+ actor: Actor,
910
+ ): Promise<PluginResult<ChannelOrderExport>> {
911
+ const store = await this.getStoreRecord(orgId, storeId);
912
+ if (!store || store.status !== "connected") {
913
+ return PluginErr("Connected store not found.", "NOT_FOUND");
914
+ }
915
+ const connector = this.connectors.get(store.provider);
916
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
917
+
918
+ const created = await this.createExport(orgId, storeId, slice.orderId);
919
+ if (!created.ok) return created;
920
+ if (created.value.state === "confirmed") return created;
921
+ if (created.value.state !== "exported") {
922
+ const exported = await this.transitionExport(
923
+ orgId,
924
+ created.value.id,
925
+ "exported",
926
+ actor.userId,
927
+ "Export attempt started.",
928
+ );
929
+ if (!exported.ok) return exported;
930
+ }
931
+
932
+ await this.db
933
+ .update(channelOrderExports)
934
+ .set({ customerData: slice.customer, updatedAt: new Date() })
935
+ .where(and(
936
+ eq(channelOrderExports.organizationId, orgId),
937
+ eq(channelOrderExports.id, created.value.id),
938
+ ));
939
+
940
+ const pushed = await connector.pushOrder(store as ChannelStore, slice);
941
+ if (!pushed.ok) {
942
+ return this.transitionExport(
943
+ orgId,
944
+ created.value.id,
945
+ "failed",
946
+ actor.userId,
947
+ pushed.error.message,
948
+ pushed.error.retriable === true ? "transient" : "definitive",
949
+ );
950
+ }
951
+
952
+ await this.db
953
+ .update(channelOrderExports)
954
+ .set({
955
+ remoteOrderId: pushed.value.remoteOrderId,
956
+ remoteUrl: pushed.value.remoteUrl ?? null,
957
+ updatedAt: new Date(),
958
+ })
959
+ .where(and(
960
+ eq(channelOrderExports.organizationId, orgId),
961
+ eq(channelOrderExports.id, created.value.id),
962
+ ));
963
+
964
+ const remoteStatus = await connector.fetchOrderStatus(
965
+ store as ChannelStore,
966
+ pushed.value.remoteOrderId,
967
+ );
968
+ if (!remoteStatus.ok) {
969
+ return this.transitionExport(
970
+ orgId,
971
+ created.value.id,
972
+ "failed",
973
+ actor.userId,
974
+ remoteStatus.error.message,
975
+ remoteStatus.error.retriable === true ? "transient" : "definitive",
976
+ );
977
+ }
978
+ if (remoteStatus.value.status === "confirmed") {
979
+ return this.transitionExport(
980
+ orgId,
981
+ created.value.id,
982
+ "confirmed",
983
+ actor.userId,
984
+ "Remote order confirmed.",
985
+ );
986
+ }
987
+ if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
988
+ return this.transitionExport(
989
+ orgId,
990
+ created.value.id,
991
+ "failed",
992
+ actor.userId,
993
+ `Remote order status: ${remoteStatus.value.status}.`,
994
+ );
995
+ }
996
+
997
+ const refreshed = await this.getExport(orgId, created.value.id);
998
+ return refreshed;
999
+ }
1000
+
1001
+ async buildOrderSlice(
1002
+ orgId: string,
1003
+ storeId: string,
1004
+ orderId: string,
1005
+ ): Promise<PluginResult<ChannelOrderSlice>> {
1006
+ const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
1007
+ if (!order) return PluginErr("Order not found.", "NOT_FOUND");
1008
+ const lineItems = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
1009
+ const entities = await this.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, lineItems.map((line) => line.entityId))));
1010
+ const entityStores = new Map(entities.map((entity) => [entity.id, entity.sourceStoreId]));
1011
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)));
1012
+ const selected = lineItems.filter((line) => entityStores.get(line.entityId) === storeId);
1013
+ const lines = [];
1014
+ for (const line of selected) {
1015
+ const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
1016
+ if (!mapping) return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
1017
+ lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
1018
+ }
1019
+
1020
+ let email: string | null = null;
1021
+ let name = "";
1022
+ let shippingAddress: Record<string, unknown> | null = null;
1023
+ if (order.customerId) {
1024
+ const [customer] = await this.db.select().from(customers).where(and(eq(customers.organizationId, orgId), eq(customers.id, order.customerId)));
1025
+ if (customer) {
1026
+ email = customer.email;
1027
+ name = `${customer.firstName ?? ""} ${customer.lastName ?? ""}`.trim();
1028
+ const addresses = await this.db.select().from(customerAddresses).where(and(eq(customerAddresses.customerId, customer.id), eq(customerAddresses.type, "shipping")));
1029
+ const address = addresses.find((item) => item.isDefault) ?? addresses[0];
1030
+ if (address) shippingAddress = { first_name: address.firstName, last_name: address.lastName, address1: address.line1, ...(address.line2 ? { address2: address.line2 } : {}), city: address.city, ...(address.state ? { state: address.state } : {}), ...(address.postalCode ? { zip: address.postalCode } : {}), country: address.country, ...(address.phone ? { phone: address.phone } : {}) };
1031
+ }
1032
+ }
1033
+ const metadata = order.metadata ?? {};
1034
+ const guest = (metadata.customer ?? metadata.guestCustomer ?? {}) as Record<string, unknown>;
1035
+ email ??= typeof guest.email === "string" ? guest.email : null;
1036
+ name ||= typeof guest.name === "string" ? guest.name : `${typeof guest.firstName === "string" ? guest.firstName : ""} ${typeof guest.lastName === "string" ? guest.lastName : ""}`.trim();
1037
+ const guestShipping = metadata.shippingAddress ?? metadata.guestShippingAddress ?? (typeof metadata.guestCustomer === "object" && metadata.guestCustomer ? (metadata.guestCustomer as Record<string, unknown>).shippingAddress : undefined);
1038
+ if (!shippingAddress && guestShipping && typeof guestShipping === "object") shippingAddress = guestShipping as Record<string, unknown>;
1039
+ if (!email || !shippingAddress) return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
1040
+ return Ok({ orderId, currency: order.currency, grandTotal: lines.reduce((sum, line) => sum + line.totalPrice, 0), lines, customer: { name, email, shippingAddress } });
1041
+ }
1042
+
1043
+ async reapExports(input: { definitiveMs: number; transientMs: number }): Promise<{ abandonedCount: number; refundedOrderIds: string[] }> {
1044
+ const now = Date.now();
1045
+ const rows = await this.db.select().from(channelOrderExports).where(inArray(channelOrderExports.state, ["exported", "failed"]));
1046
+ const abandoned: string[] = [];
1047
+ const orderService = this.services.orders as { changeStatus(input: { orderId: string; newStatus: "refunded"; reason: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
1048
+ for (const row of rows as ChannelOrderExport[]) {
1049
+ const age = now - row.updatedAt.getTime();
1050
+ const cutoff = row.failureKind === "definitive" ? input.definitiveMs : input.transientMs;
1051
+ if (age < cutoff) continue;
1052
+ const reason = `Channel order export ${row.id} abandoned after ${row.failureKind ?? "transient"} SLA.`;
1053
+ const abandonedResult = await this.abandonExport(row.organizationId, row.id, "system", reason);
1054
+ if (!abandonedResult.ok) continue;
1055
+ const refunded = await orderService.changeStatus({ orderId: row.orderId, newStatus: "refunded", reason }, createSystemActor(row.organizationId));
1056
+ if (refunded.ok) abandoned.push(row.orderId);
1057
+ }
1058
+ return { abandonedCount: abandoned.length, refundedOrderIds: abandoned };
1059
+ }
1060
+
1061
+ async getExport(orgId: string, id: string): Promise<PluginResult<ChannelOrderExport>> {
1062
+ const rows = await this.db
1063
+ .select()
1064
+ .from(channelOrderExports)
1065
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, id)));
1066
+ const item = rows[0] as ChannelOrderExport | undefined;
1067
+ if (!item) return PluginErr("Channel order export not found.", "NOT_FOUND");
1068
+ return Ok(item);
1069
+ }
1070
+
1071
+ async listFailedExports(orgId: string): Promise<PluginResult<ChannelOrderExport[]>> {
1072
+ const rows = await this.db
1073
+ .select()
1074
+ .from(channelOrderExports)
1075
+ .where(and(
1076
+ eq(channelOrderExports.organizationId, orgId),
1077
+ eq(channelOrderExports.state, "failed"),
1078
+ ));
1079
+ return Ok(rows as ChannelOrderExport[]);
1080
+ }
1081
+
1082
+ retryExport(
1083
+ orgId: string,
1084
+ exportId: string,
1085
+ changedBy: string,
1086
+ ): Promise<PluginResult<ChannelOrderExport>> {
1087
+ return this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
1088
+ }
1089
+
1090
+ abandonExport(
1091
+ orgId: string,
1092
+ exportId: string,
1093
+ changedBy: string,
1094
+ reason?: string,
1095
+ ): Promise<PluginResult<ChannelOrderExport>> {
1096
+ return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
1097
+ }
1098
+ }