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