@porulle/plugin-channel-connector 0.74.5 → 0.75.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.
@@ -73,6 +73,7 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
73
73
  externalVariantId: string;
74
74
  quantity: number;
75
75
  }[];
76
+ amount?: number;
76
77
  } | {
77
78
  kind: "return.updated";
78
79
  remoteReturnId: string;
@@ -65,7 +65,7 @@ const channelEventSchema = z.discriminatedUnion("kind", [
65
65
  z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
66
66
  z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
67
67
  z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
68
- z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
68
+ z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })), amount: z.number().int().exactOptional() }),
69
69
  z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
70
70
  z.object({ kind: z.literal("connection.revoked") }),
71
71
  z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
package/dist/schema.d.ts CHANGED
@@ -2106,6 +2106,31 @@ export declare const channelRefundRequests: import("drizzle-orm/pg-core/table").
2106
2106
  identity: undefined;
2107
2107
  generated: undefined;
2108
2108
  }, {}, {}>;
2109
+ lines: import("@porulle/core/drizzle").PgColumn<{
2110
+ name: "lines";
2111
+ tableName: "channel_refund_requests";
2112
+ dataType: "json";
2113
+ columnType: "PgJsonb";
2114
+ data: {
2115
+ lineItemId: string;
2116
+ quantity: number;
2117
+ }[];
2118
+ driverParam: unknown;
2119
+ notNull: false;
2120
+ hasDefault: false;
2121
+ isPrimaryKey: false;
2122
+ isAutoincrement: false;
2123
+ hasRuntimeDefault: false;
2124
+ enumValues: undefined;
2125
+ baseColumn: never;
2126
+ identity: undefined;
2127
+ generated: undefined;
2128
+ }, {}, {
2129
+ $type: {
2130
+ lineItemId: string;
2131
+ quantity: number;
2132
+ }[];
2133
+ }>;
2109
2134
  state: import("@porulle/core/drizzle").PgColumn<{
2110
2135
  name: "state";
2111
2136
  tableName: "channel_refund_requests";
package/dist/schema.js CHANGED
@@ -199,6 +199,8 @@ export const channelRefundRequests = pgTable("channel_refund_requests", {
199
199
  orderId: uuid("order_id").notNull(),
200
200
  remoteRefundId: text("remote_refund_id").notNull(),
201
201
  amount: integer("amount").notNull(),
202
+ /** The order lines the store refunded. Null on requests made before it was kept. */
203
+ lines: jsonb("lines").$type(),
202
204
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
203
205
  approvedBy: text("approved_by"),
204
206
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
package/dist/service.js CHANGED
@@ -4185,17 +4185,21 @@ export class ChannelConnectorService {
4185
4185
  else
4186
4186
  refundLines.push({ lineItemId: orderLine.id, quantity });
4187
4187
  }
4188
- const amount = refundLines.reduce((sum, line) => {
4188
+ const priced = refundLines.reduce((sum, line) => {
4189
4189
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId);
4190
4190
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
4191
4191
  }, 0);
4192
+ // What the store refunded, when it says, and never more than the platform's own price for the lines:
4193
+ // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
4194
+ const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
4192
4195
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4193
4196
  if (!order)
4194
4197
  return PluginErr("Order not found.", "NOT_FOUND");
4195
4198
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
4196
4199
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
4197
- const auto = clean && amount > 0 && ageOk && amount <= max;
4198
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
4200
+ // Only a whole-line refund is automatic; any other amount is a person's call.
4201
+ const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
4202
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, lines: clean ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
4199
4203
  const request = rows[0];
4200
4204
  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) });
4201
4205
  if (auto) {
@@ -4211,7 +4215,8 @@ export class ChannelConnectorService {
4211
4215
  }
4212
4216
  async executeRefund(request, lines, actor) {
4213
4217
  const ordersService = this.services.orders;
4214
- const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
4218
+ // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
4219
+ const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}`, ...(request.lines ? { amount: request.amount } : {}) }, actor);
4215
4220
  if (!result.ok)
4216
4221
  return PluginErr(result.error?.message ?? "Refund execution failed.");
4217
4222
  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();
@@ -4225,7 +4230,7 @@ export class ChannelConnectorService {
4225
4230
  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();
4226
4231
  if (!request)
4227
4232
  return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
4228
- const lines = await this.refundLinesForRequest(request);
4233
+ const lines = request.lines ?? await this.refundLinesForRequest(request);
4229
4234
  const executed = await this.executeRefund(request, lines, createSystemActor(orgId));
4230
4235
  if (!executed.ok) {
4231
4236
  // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.74.5",
3
+ "version": "0.75.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.74.5"
25
+ "@porulle/core": "0.75.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
@@ -94,7 +94,7 @@ const channelEventSchema = z.discriminatedUnion("kind", [
94
94
  z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
95
95
  z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
96
96
  z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
97
- z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
97
+ z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })), amount: z.number().int().exactOptional() }),
98
98
  z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
99
99
  z.object({ kind: z.literal("connection.revoked") }),
100
100
  z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
package/src/schema.ts CHANGED
@@ -287,6 +287,8 @@ export const channelRefundRequests = pgTable(
287
287
  orderId: uuid("order_id").notNull(),
288
288
  remoteRefundId: text("remote_refund_id").notNull(),
289
289
  amount: integer("amount").notNull(),
290
+ /** The order lines the store refunded. Null on requests made before it was kept. */
291
+ lines: jsonb("lines").$type<Array<{ lineItemId: string; quantity: number }>>(),
290
292
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
291
293
  approvedBy: text("approved_by"),
292
294
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
package/src/service.ts CHANGED
@@ -5327,16 +5327,20 @@ export class ChannelConnectorService {
5327
5327
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
5328
5328
  else refundLines.push({ lineItemId: orderLine.id, quantity });
5329
5329
  }
5330
- const amount = refundLines.reduce((sum, line) => {
5330
+ const priced = refundLines.reduce((sum, line) => {
5331
5331
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
5332
5332
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
5333
5333
  }, 0);
5334
+ // What the store refunded, when it says, and never more than the platform's own price for the lines:
5335
+ // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
5336
+ const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
5334
5337
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5335
5338
  if (!order) return PluginErr("Order not found.", "NOT_FOUND");
5336
5339
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
5337
5340
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
5338
- const auto = clean && amount > 0 && ageOk && amount <= max;
5339
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
5341
+ // Only a whole-line refund is automatic; any other amount is a person's call.
5342
+ const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
5343
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, lines: clean ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
5340
5344
  const request = rows[0] as ChannelRefundRequest;
5341
5345
  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) });
5342
5346
  if (auto) {
@@ -5352,8 +5356,9 @@ export class ChannelConnectorService {
5352
5356
  }
5353
5357
 
5354
5358
  private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
5355
- 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 } }> };
5356
- const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
5359
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; amount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5360
+ // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
5361
+ const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}`, ...(request.lines ? { amount: request.amount } : {}) }, actor);
5357
5362
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
5358
5363
  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();
5359
5364
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
@@ -5367,7 +5372,7 @@ export class ChannelConnectorService {
5367
5372
  async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
5368
5373
  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();
5369
5374
  if (!request) return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
5370
- const lines = await this.refundLinesForRequest(request as ChannelRefundRequest);
5375
+ const lines = request.lines ?? await this.refundLinesForRequest(request as ChannelRefundRequest);
5371
5376
  const executed = await this.executeRefund(request as ChannelRefundRequest, lines, createSystemActor(orgId));
5372
5377
  if (!executed.ok) {
5373
5378
  // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.