@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.
- package/dist/mock-connector.d.ts +1 -0
- package/dist/mock-connector.js +1 -1
- package/dist/schema.d.ts +25 -0
- package/dist/schema.js +2 -0
- package/dist/service.js +10 -5
- package/package.json +2 -2
- package/src/mock-connector.ts +1 -1
- package/src/schema.ts +2 -0
- package/src/service.ts +11 -6
package/dist/mock-connector.d.ts
CHANGED
package/dist/mock-connector.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
4198
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
25
|
+
"@porulle/core": "0.75.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
package/src/mock-connector.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
5339
|
-
const
|
|
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
|
-
|
|
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.
|