@porulle/plugin-channel-connector 0.76.0 → 0.77.1
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/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/mock-connector.d.ts +1 -0
- package/dist/mock-connector.js +1 -1
- package/dist/schema.d.ts +62 -0
- package/dist/schema.js +7 -0
- package/dist/service.d.ts +14 -8
- package/dist/service.js +86 -28
- package/package.json +4 -4
- package/src/index.ts +6 -1
- package/src/mock-connector.ts +1 -1
- package/src/schema.ts +14 -0
- package/src/service.ts +89 -31
package/dist/index.d.ts
CHANGED
|
@@ -27,5 +27,5 @@ export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
|
|
|
27
27
|
export { signState, verifyState } from "./oauth-state.js";
|
|
28
28
|
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
29
29
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
30
|
-
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ChannelReturn, ConnectedStore, StoreHealth, } from "./schema.js";
|
|
30
|
+
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ChannelReturn, ChannelReturnView, ConnectedStore, StoreHealth, } from "./schema.js";
|
|
31
31
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -739,7 +739,11 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
739
739
|
channels.post("/returns/{id}/approve")
|
|
740
740
|
.summary("Approve a held return: pay the shopper back and book the refund at the store")
|
|
741
741
|
.permission("channels:connect")
|
|
742
|
-
.
|
|
742
|
+
.input(z.object({ refundShipping: z.boolean().optional() }))
|
|
743
|
+
.handler(async ({ params, orgId, actor, raw, input }) => {
|
|
744
|
+
const options = z.object({ refundShipping: z.boolean().optional() }).catch({}).parse(input ?? {});
|
|
745
|
+
return unwrap(await service.approveReturn(orgId, params.id, { orgId, actor, raw }, options.refundShipping === undefined ? {} : { refundShipping: options.refundShipping }));
|
|
746
|
+
});
|
|
743
747
|
channels.post("/returns/{id}/decline")
|
|
744
748
|
.summary("Decline a held return")
|
|
745
749
|
.permission("channels:connect")
|
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() })), amount: z.number().int().exactOptional() }),
|
|
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(), shippingAmount: 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
|
@@ -1929,6 +1929,23 @@ export declare const channelReturns: import("drizzle-orm/pg-core/table").PgTable
|
|
|
1929
1929
|
quantity: number;
|
|
1930
1930
|
}[];
|
|
1931
1931
|
}>;
|
|
1932
|
+
shippingAmount: import("@porulle/core/drizzle").PgColumn<{
|
|
1933
|
+
name: "shipping_amount";
|
|
1934
|
+
tableName: "channel_returns";
|
|
1935
|
+
dataType: "number";
|
|
1936
|
+
columnType: "PgInteger";
|
|
1937
|
+
data: number;
|
|
1938
|
+
driverParam: string | number;
|
|
1939
|
+
notNull: true;
|
|
1940
|
+
hasDefault: true;
|
|
1941
|
+
isPrimaryKey: false;
|
|
1942
|
+
isAutoincrement: false;
|
|
1943
|
+
hasRuntimeDefault: false;
|
|
1944
|
+
enumValues: undefined;
|
|
1945
|
+
baseColumn: never;
|
|
1946
|
+
identity: undefined;
|
|
1947
|
+
generated: undefined;
|
|
1948
|
+
}, {}, {}>;
|
|
1932
1949
|
reason: import("@porulle/core/drizzle").PgColumn<{
|
|
1933
1950
|
name: "reason";
|
|
1934
1951
|
tableName: "channel_returns";
|
|
@@ -2106,6 +2123,40 @@ export declare const channelRefundRequests: import("drizzle-orm/pg-core/table").
|
|
|
2106
2123
|
identity: undefined;
|
|
2107
2124
|
generated: undefined;
|
|
2108
2125
|
}, {}, {}>;
|
|
2126
|
+
shippingAmount: import("@porulle/core/drizzle").PgColumn<{
|
|
2127
|
+
name: "shipping_amount";
|
|
2128
|
+
tableName: "channel_refund_requests";
|
|
2129
|
+
dataType: "number";
|
|
2130
|
+
columnType: "PgInteger";
|
|
2131
|
+
data: number;
|
|
2132
|
+
driverParam: string | number;
|
|
2133
|
+
notNull: true;
|
|
2134
|
+
hasDefault: true;
|
|
2135
|
+
isPrimaryKey: false;
|
|
2136
|
+
isAutoincrement: false;
|
|
2137
|
+
hasRuntimeDefault: false;
|
|
2138
|
+
enumValues: undefined;
|
|
2139
|
+
baseColumn: never;
|
|
2140
|
+
identity: undefined;
|
|
2141
|
+
generated: undefined;
|
|
2142
|
+
}, {}, {}>;
|
|
2143
|
+
adjustmentAmount: import("@porulle/core/drizzle").PgColumn<{
|
|
2144
|
+
name: "adjustment_amount";
|
|
2145
|
+
tableName: "channel_refund_requests";
|
|
2146
|
+
dataType: "number";
|
|
2147
|
+
columnType: "PgInteger";
|
|
2148
|
+
data: number;
|
|
2149
|
+
driverParam: string | number;
|
|
2150
|
+
notNull: true;
|
|
2151
|
+
hasDefault: true;
|
|
2152
|
+
isPrimaryKey: false;
|
|
2153
|
+
isAutoincrement: false;
|
|
2154
|
+
hasRuntimeDefault: false;
|
|
2155
|
+
enumValues: undefined;
|
|
2156
|
+
baseColumn: never;
|
|
2157
|
+
identity: undefined;
|
|
2158
|
+
generated: undefined;
|
|
2159
|
+
}, {}, {}>;
|
|
2109
2160
|
lines: import("@porulle/core/drizzle").PgColumn<{
|
|
2110
2161
|
name: "lines";
|
|
2111
2162
|
tableName: "channel_refund_requests";
|
|
@@ -2355,4 +2406,15 @@ export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
|
2355
2406
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
2356
2407
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|
|
2357
2408
|
export type ChannelReturn = typeof channelReturns.$inferSelect;
|
|
2409
|
+
/** A held return as its merchant decides it: which order, which items, and how much delivery is refundable. */
|
|
2410
|
+
export type ChannelReturnView = ChannelReturn & {
|
|
2411
|
+
orderNumber: string | null;
|
|
2412
|
+
items: Array<{
|
|
2413
|
+
orderLineItemId: string;
|
|
2414
|
+
title: string;
|
|
2415
|
+
quantity: number;
|
|
2416
|
+
}>;
|
|
2417
|
+
/** Delivery not yet refunded on the order: what approving with delivery would pay back. */
|
|
2418
|
+
shippingRefundable: number;
|
|
2419
|
+
};
|
|
2358
2420
|
export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
|
package/dist/schema.js
CHANGED
|
@@ -184,6 +184,8 @@ export const channelReturns = pgTable("channel_returns", {
|
|
|
184
184
|
remoteReturnId: text("remote_return_id").notNull(),
|
|
185
185
|
status: text("status", { enum: ["requested", "approved", "declined", "closed", "cancelled"] }).notNull().default("requested"),
|
|
186
186
|
lines: jsonb("lines").$type().notNull(),
|
|
187
|
+
/** Delivery the merchant refunded when approving a return held on the platform. */
|
|
188
|
+
shippingAmount: integer("shipping_amount").notNull().default(0),
|
|
187
189
|
reason: text("reason").notNull(),
|
|
188
190
|
note: text("note"),
|
|
189
191
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
@@ -198,7 +200,12 @@ export const channelRefundRequests = pgTable("channel_refund_requests", {
|
|
|
198
200
|
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
199
201
|
orderId: uuid("order_id").notNull(),
|
|
200
202
|
remoteRefundId: text("remote_refund_id").notNull(),
|
|
203
|
+
/** Everything the request pays back: its lines, `shippingAmount` and `adjustmentAmount`. */
|
|
201
204
|
amount: integer("amount").notNull(),
|
|
205
|
+
/** Of `amount`, delivery the store refunded. */
|
|
206
|
+
shippingAmount: integer("shipping_amount").notNull().default(0),
|
|
207
|
+
/** Of `amount`, money the store refunded with no line behind it (goodwill). */
|
|
208
|
+
adjustmentAmount: integer("adjustment_amount").notNull().default(0),
|
|
202
209
|
/** The order lines the store refunded. Null on requests made before it was kept. */
|
|
203
210
|
lines: jsonb("lines").$type(),
|
|
204
211
|
state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
|
package/dist/service.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Actor, ChannelCancelOrderInput, ChannelCatalogItem, ChannelConnect
|
|
|
3
3
|
import type { ChannelCatalogImage } from "@porulle/core";
|
|
4
4
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
5
5
|
import type { JobsAdapter } from "@porulle/core";
|
|
6
|
-
import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ChannelReturn, type ConnectedStore } from "./schema.js";
|
|
6
|
+
import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ChannelReturn, type ChannelReturnView, type ConnectedStore } from "./schema.js";
|
|
7
7
|
import type { StoreHealth } from "./schema.js";
|
|
8
8
|
import { type CatalogFieldMapping, type CatalogFieldTarget } from "./catalog-field-mapping.js";
|
|
9
9
|
export type ExportState = ChannelOrderExport["state"];
|
|
@@ -712,7 +712,10 @@ export declare class ChannelConnectorService {
|
|
|
712
712
|
private setInventoryLevel;
|
|
713
713
|
private createRefundRequest;
|
|
714
714
|
private executeRefund;
|
|
715
|
-
|
|
715
|
+
/** Held refunds, each with the order number an approver knows the order by. */
|
|
716
|
+
listRefundRequests(orgId: string): Promise<PluginResult<Array<ChannelRefundRequest & {
|
|
717
|
+
orderNumber: string | null;
|
|
718
|
+
}>>>;
|
|
716
719
|
approveRefund(orgId: string, id: string, actor: {
|
|
717
720
|
userId: string;
|
|
718
721
|
}): Promise<PluginResult<ChannelRefundRequest>>;
|
|
@@ -755,14 +758,17 @@ export declare class ChannelConnectorService {
|
|
|
755
758
|
status: string;
|
|
756
759
|
}>>;
|
|
757
760
|
/** Returns held on the platform (stores with none of their own) that wait for their merchant. */
|
|
758
|
-
listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<
|
|
761
|
+
listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>>;
|
|
759
762
|
/**
|
|
760
|
-
* The merchant takes a held return back: the shopper is paid back those lines
|
|
761
|
-
* booked at the store with the stock put back, and kept
|
|
762
|
-
* store's own refund id so the store's webhook for it pays
|
|
763
|
-
* it, the shopper has still been paid and the return stays
|
|
763
|
+
* The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
|
|
764
|
+
* the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
|
|
765
|
+
* as an executed refund request under the store's own refund id so the store's webhook for it pays
|
|
766
|
+
* nobody twice. If the store will not book it, the shopper has still been paid and the return stays
|
|
767
|
+
* `approved`; approving again only books it, with the delivery decided the first time.
|
|
764
768
|
*/
|
|
765
|
-
approveReturn(orgId: string, id: string, context?: StoreReadContext
|
|
769
|
+
approveReturn(orgId: string, id: string, context?: StoreReadContext, options?: {
|
|
770
|
+
refundShipping?: boolean;
|
|
771
|
+
}): Promise<PluginResult<ChannelReturn>>;
|
|
766
772
|
/** The merchant refuses a held return. Nothing moves. */
|
|
767
773
|
declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
|
|
768
774
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
package/dist/service.js
CHANGED
|
@@ -5,7 +5,7 @@ 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";
|
|
7
7
|
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
8
|
-
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
8
|
+
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orderRefunds, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
9
9
|
import { planAbsentArchives } from "./deletion-policy.js";
|
|
10
10
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, channelReturns, } from "./schema.js";
|
|
11
11
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
@@ -4176,14 +4176,15 @@ export class ChannelConnectorService {
|
|
|
4176
4176
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4177
4177
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
4178
4178
|
const refundLines = [];
|
|
4179
|
-
|
|
4179
|
+
// Every line the store named is one of ours, with that much still refundable.
|
|
4180
|
+
let mapped = true;
|
|
4180
4181
|
for (const line of event.lines) {
|
|
4181
4182
|
const externalId = line.externalVariantId;
|
|
4182
4183
|
const quantity = line.quantity;
|
|
4183
4184
|
const mapping = mappings.find((item) => item.externalId === externalId);
|
|
4184
4185
|
const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
|
|
4185
4186
|
if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
|
|
4186
|
-
|
|
4187
|
+
mapped = false;
|
|
4187
4188
|
else
|
|
4188
4189
|
refundLines.push({ lineItemId: orderLine.id, quantity });
|
|
4189
4190
|
}
|
|
@@ -4191,17 +4192,33 @@ export class ChannelConnectorService {
|
|
|
4191
4192
|
const item = orderLines.find((candidate) => candidate.id === line.lineItemId);
|
|
4192
4193
|
return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
|
|
4193
4194
|
}, 0);
|
|
4194
|
-
// What the store refunded, when it says, and never more than the platform's own price for the lines:
|
|
4195
|
-
// a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
|
|
4196
|
-
const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
|
|
4197
4195
|
const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4198
4196
|
if (!order)
|
|
4199
4197
|
return PluginErr("Order not found.", "NOT_FOUND");
|
|
4198
|
+
const completed = (await this.db.select({ amount: orderRefunds.amount, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
4199
|
+
.where(and(eq(orderRefunds.orderId, orderId), eq(orderRefunds.status, "completed"))));
|
|
4200
|
+
const shippingLeft = Math.max(0, order.shippingTotal - completed.reduce((sum, refund) => sum + refund.shippingAmount, 0));
|
|
4201
|
+
const orderLeft = Math.max(0, order.grandTotal - completed.reduce((sum, refund) => sum + refund.amount, 0));
|
|
4202
|
+
// What the store refunded, and never more than the shopper paid for what it names: the lines at most
|
|
4203
|
+
// at the platform's own price (part of a line, or a line the store discounted, is less), the delivery
|
|
4204
|
+
// at most what of it is not refunded yet, money with no line behind it (goodwill) only when the refund
|
|
4205
|
+
// names no line, and the whole at most what the order has left.
|
|
4206
|
+
const storeShipping = Math.max(0, event.shippingAmount ?? 0);
|
|
4207
|
+
const shippingAmount = Math.min(storeShipping, shippingLeft);
|
|
4208
|
+
const rest = event.amount === undefined ? priced : Math.max(0, event.amount - storeShipping);
|
|
4209
|
+
const linesAmount = Math.min(rest, priced);
|
|
4210
|
+
const goodwill = event.lines.length === 0 ? rest : 0;
|
|
4211
|
+
const adjustmentAmount = Math.max(0, Math.min(goodwill, orderLeft - linesAmount - shippingAmount));
|
|
4212
|
+
const amount = Math.min(linesAmount + shippingAmount + adjustmentAmount, orderLeft);
|
|
4213
|
+
// A refund that pays nothing (a restock, or nothing left to pay) asks nobody for money.
|
|
4214
|
+
if (amount === 0)
|
|
4215
|
+
return Ok(null);
|
|
4200
4216
|
const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
|
|
4201
4217
|
const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
|
|
4202
|
-
// Only
|
|
4203
|
-
|
|
4204
|
-
const
|
|
4218
|
+
// Only whole lines, at exactly the platform's price with exactly their delivery, are automatic; any
|
|
4219
|
+
// other amount is a person's call.
|
|
4220
|
+
const auto = mapped && refundLines.length > 0 && adjustmentAmount === 0 && linesAmount === priced && shippingAmount === storeShipping && ageOk && amount <= max;
|
|
4221
|
+
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, shippingAmount, adjustmentAmount, lines: mapped ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
|
|
4205
4222
|
const request = rows[0];
|
|
4206
4223
|
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) });
|
|
4207
4224
|
if (auto) {
|
|
@@ -4217,16 +4234,28 @@ export class ChannelConnectorService {
|
|
|
4217
4234
|
}
|
|
4218
4235
|
async executeRefund(request, lines, actor) {
|
|
4219
4236
|
const ordersService = this.services.orders;
|
|
4220
|
-
// A request that kept its lines pays back its own amount; an older one is priced from the
|
|
4221
|
-
|
|
4237
|
+
// A request that kept its lines pays back its own amount for them; an older one is priced from the
|
|
4238
|
+
// lines rebuilt for it. Delivery and goodwill ride beside the lines.
|
|
4239
|
+
const linesAmount = request.amount - request.shippingAmount - request.adjustmentAmount;
|
|
4240
|
+
const result = await ordersService.refundLines(request.orderId, {
|
|
4241
|
+
lines,
|
|
4242
|
+
reason: `Channel refund ${request.remoteRefundId}`,
|
|
4243
|
+
...(request.lines && lines.length > 0 ? { amount: linesAmount } : {}),
|
|
4244
|
+
...(request.shippingAmount > 0 ? { shippingAmount: request.shippingAmount } : {}),
|
|
4245
|
+
...(request.adjustmentAmount > 0 ? { adjustmentAmount: request.adjustmentAmount } : {}),
|
|
4246
|
+
}, actor);
|
|
4222
4247
|
if (!result.ok)
|
|
4223
4248
|
return PluginErr(result.error?.message ?? "Refund execution failed.");
|
|
4224
4249
|
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
4250
|
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
|
|
4226
4251
|
return Ok(updated);
|
|
4227
4252
|
}
|
|
4253
|
+
/** Held refunds, each with the order number an approver knows the order by. */
|
|
4228
4254
|
async listRefundRequests(orgId) {
|
|
4229
|
-
|
|
4255
|
+
const rows = await this.db.select({ request: channelRefundRequests, orderNumber: orders.orderNumber }).from(channelRefundRequests)
|
|
4256
|
+
.leftJoin(orders, eq(orders.id, channelRefundRequests.orderId))
|
|
4257
|
+
.where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested")));
|
|
4258
|
+
return Ok(rows.map((row) => ({ ...row.request, orderNumber: row.orderNumber })));
|
|
4230
4259
|
}
|
|
4231
4260
|
async approveRefund(orgId, id, actor) {
|
|
4232
4261
|
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();
|
|
@@ -4467,15 +4496,33 @@ export class ChannelConnectorService {
|
|
|
4467
4496
|
if (allowed !== null && allowed.length === 0)
|
|
4468
4497
|
return Ok([]);
|
|
4469
4498
|
const rows = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.status, "requested"), sql `${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`, ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]))).orderBy(desc(channelReturns.createdAt));
|
|
4470
|
-
|
|
4499
|
+
if (rows.length === 0)
|
|
4500
|
+
return Ok([]);
|
|
4501
|
+
const orderIds = [...new Set(rows.map((row) => row.orderId))];
|
|
4502
|
+
const orderRows = await this.db.select({ id: orders.id, orderNumber: orders.orderNumber, shippingTotal: orders.shippingTotal }).from(orders)
|
|
4503
|
+
.where(and(eq(orders.organizationId, orgId), inArray(orders.id, orderIds)));
|
|
4504
|
+
const lineRows = await this.db.select({ id: orderLineItems.id, title: orderLineItems.title }).from(orderLineItems).where(inArray(orderLineItems.orderId, orderIds));
|
|
4505
|
+
const refunded = await this.db.select({ orderId: orderRefunds.orderId, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
4506
|
+
.where(and(inArray(orderRefunds.orderId, orderIds), eq(orderRefunds.status, "completed")));
|
|
4507
|
+
return Ok(rows.map((row) => {
|
|
4508
|
+
const order = orderRows.find((candidate) => candidate.id === row.orderId);
|
|
4509
|
+
const shippingRefunded = refunded.filter((refund) => refund.orderId === row.orderId).reduce((sum, refund) => sum + refund.shippingAmount, 0);
|
|
4510
|
+
return {
|
|
4511
|
+
...row,
|
|
4512
|
+
orderNumber: order?.orderNumber ?? null,
|
|
4513
|
+
items: row.lines.map((line) => ({ orderLineItemId: line.orderLineItemId, title: lineRows.find((candidate) => candidate.id === line.orderLineItemId)?.title ?? "Item", quantity: line.quantity })),
|
|
4514
|
+
shippingRefundable: Math.max(0, (order?.shippingTotal ?? 0) - shippingRefunded),
|
|
4515
|
+
};
|
|
4516
|
+
}));
|
|
4471
4517
|
}
|
|
4472
4518
|
/**
|
|
4473
|
-
* The merchant takes a held return back: the shopper is paid back those lines
|
|
4474
|
-
* booked at the store with the stock put back, and kept
|
|
4475
|
-
* store's own refund id so the store's webhook for it pays
|
|
4476
|
-
* it, the shopper has still been paid and the return stays
|
|
4519
|
+
* The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
|
|
4520
|
+
* the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
|
|
4521
|
+
* as an executed refund request under the store's own refund id so the store's webhook for it pays
|
|
4522
|
+
* nobody twice. If the store will not book it, the shopper has still been paid and the return stays
|
|
4523
|
+
* `approved`; approving again only books it, with the delivery decided the first time.
|
|
4477
4524
|
*/
|
|
4478
|
-
async approveReturn(orgId, id, context) {
|
|
4525
|
+
async approveReturn(orgId, id, context, options = {}) {
|
|
4479
4526
|
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
4480
4527
|
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
|
|
4481
4528
|
return PluginErr("Return not found.", "NOT_FOUND");
|
|
@@ -4501,12 +4548,19 @@ export class ChannelConnectorService {
|
|
|
4501
4548
|
priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
|
|
4502
4549
|
}
|
|
4503
4550
|
const actor = createSystemActor(orgId);
|
|
4551
|
+
let shippingAmount = held.shippingAmount;
|
|
4504
4552
|
if (held.status === "requested") {
|
|
4553
|
+
if (options.refundShipping === true) {
|
|
4554
|
+
const [order] = await this.db.select({ shippingTotal: orders.shippingTotal }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, held.orderId)));
|
|
4555
|
+
const refunded = await this.db.select({ shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
4556
|
+
.where(and(eq(orderRefunds.orderId, held.orderId), eq(orderRefunds.status, "completed")));
|
|
4557
|
+
shippingAmount = Math.max(0, (order?.shippingTotal ?? 0) - refunded.reduce((sum, refund) => sum + refund.shippingAmount, 0));
|
|
4558
|
+
}
|
|
4505
4559
|
const ordersService = this.services.orders;
|
|
4506
|
-
const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}
|
|
4560
|
+
const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}`, ...(shippingAmount > 0 ? { shippingAmount } : {}) }, actor);
|
|
4507
4561
|
if (!refunded.ok)
|
|
4508
4562
|
return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
|
|
4509
|
-
await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
4563
|
+
await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
4510
4564
|
}
|
|
4511
4565
|
const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
4512
4566
|
const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
|
|
@@ -4518,14 +4572,14 @@ export class ChannelConnectorService {
|
|
|
4518
4572
|
return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
|
|
4519
4573
|
storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
|
|
4520
4574
|
}
|
|
4521
|
-
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
|
|
4522
|
-
const booked = await connector.recordRefund(store, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
|
|
4575
|
+
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0) + shippingAmount;
|
|
4576
|
+
const booked = await connector.recordRefund(store, exported.remoteOrderId, { lines: storeLines, amount, ...(shippingAmount > 0 ? { shippingAmount } : {}), reason: `Return: ${held.reason}`, restock: true });
|
|
4523
4577
|
if (!booked.ok)
|
|
4524
4578
|
return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
|
|
4525
4579
|
const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
|
|
4526
4580
|
await this.db.insert(channelRefundRequests)
|
|
4527
|
-
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
4528
|
-
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
|
|
4581
|
+
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, shippingAmount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
4582
|
+
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, shippingAmount, adjustmentAmount: 0, lines, state: "executed", updatedAt: new Date() } });
|
|
4529
4583
|
const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
|
|
4530
4584
|
return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
|
|
4531
4585
|
}
|
|
@@ -4591,8 +4645,10 @@ export class ChannelConnectorService {
|
|
|
4591
4645
|
if (!remoteStatus.ok) {
|
|
4592
4646
|
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
|
|
4593
4647
|
}
|
|
4594
|
-
|
|
4595
|
-
|
|
4648
|
+
// `fulfilled` is a received order too: WooCommerce completes virtual and downloadable orders on
|
|
4649
|
+
// arrival, and an export waiting for `confirmed` would wait forever.
|
|
4650
|
+
if (remoteStatus.value.status === "confirmed" || remoteStatus.value.status === "fulfilled") {
|
|
4651
|
+
return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), remoteStatus.value.status === "fulfilled" ? "Remote order received and completed by the store." : "Remote order confirmed.");
|
|
4596
4652
|
}
|
|
4597
4653
|
if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
|
|
4598
4654
|
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), `Remote order status: ${remoteStatus.value.status}.`);
|
|
@@ -4614,7 +4670,7 @@ export class ChannelConnectorService {
|
|
|
4614
4670
|
const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
|
|
4615
4671
|
if (!mapping)
|
|
4616
4672
|
return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
|
|
4617
|
-
lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
|
|
4673
|
+
lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice, ...(line.discountAmount > 0 ? { discountAmount: line.discountAmount } : {}) });
|
|
4618
4674
|
}
|
|
4619
4675
|
let email = null;
|
|
4620
4676
|
let name = "";
|
|
@@ -4663,7 +4719,9 @@ export class ChannelConnectorService {
|
|
|
4663
4719
|
// ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
|
|
4664
4720
|
const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
|
|
4665
4721
|
const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
|
|
4666
|
-
|
|
4722
|
+
// A line's discount travels with the order discount it is a share of, never without it.
|
|
4723
|
+
const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
|
|
4724
|
+
return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
|
|
4667
4725
|
}
|
|
4668
4726
|
async reapExports(input) {
|
|
4669
4727
|
const now = Date.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.1",
|
|
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.
|
|
25
|
+
"@porulle/core": "0.77.1"
|
|
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/
|
|
33
|
-
"@porulle/
|
|
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/index.ts
CHANGED
|
@@ -260,6 +260,7 @@ export type {
|
|
|
260
260
|
ChannelRefundEvent,
|
|
261
261
|
ChannelRefundRequest,
|
|
262
262
|
ChannelReturn,
|
|
263
|
+
ChannelReturnView,
|
|
263
264
|
ConnectedStore,
|
|
264
265
|
StoreHealth,
|
|
265
266
|
} from "./schema.js";
|
|
@@ -918,7 +919,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
918
919
|
channels.post("/returns/{id}/approve")
|
|
919
920
|
.summary("Approve a held return: pay the shopper back and book the refund at the store")
|
|
920
921
|
.permission("channels:connect")
|
|
921
|
-
.
|
|
922
|
+
.input(z.object({ refundShipping: z.boolean().optional() }))
|
|
923
|
+
.handler(async ({ params, orgId, actor, raw, input }: ChannelRouteContext) => {
|
|
924
|
+
const options = z.object({ refundShipping: z.boolean().optional() }).catch({}).parse(input ?? {});
|
|
925
|
+
return unwrap(await service.approveReturn(orgId, params.id!, { orgId, actor, raw }, options.refundShipping === undefined ? {} : { refundShipping: options.refundShipping }));
|
|
926
|
+
});
|
|
922
927
|
|
|
923
928
|
channels.post("/returns/{id}/decline")
|
|
924
929
|
.summary("Decline a held return")
|
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() })), amount: z.number().int().exactOptional() }),
|
|
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(), shippingAmount: 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
|
@@ -267,6 +267,8 @@ export const channelReturns = pgTable(
|
|
|
267
267
|
remoteReturnId: text("remote_return_id").notNull(),
|
|
268
268
|
status: text("status", { enum: ["requested", "approved", "declined", "closed", "cancelled"] }).notNull().default("requested"),
|
|
269
269
|
lines: jsonb("lines").$type<Array<{ orderLineItemId: string; quantity: number }>>().notNull(),
|
|
270
|
+
/** Delivery the merchant refunded when approving a return held on the platform. */
|
|
271
|
+
shippingAmount: integer("shipping_amount").notNull().default(0),
|
|
270
272
|
reason: text("reason").notNull(),
|
|
271
273
|
note: text("note"),
|
|
272
274
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
@@ -286,7 +288,12 @@ export const channelRefundRequests = pgTable(
|
|
|
286
288
|
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
287
289
|
orderId: uuid("order_id").notNull(),
|
|
288
290
|
remoteRefundId: text("remote_refund_id").notNull(),
|
|
291
|
+
/** Everything the request pays back: its lines, `shippingAmount` and `adjustmentAmount`. */
|
|
289
292
|
amount: integer("amount").notNull(),
|
|
293
|
+
/** Of `amount`, delivery the store refunded. */
|
|
294
|
+
shippingAmount: integer("shipping_amount").notNull().default(0),
|
|
295
|
+
/** Of `amount`, money the store refunded with no line behind it (goodwill). */
|
|
296
|
+
adjustmentAmount: integer("adjustment_amount").notNull().default(0),
|
|
290
297
|
/** The order lines the store refunded. Null on requests made before it was kept. */
|
|
291
298
|
lines: jsonb("lines").$type<Array<{ lineItemId: string; quantity: number }>>(),
|
|
292
299
|
state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
|
|
@@ -329,4 +336,11 @@ export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
|
329
336
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
330
337
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|
|
331
338
|
export type ChannelReturn = typeof channelReturns.$inferSelect;
|
|
339
|
+
/** A held return as its merchant decides it: which order, which items, and how much delivery is refundable. */
|
|
340
|
+
export type ChannelReturnView = ChannelReturn & {
|
|
341
|
+
orderNumber: string | null;
|
|
342
|
+
items: Array<{ orderLineItemId: string; title: string; quantity: number }>;
|
|
343
|
+
/** Delivery not yet refunded on the order: what approving with delivery would pay back. */
|
|
344
|
+
shippingRefundable: number;
|
|
345
|
+
};
|
|
332
346
|
export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
|
package/src/service.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
fulfillmentLineItems,
|
|
65
65
|
fulfillmentRecords,
|
|
66
66
|
orderLineItems,
|
|
67
|
+
orderRefunds,
|
|
67
68
|
orders,
|
|
68
69
|
prices,
|
|
69
70
|
sellableAttributes,
|
|
@@ -95,6 +96,7 @@ import {
|
|
|
95
96
|
type ChannelOrderExport,
|
|
96
97
|
type ChannelRefundRequest,
|
|
97
98
|
type ChannelReturn,
|
|
99
|
+
type ChannelReturnView,
|
|
98
100
|
type ConnectedStore,
|
|
99
101
|
} from "./schema.js";
|
|
100
102
|
import type { StoreHealth } from "./schema.js";
|
|
@@ -5321,29 +5323,45 @@ export class ChannelConnectorService {
|
|
|
5321
5323
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5322
5324
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
5323
5325
|
const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
|
|
5324
|
-
|
|
5326
|
+
// Every line the store named is one of ours, with that much still refundable.
|
|
5327
|
+
let mapped = true;
|
|
5325
5328
|
for (const line of event.lines) {
|
|
5326
5329
|
const externalId = line.externalVariantId;
|
|
5327
5330
|
const quantity = line.quantity;
|
|
5328
5331
|
const mapping = mappings.find((item) => item.externalId === externalId);
|
|
5329
5332
|
const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
|
|
5330
|
-
if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
|
|
5333
|
+
if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) mapped = false;
|
|
5331
5334
|
else refundLines.push({ lineItemId: orderLine.id, quantity });
|
|
5332
5335
|
}
|
|
5333
5336
|
const priced = refundLines.reduce((sum, line) => {
|
|
5334
5337
|
const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
|
|
5335
5338
|
return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
|
|
5336
5339
|
}, 0);
|
|
5337
|
-
// What the store refunded, when it says, and never more than the platform's own price for the lines:
|
|
5338
|
-
// a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
|
|
5339
|
-
const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
|
|
5340
5340
|
const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5341
5341
|
if (!order) return PluginErr("Order not found.", "NOT_FOUND");
|
|
5342
|
+
const completed = (await this.db.select({ amount: orderRefunds.amount, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
5343
|
+
.where(and(eq(orderRefunds.orderId, orderId), eq(orderRefunds.status, "completed"))));
|
|
5344
|
+
const shippingLeft = Math.max(0, order.shippingTotal - completed.reduce((sum, refund) => sum + refund.shippingAmount, 0));
|
|
5345
|
+
const orderLeft = Math.max(0, order.grandTotal - completed.reduce((sum, refund) => sum + refund.amount, 0));
|
|
5346
|
+
// What the store refunded, and never more than the shopper paid for what it names: the lines at most
|
|
5347
|
+
// at the platform's own price (part of a line, or a line the store discounted, is less), the delivery
|
|
5348
|
+
// at most what of it is not refunded yet, money with no line behind it (goodwill) only when the refund
|
|
5349
|
+
// names no line, and the whole at most what the order has left.
|
|
5350
|
+
const storeShipping = Math.max(0, event.shippingAmount ?? 0);
|
|
5351
|
+
const shippingAmount = Math.min(storeShipping, shippingLeft);
|
|
5352
|
+
const rest = event.amount === undefined ? priced : Math.max(0, event.amount - storeShipping);
|
|
5353
|
+
const linesAmount = Math.min(rest, priced);
|
|
5354
|
+
const goodwill = event.lines.length === 0 ? rest : 0;
|
|
5355
|
+
const adjustmentAmount = Math.max(0, Math.min(goodwill, orderLeft - linesAmount - shippingAmount));
|
|
5356
|
+
const amount = Math.min(linesAmount + shippingAmount + adjustmentAmount, orderLeft);
|
|
5357
|
+
// A refund that pays nothing (a restock, or nothing left to pay) asks nobody for money.
|
|
5358
|
+
if (amount === 0) return Ok(null);
|
|
5342
5359
|
const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
|
|
5343
5360
|
const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
|
|
5344
|
-
// Only
|
|
5345
|
-
|
|
5346
|
-
const
|
|
5361
|
+
// Only whole lines, at exactly the platform's price with exactly their delivery, are automatic; any
|
|
5362
|
+
// other amount is a person's call.
|
|
5363
|
+
const auto = mapped && refundLines.length > 0 && adjustmentAmount === 0 && linesAmount === priced && shippingAmount === storeShipping && ageOk && amount <= max;
|
|
5364
|
+
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, shippingAmount, adjustmentAmount, lines: mapped ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
|
|
5347
5365
|
const request = rows[0] as ChannelRefundRequest;
|
|
5348
5366
|
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) });
|
|
5349
5367
|
if (auto) {
|
|
@@ -5359,17 +5377,29 @@ export class ChannelConnectorService {
|
|
|
5359
5377
|
}
|
|
5360
5378
|
|
|
5361
5379
|
private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
|
|
5362
|
-
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 } }> };
|
|
5363
|
-
// A request that kept its lines pays back its own amount; an older one is priced from the
|
|
5364
|
-
|
|
5380
|
+
const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; amount?: number; shippingAmount?: number; adjustmentAmount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5381
|
+
// A request that kept its lines pays back its own amount for them; an older one is priced from the
|
|
5382
|
+
// lines rebuilt for it. Delivery and goodwill ride beside the lines.
|
|
5383
|
+
const linesAmount = request.amount - request.shippingAmount - request.adjustmentAmount;
|
|
5384
|
+
const result = await ordersService.refundLines(request.orderId, {
|
|
5385
|
+
lines,
|
|
5386
|
+
reason: `Channel refund ${request.remoteRefundId}`,
|
|
5387
|
+
...(request.lines && lines.length > 0 ? { amount: linesAmount } : {}),
|
|
5388
|
+
...(request.shippingAmount > 0 ? { shippingAmount: request.shippingAmount } : {}),
|
|
5389
|
+
...(request.adjustmentAmount > 0 ? { adjustmentAmount: request.adjustmentAmount } : {}),
|
|
5390
|
+
}, actor);
|
|
5365
5391
|
if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
|
|
5366
5392
|
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();
|
|
5367
5393
|
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
|
|
5368
5394
|
return Ok(updated as ChannelRefundRequest);
|
|
5369
5395
|
}
|
|
5370
5396
|
|
|
5371
|
-
|
|
5372
|
-
|
|
5397
|
+
/** Held refunds, each with the order number an approver knows the order by. */
|
|
5398
|
+
async listRefundRequests(orgId: string): Promise<PluginResult<Array<ChannelRefundRequest & { orderNumber: string | null }>>> {
|
|
5399
|
+
const rows = await this.db.select({ request: channelRefundRequests, orderNumber: orders.orderNumber }).from(channelRefundRequests)
|
|
5400
|
+
.leftJoin(orders, eq(orders.id, channelRefundRequests.orderId))
|
|
5401
|
+
.where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested")));
|
|
5402
|
+
return Ok(rows.map((row) => ({ ...(row.request as ChannelRefundRequest), orderNumber: row.orderNumber })));
|
|
5373
5403
|
}
|
|
5374
5404
|
|
|
5375
5405
|
async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
|
|
@@ -5626,7 +5656,7 @@ export class ChannelConnectorService {
|
|
|
5626
5656
|
}
|
|
5627
5657
|
|
|
5628
5658
|
/** Returns held on the platform (stores with none of their own) that wait for their merchant. */
|
|
5629
|
-
async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<
|
|
5659
|
+
async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>> {
|
|
5630
5660
|
const allowed = await this.allowedStores(orgId, context);
|
|
5631
5661
|
if (allowed !== null && allowed.length === 0) return Ok([]);
|
|
5632
5662
|
const rows = await this.db.select().from(channelReturns).where(and(
|
|
@@ -5635,16 +5665,33 @@ export class ChannelConnectorService {
|
|
|
5635
5665
|
sql`${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`,
|
|
5636
5666
|
...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]),
|
|
5637
5667
|
)).orderBy(desc(channelReturns.createdAt));
|
|
5638
|
-
return Ok(
|
|
5668
|
+
if (rows.length === 0) return Ok([]);
|
|
5669
|
+
const orderIds = [...new Set(rows.map((row) => row.orderId))];
|
|
5670
|
+
const orderRows = await this.db.select({ id: orders.id, orderNumber: orders.orderNumber, shippingTotal: orders.shippingTotal }).from(orders)
|
|
5671
|
+
.where(and(eq(orders.organizationId, orgId), inArray(orders.id, orderIds)));
|
|
5672
|
+
const lineRows = await this.db.select({ id: orderLineItems.id, title: orderLineItems.title }).from(orderLineItems).where(inArray(orderLineItems.orderId, orderIds));
|
|
5673
|
+
const refunded = await this.db.select({ orderId: orderRefunds.orderId, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
5674
|
+
.where(and(inArray(orderRefunds.orderId, orderIds), eq(orderRefunds.status, "completed")));
|
|
5675
|
+
return Ok(rows.map((row) => {
|
|
5676
|
+
const order = orderRows.find((candidate) => candidate.id === row.orderId);
|
|
5677
|
+
const shippingRefunded = refunded.filter((refund) => refund.orderId === row.orderId).reduce((sum, refund) => sum + refund.shippingAmount, 0);
|
|
5678
|
+
return {
|
|
5679
|
+
...row,
|
|
5680
|
+
orderNumber: order?.orderNumber ?? null,
|
|
5681
|
+
items: row.lines.map((line) => ({ orderLineItemId: line.orderLineItemId, title: lineRows.find((candidate) => candidate.id === line.orderLineItemId)?.title ?? "Item", quantity: line.quantity })),
|
|
5682
|
+
shippingRefundable: Math.max(0, (order?.shippingTotal ?? 0) - shippingRefunded),
|
|
5683
|
+
};
|
|
5684
|
+
}));
|
|
5639
5685
|
}
|
|
5640
5686
|
|
|
5641
5687
|
/**
|
|
5642
|
-
* The merchant takes a held return back: the shopper is paid back those lines
|
|
5643
|
-
* booked at the store with the stock put back, and kept
|
|
5644
|
-
* store's own refund id so the store's webhook for it pays
|
|
5645
|
-
* it, the shopper has still been paid and the return stays
|
|
5688
|
+
* The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
|
|
5689
|
+
* the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
|
|
5690
|
+
* as an executed refund request under the store's own refund id so the store's webhook for it pays
|
|
5691
|
+
* nobody twice. If the store will not book it, the shopper has still been paid and the return stays
|
|
5692
|
+
* `approved`; approving again only books it, with the delivery decided the first time.
|
|
5646
5693
|
*/
|
|
5647
|
-
async approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
|
|
5694
|
+
async approveReturn(orgId: string, id: string, context?: StoreReadContext, options: { refundShipping?: boolean } = {}): Promise<PluginResult<ChannelReturn>> {
|
|
5648
5695
|
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
5649
5696
|
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
|
|
5650
5697
|
const reached = await this.reachableStore(orgId, held.storeId, context);
|
|
@@ -5665,11 +5712,18 @@ export class ChannelConnectorService {
|
|
|
5665
5712
|
priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
|
|
5666
5713
|
}
|
|
5667
5714
|
const actor = createSystemActor(orgId);
|
|
5715
|
+
let shippingAmount = held.shippingAmount;
|
|
5668
5716
|
if (held.status === "requested") {
|
|
5669
|
-
|
|
5670
|
-
|
|
5717
|
+
if (options.refundShipping === true) {
|
|
5718
|
+
const [order] = await this.db.select({ shippingTotal: orders.shippingTotal }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, held.orderId)));
|
|
5719
|
+
const refunded = await this.db.select({ shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
|
|
5720
|
+
.where(and(eq(orderRefunds.orderId, held.orderId), eq(orderRefunds.status, "completed")));
|
|
5721
|
+
shippingAmount = Math.max(0, (order?.shippingTotal ?? 0) - refunded.reduce((sum, refund) => sum + refund.shippingAmount, 0));
|
|
5722
|
+
}
|
|
5723
|
+
const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; shippingAmount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5724
|
+
const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}`, ...(shippingAmount > 0 ? { shippingAmount } : {}) }, actor);
|
|
5671
5725
|
if (!refunded.ok) return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
|
|
5672
|
-
await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
5726
|
+
await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
5673
5727
|
}
|
|
5674
5728
|
|
|
5675
5729
|
const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
@@ -5681,13 +5735,13 @@ export class ChannelConnectorService {
|
|
|
5681
5735
|
if (!externalId) return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
|
|
5682
5736
|
storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
|
|
5683
5737
|
}
|
|
5684
|
-
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
|
|
5685
|
-
const booked = await connector.recordRefund(store as ChannelStore, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
|
|
5738
|
+
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0) + shippingAmount;
|
|
5739
|
+
const booked = await connector.recordRefund(store as ChannelStore, exported.remoteOrderId, { lines: storeLines, amount, ...(shippingAmount > 0 ? { shippingAmount } : {}), reason: `Return: ${held.reason}`, restock: true });
|
|
5686
5740
|
if (!booked.ok) return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
|
|
5687
5741
|
const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
|
|
5688
5742
|
await this.db.insert(channelRefundRequests)
|
|
5689
|
-
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
5690
|
-
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
|
|
5743
|
+
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, shippingAmount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
5744
|
+
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, shippingAmount, adjustmentAmount: 0, lines, state: "executed", updatedAt: new Date() } });
|
|
5691
5745
|
const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
|
|
5692
5746
|
return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
|
|
5693
5747
|
}
|
|
@@ -5789,13 +5843,15 @@ export class ChannelConnectorService {
|
|
|
5789
5843
|
remoteStatus.error.retriable === true ? "transient" : "definitive",
|
|
5790
5844
|
);
|
|
5791
5845
|
}
|
|
5792
|
-
|
|
5846
|
+
// `fulfilled` is a received order too: WooCommerce completes virtual and downloadable orders on
|
|
5847
|
+
// arrival, and an export waiting for `confirmed` would wait forever.
|
|
5848
|
+
if (remoteStatus.value.status === "confirmed" || remoteStatus.value.status === "fulfilled") {
|
|
5793
5849
|
return this.transitionExport(
|
|
5794
5850
|
orgId,
|
|
5795
5851
|
created.value.id,
|
|
5796
5852
|
"confirmed",
|
|
5797
5853
|
requireUserId(actor),
|
|
5798
|
-
"Remote order confirmed.",
|
|
5854
|
+
remoteStatus.value.status === "fulfilled" ? "Remote order received and completed by the store." : "Remote order confirmed.",
|
|
5799
5855
|
);
|
|
5800
5856
|
}
|
|
5801
5857
|
if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
|
|
@@ -5828,7 +5884,7 @@ export class ChannelConnectorService {
|
|
|
5828
5884
|
for (const line of selected) {
|
|
5829
5885
|
const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
|
|
5830
5886
|
if (!mapping) return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
|
|
5831
|
-
lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
|
|
5887
|
+
lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice, ...(line.discountAmount > 0 ? { discountAmount: line.discountAmount } : {}) });
|
|
5832
5888
|
}
|
|
5833
5889
|
|
|
5834
5890
|
let email: string | null = null;
|
|
@@ -5876,7 +5932,9 @@ export class ChannelConnectorService {
|
|
|
5876
5932
|
// ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
|
|
5877
5933
|
const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
|
|
5878
5934
|
const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
|
|
5879
|
-
|
|
5935
|
+
// A line's discount travels with the order discount it is a share of, never without it.
|
|
5936
|
+
const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
|
|
5937
|
+
return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
|
|
5880
5938
|
}
|
|
5881
5939
|
|
|
5882
5940
|
async reapExports(input: { definitiveMs: number; transientMs: number }): Promise<{ abandonedCount: number; refundedOrderIds: string[] }> {
|