@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/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@porulle/plugin-channel-connector",
3
+ "version": "0.9.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./schema": {
13
+ "bun": "./src/schema.ts",
14
+ "import": "./dist/schema.js",
15
+ "require": "./dist/schema.js",
16
+ "types": "./src/schema.ts"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "@hono/zod-openapi": "^1.2.2",
21
+ "hono": "^4.12.5",
22
+ "@porulle/core": "0.9.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^24.5.2",
26
+ "eslint": "^9.39.1",
27
+ "typescript": "5.9.2",
28
+ "vitest": "^3.2.4",
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "dist",
38
+ "README.md"
39
+ ],
40
+ "peerDependencies": {
41
+ "zod": ">=4.0.0"
42
+ },
43
+ "description": "Standalone channel-connector engine for external catalog ingestion and paid order export.",
44
+ "homepage": "https://porulle-docs.vercel.app",
45
+ "bugs": {
46
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
51
+ "directory": "packages/plugins/plugin-channel-connector"
52
+ },
53
+ "author": "Porulle contributors",
54
+ "scripts": {
55
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
56
+ "check-types": "tsc --noEmit",
57
+ "lint": "eslint . --max-warnings 1000",
58
+ "test": "vitest run"
59
+ }
60
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { resolveOrgId } from "@porulle/core";
2
+ import type { PluginHookRegistration } from "@porulle/core";
3
+ import { and, eq, inArray } from "@porulle/core/drizzle";
4
+ import { sellableEntities } from "@porulle/core/schema";
5
+ import {
6
+ ChannelConnectorService,
7
+ type ChannelConnectorPluginOptions,
8
+ type ChannelStockLine,
9
+ } from "./service.js";
10
+
11
+ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
12
+ return [{
13
+ key: "checkout.beforePayment",
14
+ async handler(args: unknown) {
15
+ const { data, context } = args as {
16
+ data: { lineItems: ChannelStockLine[] };
17
+ context: {
18
+ actor: Parameters<typeof resolveOrgId>[0];
19
+ db: ConstructorParameters<typeof ChannelConnectorService>[0];
20
+ services: Record<string, unknown>;
21
+ };
22
+ };
23
+ const service = new ChannelConnectorService(context.db, context.services, options);
24
+ await service.validateLineStock(
25
+ resolveOrgId(context.actor),
26
+ data.lineItems,
27
+ options.inventoryTimeoutMs,
28
+ );
29
+ return data;
30
+ },
31
+ }, {
32
+ key: "orders.afterCreate",
33
+ async handler(args: unknown) {
34
+ const { result, context } = args as {
35
+ result: { id: string; lineItems?: Array<{ entityId: string }> };
36
+ context: { actor: Parameters<typeof resolveOrgId>[0]; db: ConstructorParameters<typeof ChannelConnectorService>[0]; services: Record<string, unknown>; jobs: { enqueue(task: string, input: Record<string, unknown>, options: { organizationId: string; concurrencyKey: string; supersedes: boolean }): Promise<string> } };
37
+ };
38
+ const orgId = resolveOrgId(context.actor);
39
+ const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
40
+ const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId): storeId is string => storeId !== null));
41
+ await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
42
+ },
43
+ }];
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,423 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ CommerceConflictError,
4
+ CommerceInvalidTransitionError,
5
+ CommerceNotFoundError,
6
+ CommerceValidationError,
7
+ defineCommercePlugin,
8
+ router,
9
+ createSystemActor,
10
+ } from "@porulle/core";
11
+ import type { JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
12
+ import { z } from "@hono/zod-openapi";
13
+ import { and, eq } from "@porulle/core/drizzle";
14
+ import { processedWebhookEvents } from "@porulle/core/schema";
15
+ import {
16
+ channelEntityMap,
17
+ channelExportEvents,
18
+ channelOrderExports,
19
+ channelRefundEvents,
20
+ channelRefundRequests,
21
+ connectedStores,
22
+ } from "./schema.js";
23
+ import {
24
+ ChannelConnectorService,
25
+ type ChannelComplianceData,
26
+ type ChannelConnectorPluginOptions,
27
+ } from "./service.js";
28
+ import { buildHooks } from "./hooks.js";
29
+ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
30
+
31
+ type ChannelRouteContext = {
32
+ input: unknown;
33
+ params: Record<string, string>;
34
+ orgId: string;
35
+ actor: { userId: string } | null;
36
+ };
37
+
38
+ export { mockChannelConnector } from "./mock-connector.js";
39
+ export type { MockChannelConnectorOptions } from "./mock-connector.js";
40
+ export {
41
+ ChannelConnectorService,
42
+ canExportTransition,
43
+ } from "./service.js";
44
+ export { signState, verifyState } from "./oauth-state.js";
45
+ export type {
46
+ ChannelComplianceData,
47
+ ChannelConnectorPluginOptions,
48
+ ChannelStockLine,
49
+ ExportState,
50
+ PublicConnectedStore,
51
+ ReconcileReport,
52
+ } from "./service.js";
53
+ export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
54
+ export type {
55
+ ChannelEntityMapEntry,
56
+ ChannelExportEvent,
57
+ ChannelOrderExport,
58
+ ChannelRefundEvent,
59
+ ChannelRefundRequest,
60
+ ConnectedStore,
61
+ } from "./schema.js";
62
+
63
+ function unwrap<T>(result: PluginResult<T>): T {
64
+ if (result.ok) return result.value;
65
+ switch (result.code) {
66
+ case "NOT_FOUND":
67
+ throw new CommerceNotFoundError(result.error);
68
+ case "INVALID_TRANSITION":
69
+ throw new CommerceInvalidTransitionError(result.error);
70
+ case "CONFLICT":
71
+ throw new CommerceConflictError(result.error);
72
+ default:
73
+ throw new CommerceValidationError(result.error);
74
+ }
75
+ }
76
+
77
+ function oauthError(status: number, code: string, message: string): Response {
78
+ return new Response(JSON.stringify({ error: { code, message } }), {
79
+ status,
80
+ headers: { "content-type": "application/json" },
81
+ });
82
+ }
83
+
84
+ function oauthRedirect(location: string): Response {
85
+ return new Response(null, { status: 302, headers: { location } });
86
+ }
87
+
88
+ function callbackUri(raw: unknown, redirect: string, provider: string): string {
89
+ const request = (raw as { req: { raw: Request } }).req.raw;
90
+ const requestUrl = new URL(request.url);
91
+ let origin = requestUrl.origin;
92
+ try {
93
+ const configured = new URL(redirect);
94
+ if (configured.protocol === "http:" || configured.protocol === "https:") origin = configured.origin;
95
+ } catch {
96
+ origin = requestUrl.origin;
97
+ }
98
+ return new URL(`/api/channels/oauth/${provider}/callback`, origin).toString();
99
+ }
100
+
101
+ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions = {}) {
102
+ const jobs: TaskDefinition[] = [
103
+ {
104
+ slug: "channel/reconcile",
105
+ concurrency: { key: (input: Record<string, unknown>) => String(input.storeId), supersedes: true },
106
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
107
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
108
+ const orgId = String(input.orgId);
109
+ const result = await service.reconcile(orgId, String(input.storeId), createSystemActor(orgId));
110
+ if (!result.ok) throw new Error(result.error);
111
+ if (result.value.driftAlert) ctx.logger.warn("Channel reconciliation detected significant drift.", result.value);
112
+ return { output: result.value };
113
+ },
114
+ },
115
+ {
116
+ slug: "channel/reconcile-sweep",
117
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
118
+ const orgId = String(input.orgId);
119
+ const jobs = ctx.services.jobs as JobsAdapter;
120
+ const stores = await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected")));
121
+ const window = options.reconcileJitterWindowMs ?? 60 * 60 * 1000;
122
+ for (const store of stores) {
123
+ const offset = createHash("sha256").update(store.id).digest().readUInt32BE(0) % window;
124
+ await jobs.enqueue("channel/reconcile", { orgId, storeId: store.id }, {
125
+ organizationId: orgId,
126
+ concurrencyKey: store.id,
127
+ supersedes: true,
128
+ delayMs: offset,
129
+ });
130
+ }
131
+ return { output: { enqueued: stores.length } };
132
+ },
133
+ },
134
+ {
135
+ slug: "channel/import-catalog",
136
+ concurrency: { key: (input: Record<string, unknown>) => String(input.storeId), supersedes: true },
137
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
138
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
139
+ const result = await service.importCatalog(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
140
+ if (!result.ok) throw new Error(result.error);
141
+ return { output: { imported: result.value.imported, cursor: result.value.cursor } };
142
+ },
143
+ },
144
+ {
145
+ slug: "channel/sync-inventory",
146
+ concurrency: { key: (input: Record<string, unknown>) => String(input.storeId) },
147
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
148
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
149
+ const result = await service.syncInventory(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
150
+ if (!result.ok) throw new Error(result.error);
151
+ return { output: { synced: result.value.synced } };
152
+ },
153
+ },
154
+ {
155
+ slug: "channel/push-order",
156
+ concurrency: { key: (input: Record<string, unknown>) => `push:${String(input.orderId)}:${String(input.storeId)}`, supersedes: true },
157
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
158
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
159
+ const orgId = String(input.orgId);
160
+ const storeId = String(input.storeId);
161
+ const orderId = String(input.orderId);
162
+ const existing = await service.createExport(orgId, storeId, orderId);
163
+ if (!existing.ok) throw new Error(existing.error);
164
+ const slice = await service.buildOrderSlice(orgId, storeId, orderId);
165
+ if (!slice.ok) {
166
+ if (existing.value.state === "pending") await service.transitionExport(orgId, existing.value.id, "exported", "system", "Export attempt started.");
167
+ await service.transitionExport(orgId, existing.value.id, "failed", "system", slice.error);
168
+ return { output: { state: "failed" } };
169
+ }
170
+ const result = await service.exportOrder(orgId, storeId, slice.value, createSystemActor(orgId));
171
+ if (!result.ok) throw new Error(result.error);
172
+ return { output: { exportId: result.value.id, state: result.value.state } };
173
+ },
174
+ },
175
+ {
176
+ slug: "channel/reap-exports",
177
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
178
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
179
+ const result = await service.reapExports({
180
+ definitiveMs: typeof input.definitiveMs === "number" ? input.definitiveMs : options.exportSla?.definitiveMs ?? 4 * 60 * 60 * 1000,
181
+ transientMs: typeof input.transientMs === "number" ? input.transientMs : options.exportSla?.transientMs ?? 24 * 60 * 60 * 1000,
182
+ });
183
+ return { output: result };
184
+ },
185
+ },
186
+ ];
187
+ return defineCommercePlugin({
188
+ id: "channel-connector",
189
+ version: "1.0.0",
190
+ permissions: [
191
+ { scope: "channels:read", description: "Read connected stores and channel export status." },
192
+ { scope: "channels:manage", description: "Connect stores and retry channel order exports." },
193
+ ],
194
+ schema: () => ({
195
+ connectedStores,
196
+ channelEntityMap,
197
+ channelOrderExports,
198
+ channelExportEvents,
199
+ channelRefundRequests,
200
+ channelRefundEvents,
201
+ }),
202
+ hooks: () => buildHooks(options),
203
+ jobs: () => jobs,
204
+ routes: (ctx) => {
205
+ const db = ctx.database.db;
206
+ if (!db) return [];
207
+ const service = new ChannelConnectorService(
208
+ db,
209
+ ctx.services,
210
+ options,
211
+ ctx.database.transaction,
212
+ );
213
+ const channels = router("Channels", "/channels", ctx);
214
+
215
+ channels.get("/oauth/{provider}/start")
216
+ .summary("Start channel OAuth onboarding")
217
+ .permission("channels:manage")
218
+ .params(z.object({ provider: z.string().min(1) }))
219
+ .query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
220
+ .handler(async ({ params, query, orgId, raw }) => {
221
+ const oauth = options.oauth;
222
+ if (!oauth?.stateSecret || !oauth.postConnectRedirect) return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
223
+ const provider = params.provider!;
224
+ const connector = service.getConnector(provider);
225
+ if (!connector) return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
226
+ if (!connector.buildAuthUrl) return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
227
+ const storeDomain = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
228
+ if (!storeDomain) return oauthError(400, "STORE_DOMAIN_REQUIRED", "The shop or store query parameter is required.");
229
+ const state = signState({
230
+ provider,
231
+ orgId,
232
+ shopDomain: storeDomain,
233
+ exp: Math.floor(Date.now() / 1000) + 300,
234
+ jti: crypto.randomUUID(),
235
+ }, oauth.stateSecret);
236
+ const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
237
+ const authUrl = connector.buildAuthUrl({
238
+ storeDomain,
239
+ state,
240
+ redirectUri: redirect,
241
+ callbackUri: redirect,
242
+ scopes: [],
243
+ });
244
+ if (!authUrl.ok) return oauthError(422, authUrl.error.code, authUrl.error.message);
245
+ return oauthRedirect(authUrl.value);
246
+ });
247
+
248
+ const handleOAuthCallback = async ({ params, raw }: { params: Record<string, string>; raw: unknown }) => {
249
+ const oauth = options.oauth;
250
+ if (!oauth?.stateSecret || !oauth.postConnectRedirect) return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
251
+ const provider = params.provider!;
252
+ const connector = service.getConnector(provider);
253
+ if (!connector) return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
254
+ if (!connector.completeAuth) return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
255
+ const request = (raw as { req: { raw: Request } }).req.raw;
256
+ const requestUrl = new URL(request.url);
257
+ const state = requestUrl.searchParams.get("state");
258
+ if (!state) return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is missing.");
259
+ const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
260
+ const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000), !landing);
261
+ if (!verified.ok || verified.value.provider !== provider) return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is invalid or expired.");
262
+ if (landing) return oauthRedirect(oauth.postConnectRedirect);
263
+ const [consumed] = await db.insert(processedWebhookEvents).values({
264
+ eventId: oauthStateEventId(verified.value.jti),
265
+ provider: `oauth:${provider}`,
266
+ eventType: "oauth_state",
267
+ }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
268
+ if (!consumed) return oauthError(403, "OAUTH_STATE_REPLAYED", "OAuth state has already been used.");
269
+ const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
270
+ if (!completed.ok) return oauthError(400, completed.error.code, completed.error.message);
271
+ if (completed.value.storeDomain !== verified.value.shopDomain) return oauthError(400, "OAUTH_STORE_MISMATCH", "OAuth callback store does not match the signed state.");
272
+ const connected = await service.connectStore(verified.value.orgId, {
273
+ provider,
274
+ storeDomain: verified.value.shopDomain,
275
+ credentials: completed.value.credentials,
276
+ });
277
+ if (!connected.ok) return oauthError(422, connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
278
+ return oauthRedirect(oauth.postConnectRedirect);
279
+ };
280
+
281
+ channels.get("/oauth/{provider}/callback")
282
+ .summary("Complete channel OAuth onboarding")
283
+ .params(z.object({ provider: z.string().min(1) }))
284
+ .handler(handleOAuthCallback);
285
+
286
+ channels.post("/oauth/{provider}/callback")
287
+ .summary("Receive channel OAuth credentials")
288
+ .params(z.object({ provider: z.string().min(1) }))
289
+ .handler(handleOAuthCallback);
290
+
291
+ channels.post("/webhooks/{storeId}")
292
+ .summary("Receive a channel webhook")
293
+ .handler(async ({ params, raw }) => {
294
+ const context = raw as { req: { raw: Request; header(name: string): string | undefined }; json(data: unknown, status?: number): Response };
295
+ const storeId = params.storeId!;
296
+ const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
297
+ if (!store || !store.webhookSecret) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
298
+ const connector = service.getConnector(store.provider);
299
+ if (!connector) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
300
+ const verified = await connector.verifyWebhook(store, context.req.raw);
301
+ if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
302
+ const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
303
+ if (!inserted) return context.json({ data: { received: true, duplicate: true } });
304
+ const handled = await service.handleWebhook(store.organizationId, store.id, verified.value);
305
+ if (!handled.ok) return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
306
+ return context.json({ data: {
307
+ received: true,
308
+ ...(handled.value.data ? { data: handled.value.data } : {}),
309
+ ...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
310
+ } });
311
+ });
312
+
313
+ channels.post("/compliance/{provider}")
314
+ .summary("Receive a compliance webhook")
315
+ .params(z.object({ provider: z.string().min(1) }))
316
+ .handler(async ({ params, raw }) => {
317
+ const context = raw as { req: { raw: Request; header(name: string): string | undefined }; json(data: unknown, status?: number): Response };
318
+ const provider = params.provider!;
319
+ const connector = service.getConnector(provider);
320
+ if (!connector) return context.json({ error: { code: "NOT_FOUND", message: `No connector registered for provider "${provider}".` } }, 404);
321
+ if (!connector.verifyAppWebhook) return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
322
+ const verified = await connector.verifyAppWebhook(context.req.raw);
323
+ if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
324
+ const request = context.req.raw;
325
+ const eventId = request.headers.get("x-shopify-event-id") ?? createHash("sha256").update(`${verified.value.topic}:${verified.value.shopDomain}:${JSON.stringify(verified.value.data)}`).digest("hex");
326
+ const [inserted] = await db.insert(processedWebhookEvents).values({ eventId, provider, eventType: verified.value.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
327
+ if (!inserted) return context.json({ data: { received: true, duplicate: true } });
328
+ const stores = await service.getStoresByDomain(verified.value.shopDomain);
329
+ if (stores.length === 0) return context.json({ data: { received: true } });
330
+ let redacted = 0;
331
+ let redactedSeen = false;
332
+ let complianceData: ChannelComplianceData | undefined;
333
+ for (const store of stores) {
334
+ const handled = await service.handleWebhook(store.organizationId, store.id, { id: eventId, type: verified.value.topic, data: verified.value.data });
335
+ if (!handled.ok) return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
336
+ if (handled.value.redacted !== undefined) { redacted += handled.value.redacted; redactedSeen = true; }
337
+ if (handled.value.data) {
338
+ complianceData = complianceData
339
+ ? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
340
+ : handled.value.data;
341
+ }
342
+ }
343
+ return context.json({ data: {
344
+ received: true,
345
+ ...(complianceData ? { data: complianceData } : {}),
346
+ ...(redactedSeen ? { redacted } : {}),
347
+ } });
348
+ });
349
+
350
+ channels.post("/stores")
351
+ .summary("Connect a channel store")
352
+ .permission("channels:manage")
353
+ .input(z.object({
354
+ provider: z.string().min(1),
355
+ credentials: z.record(z.string(), z.unknown()),
356
+ storeDomain: z.string().min(1),
357
+ webhookSecret: z.string().min(1).optional(),
358
+ }))
359
+ .handler(async ({ input, orgId }: ChannelRouteContext) => {
360
+ return unwrap(await service.connectStore(
361
+ orgId,
362
+ input as {
363
+ provider: string;
364
+ credentials: Record<string, unknown>;
365
+ storeDomain: string;
366
+ webhookSecret?: string;
367
+ },
368
+ ));
369
+ });
370
+
371
+ channels.get("/stores")
372
+ .summary("List connected channel stores")
373
+ .permission("channels:read")
374
+ .handler(async ({ orgId }: ChannelRouteContext) => unwrap(await service.listStores(orgId)));
375
+
376
+ channels.get("/stores/{id}")
377
+ .summary("Get a connected channel store")
378
+ .permission("channels:read")
379
+ .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id!)));
380
+
381
+ channels.get("/stores/{storeId}/reconcile-status")
382
+ .summary("Get channel reconciliation status")
383
+ .permission("channels:read")
384
+ .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getReconcileStatus(orgId, params.storeId!)));
385
+
386
+ channels.post("/stores/{id}/disconnect")
387
+ .summary("Disconnect a channel store")
388
+ .permission("channels:manage")
389
+ .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.disconnectStore(orgId, params.id!)));
390
+
391
+ channels.get("/exports/failed")
392
+ .summary("List failed channel order exports")
393
+ .permission("channels:read")
394
+ .handler(async ({ orgId }: ChannelRouteContext) => unwrap(await service.listFailedExports(orgId)));
395
+
396
+ channels.get("/refund-requests")
397
+ .summary("List pending channel refund requests")
398
+ .permission("channels:manage")
399
+ .handler(async ({ orgId }: ChannelRouteContext) => unwrap(await service.listRefundRequests(orgId)));
400
+
401
+ channels.post("/refund-requests/{id}/approve")
402
+ .summary("Approve a channel refund request")
403
+ .permission("channels:manage")
404
+ .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, actor!)));
405
+
406
+ channels.post("/refund-requests/{id}/reject")
407
+ .summary("Reject a channel refund request")
408
+ .permission("channels:manage")
409
+ .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, actor!)));
410
+
411
+ channels.post("/exports/{id}/retry")
412
+ .summary("Retry a failed channel order export")
413
+ .permission("channels:manage")
414
+ .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.retryExport(
415
+ orgId,
416
+ params.id!,
417
+ actor!.userId,
418
+ )));
419
+
420
+ return channels.routes() as PluginRouteRegistration[];
421
+ },
422
+ });
423
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ CommerceValidationError,
3
+ Err,
4
+ Ok,
5
+ defineChannelConnector,
6
+ } from "@porulle/core";
7
+ import type {
8
+ ChannelCatalogItem,
9
+ ChannelInventoryLevel,
10
+ ChannelOrderSlice,
11
+ } from "@porulle/core";
12
+
13
+ export interface MockChannelConnectorOptions {
14
+ catalog?: ChannelCatalogItem[];
15
+ inventory?: ChannelInventoryLevel[];
16
+ inventoryError?: Error;
17
+ throwOnInventory?: boolean;
18
+ inventoryDelayMs?: number;
19
+ onFetchInventory?: (ids: string[]) => void;
20
+ }
21
+
22
+ export function mockChannelConnector(options: MockChannelConnectorOptions = {}) {
23
+ const orders = new Map<string, ChannelOrderSlice>();
24
+
25
+ return defineChannelConnector({
26
+ providerId: "mock",
27
+ capabilities: {
28
+ importCatalog: true,
29
+ importInventory: true,
30
+ pushOrder: true,
31
+ receiveWebhooks: true,
32
+ },
33
+ async importCatalog() {
34
+ return Ok({ items: options.catalog ?? [], nextCursor: null });
35
+ },
36
+ async fetchInventory(_store, ids) {
37
+ const requestedIds = ids ?? [];
38
+ options.onFetchInventory?.(requestedIds);
39
+ if (options.inventoryDelayMs !== undefined) {
40
+ await new Promise((resolve) => setTimeout(resolve, options.inventoryDelayMs));
41
+ }
42
+ if (options.throwOnInventory) throw new Error("Mock inventory failure.");
43
+ if (options.inventoryError) return Err(new CommerceValidationError(options.inventoryError.message));
44
+ const inventory = options.inventory ?? [];
45
+ return Ok(ids ? inventory.filter((item) => ids.includes(item.externalId)) : inventory);
46
+ },
47
+ async pushOrder(_store, slice) {
48
+ const remoteOrderId = `mock-order-${orders.size + 1}`;
49
+ orders.set(remoteOrderId, structuredClone(slice));
50
+ return Ok({
51
+ remoteOrderId,
52
+ remoteUrl: `https://mock.channel.test/orders/${remoteOrderId}`,
53
+ });
54
+ },
55
+ async fetchOrderStatus(_store, remoteId) {
56
+ if (!orders.has(remoteId)) {
57
+ return Err(new CommerceValidationError(`Mock order "${remoteId}" was not found.`));
58
+ }
59
+ return Ok({ status: "confirmed" as const });
60
+ },
61
+ async verifyWebhook(store, request) {
62
+ if (request.headers.get("x-mock-signature") !== store.webhookSecret) {
63
+ return Err(new CommerceValidationError("Invalid mock webhook signature."));
64
+ }
65
+ let data: { id?: string; type?: string; data?: unknown };
66
+ try {
67
+ data = await request.json() as typeof data;
68
+ } catch {
69
+ return Err(new CommerceValidationError("Mock webhook body must be valid JSON."));
70
+ }
71
+ if (!data.id || !data.type) {
72
+ return Err(new CommerceValidationError("Mock webhook requires id and type."));
73
+ }
74
+ return Ok({ id: data.id, type: data.type, data: data.data });
75
+ },
76
+ async refundExecute() {
77
+ return Err(new CommerceValidationError(
78
+ "Channel refund execution is not implemented in the foundations slice.",
79
+ ));
80
+ },
81
+ });
82
+ }