@porulle/plugin-channel-connector 0.70.1 → 0.71.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/dist/service.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
3
+ import { CHANNEL_OUT_OF_STOCK, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
4
4
  import { isValidFieldPath, requireUserId } from "@porulle/core";
5
5
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
6
6
  import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
@@ -4226,8 +4226,12 @@ export class ChannelConnectorService {
4226
4226
  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: requireUserId(actor) });
4227
4227
  if (auto) {
4228
4228
  const result = await this.executeRefund(request, refundLines, actor);
4229
- if (!result.ok)
4230
- return PluginErr(result.error);
4229
+ if (!result.ok) {
4230
+ // Nothing moved: an operator decides instead, rather than the request sticking as approved.
4231
+ await this.db.update(channelRefundRequests).set({ state: "requested", approvedBy: null, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved")));
4232
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: "approved", toState: "requested", reason: `Automatic refund failed: ${result.error}`, changedBy: requireUserId(actor) });
4233
+ return Ok({ ...request, state: "requested" });
4234
+ }
4231
4235
  }
4232
4236
  return Ok(request);
4233
4237
  }
@@ -4248,7 +4252,13 @@ export class ChannelConnectorService {
4248
4252
  if (!request)
4249
4253
  return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
4250
4254
  const lines = await this.refundLinesForRequest(request);
4251
- return this.executeRefund(request, lines, createSystemActor(orgId));
4255
+ const executed = await this.executeRefund(request, lines, createSystemActor(orgId));
4256
+ if (!executed.ok) {
4257
+ // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.
4258
+ await this.db.update(channelRefundRequests).set({ state: "requested", approvedBy: null, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "approved")));
4259
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: id, fromState: "approved", toState: "requested", reason: `Execution failed: ${executed.error}`, changedBy: actor.userId });
4260
+ }
4261
+ return executed;
4252
4262
  }
4253
4263
  async rejectRefund(orgId, id, actor) {
4254
4264
  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();
@@ -4442,7 +4452,14 @@ export class ChannelConnectorService {
4442
4452
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
4443
4453
  const pushed = await connector.pushOrder(store, slice);
4444
4454
  if (!pushed.ok) {
4445
- return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
4455
+ const failed = await this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
4456
+ if (pushed.error.code === CHANNEL_OUT_OF_STOCK) {
4457
+ // The store has said nobody will send these goods: cancel, and let the host's cancel path
4458
+ // refund the shopper. Any other refusal can be fixed and retried, so it waits for an operator.
4459
+ const ordersService = this.services.orders;
4460
+ await ordersService.changeStatus({ orderId: slice.orderId, newStatus: "cancelled", reason: "channel_out_of_stock" }, actor);
4461
+ }
4462
+ return failed;
4446
4463
  }
4447
4464
  await this.db
4448
4465
  .update(channelOrderExports)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.70.1",
3
+ "version": "0.71.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.70.1"
25
+ "@porulle/core": "0.71.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
29
29
  "eslint": "^9.39.1",
30
30
  "typescript": "5.9.2",
31
31
  "vitest": "^3.2.4",
32
- "@porulle/eslint-config": "0.1.0",
33
- "@porulle/typescript-config": "0.1.0"
32
+ "@porulle/typescript-config": "0.1.0",
33
+ "@porulle/eslint-config": "0.1.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/src/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import {
4
+ CHANNEL_OUT_OF_STOCK,
4
5
  CommerceInvalidTransitionError,
5
6
  CommerceNotFoundError,
6
7
  CommerceValidationError,
@@ -5350,7 +5351,12 @@ export class ChannelConnectorService {
5350
5351
  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: requireUserId(actor) });
5351
5352
  if (auto) {
5352
5353
  const result = await this.executeRefund(request, refundLines, actor);
5353
- if (!result.ok) return PluginErr(result.error);
5354
+ if (!result.ok) {
5355
+ // Nothing moved: an operator decides instead, rather than the request sticking as approved.
5356
+ await this.db.update(channelRefundRequests).set({ state: "requested", approvedBy: null, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved")));
5357
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: "approved", toState: "requested", reason: `Automatic refund failed: ${result.error}`, changedBy: requireUserId(actor) });
5358
+ return Ok({ ...request, state: "requested" });
5359
+ }
5354
5360
  }
5355
5361
  return Ok(request);
5356
5362
  }
@@ -5372,7 +5378,13 @@ export class ChannelConnectorService {
5372
5378
  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();
5373
5379
  if (!request) return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
5374
5380
  const lines = await this.refundLinesForRequest(request as ChannelRefundRequest);
5375
- return this.executeRefund(request as ChannelRefundRequest, lines, createSystemActor(orgId));
5381
+ const executed = await this.executeRefund(request as ChannelRefundRequest, lines, createSystemActor(orgId));
5382
+ if (!executed.ok) {
5383
+ // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.
5384
+ await this.db.update(channelRefundRequests).set({ state: "requested", approvedBy: null, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "approved")));
5385
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: id, fromState: "approved", toState: "requested", reason: `Execution failed: ${executed.error}`, changedBy: actor.userId });
5386
+ }
5387
+ return executed;
5376
5388
  }
5377
5389
 
5378
5390
  async rejectRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
@@ -5602,7 +5614,7 @@ export class ChannelConnectorService {
5602
5614
 
5603
5615
  const pushed = await connector.pushOrder(store as ChannelStore, slice);
5604
5616
  if (!pushed.ok) {
5605
- return this.transitionExport(
5617
+ const failed = await this.transitionExport(
5606
5618
  orgId,
5607
5619
  created.value.id,
5608
5620
  "failed",
@@ -5610,6 +5622,13 @@ export class ChannelConnectorService {
5610
5622
  pushed.error.message,
5611
5623
  pushed.error.retriable === true ? "transient" : "definitive",
5612
5624
  );
5625
+ if (pushed.error.code === CHANNEL_OUT_OF_STOCK) {
5626
+ // The store has said nobody will send these goods: cancel, and let the host's cancel path
5627
+ // refund the shopper. Any other refusal can be fixed and retried, so it waits for an operator.
5628
+ const ordersService = this.services.orders as { changeStatus(input: { orderId: string; newStatus: "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
5629
+ await ordersService.changeStatus({ orderId: slice.orderId, newStatus: "cancelled", reason: "channel_out_of_stock" }, actor);
5630
+ }
5631
+ return failed;
5613
5632
  }
5614
5633
 
5615
5634
  await this.db