feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)

This commit is contained in:
Chengdong Zhang
2026-09-23 16:56:30 +08:00
parent 93e5a05d48
commit 2c2b54c21c
43 changed files with 4889 additions and 32 deletions
+262 -4
View File
@@ -8,6 +8,13 @@ import type {
Address,
AddressBookEntry,
AddressInput,
Aftersale,
AftersaleApplyBody,
AftersaleDetail,
AftersaleMessage,
AftersaleMessageBody,
AftersaleReturnTrackingBody,
AftersaleStatus,
ApiClient,
AuthTokens,
Cart,
@@ -77,6 +84,9 @@ interface MockState {
coupons: Coupon[];
/** Persisted customer favorites for fixed-adapter reload parity. */
favorites: Favorite[];
/** Persisted customer aftersales for fixed-adapter reload parity. */
aftersales: Aftersale[];
aftersaleMessages: AftersaleMessage[];
/** In-memory points catalog and redemptions for the fixed-data path. */
pointsProducts: IntegralProduct[];
redemptions: IntegralOrder[];
@@ -85,15 +95,17 @@ interface MockState {
orderSeq: number;
invoiceSeq: number;
redemptionSeq: number;
aftersaleSeq: number;
aftersaleMessageSeq: number;
}
// v4: customer favorites joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v4";
// v5: customer aftersales joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v5";
type PersistedState = Pick<
MockState,
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
"orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq"
"aftersales" | "aftersaleMessages" | "orderSeq" | "invoiceSeq" | "addressSeq" |
"favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
@@ -110,6 +122,8 @@ function loadPersisted(): PersistedState | null {
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
@@ -312,6 +326,61 @@ function mockGroupBuying(): GroupBuyingActivityView[] {
});
}
function seedAftersales(orders: Order[]): Aftersale[] {
const item = (orderId: string, itemId: string) => {
const order = orders.find((entry) => entry.id === orderId);
const line = order?.items.find((entry) => entry.id === itemId);
return order && line ? { order, line } : null;
};
const rows: Aftersale[] = [];
const pending = item("o1", "o1-it1");
const approved = item("o1", "o1-it2");
const refunded = item("o2", "o2-it1");
const rejected = item("o2", "o2-it2");
if (pending) rows.push({
id: "as-demo-pending", order_id: pending.order.id, order_item_id: pending.line.id,
shop_id: pending.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "pending",
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty), currency: pending.order.currency,
evidence: ["https://example.com/aftersale/demo-evidence.jpg"], reopened: false,
return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T12:00:00.000Z", updated_at: "2026-09-10T12:00:00.000Z",
});
if (approved) rows.push({
id: "as-demo-approved", order_id: approved.order.id, order_item_id: approved.line.id,
shop_id: approved.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "approved",
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
amount_minor: approved.line.unit_price_minor * approved.line.qty, currency: approved.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-10T13:00:00.000Z", updated_at: "2026-09-11T09:00:00.000Z",
});
if (refunded) rows.push({
id: "as-demo-refunded", order_id: refunded.order.id, order_item_id: refunded.line.id,
shop_id: refunded.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "refunded",
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
amount_minor: refunded.line.unit_price_minor * refunded.line.qty, currency: refunded.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z",
});
if (rejected) rows.push({
id: "as-demo-rejected", order_id: rejected.order.id, order_item_id: rejected.line.id,
shop_id: rejected.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "rejected",
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
amount_minor: rejected.line.unit_price_minor * rejected.line.qty, currency: rejected.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z",
});
return rows;
}
function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
const refunded = aftersales.find((row) => row.id === "as-demo-refunded");
return refunded ? [{
id: "asm-demo-1", aftersale_id: refunded.id, author_role: "buyer", author_id: MOCK_USER.id,
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" }, evidence: [],
created_at: "2026-09-12T17:30:00.000Z",
}] : [];
}
function initialState(): MockState {
const persisted = loadPersisted();
// Coupons and points are session-only, so a restored snapshot re-seeds them.
@@ -326,6 +395,12 @@ function initialState(): MockState {
};
}
const seed = seedOrders(MOCK_USER.id);
const aftersales = seedAftersales(seed.orders);
for (const row of aftersales) {
if (row.status !== "refunded") continue;
const order = seed.orders.find((entry) => entry.id === row.order_id);
if (order) order.refund_total_minor += row.amount_minor;
}
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
user_id: MOCK_USER.id,
@@ -348,6 +423,8 @@ function initialState(): MockState {
addresses: seededAddresses,
coupons: seedCoupons(),
favorites: seedFavorites(),
aftersales,
aftersaleMessages: seedAftersaleMessages(aftersales),
pointsProducts: seedPointsProducts(),
redemptions: [],
addressSeq: 100,
@@ -355,6 +432,8 @@ function initialState(): MockState {
orderSeq: 100,
invoiceSeq: 100,
redemptionSeq: 0,
aftersaleSeq: 100,
aftersaleMessageSeq: 100,
};
}
@@ -409,6 +488,10 @@ export function createMockApi(): ApiClient {
addressSeq: state.addressSeq,
favorites: state.favorites,
favoriteSeq: state.favoriteSeq,
aftersales: state.aftersales,
aftersaleMessages: state.aftersaleMessages,
aftersaleSeq: state.aftersaleSeq,
aftersaleMessageSeq: state.aftersaleMessageSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
@@ -429,6 +512,57 @@ export function createMockApi(): ApiClient {
state.orderSeq += 1;
return `VM20260917${String(state.orderSeq).padStart(3, "0")}`;
}
function findAftersale(id: string): Aftersale {
const row = state.aftersales.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id);
if (!row) throw new ApiError(404, "NOT_FOUND", "Aftersale not found");
return row;
}
function findOrderItem(orderItemId: string): { order: Order; item: Order["items"][number] } | null {
for (const order of state.orders) {
const item = order.items.find((entry) => entry.id === orderItemId);
if (item) return { order, item };
}
return null;
}
function eligibleOrder(order: Order): boolean {
const statusEligible = ["paid", "fulfilling", "shipped", "completed"].includes(order.status);
const windowEnd = Date.parse(order.created_at) + 15 * 24 * 60 * 60 * 1000;
return statusEligible && Date.now() <= windowEnd;
}
function remainingFor(orderItemId: string, lineAmount: number): number {
const refunded = state.aftersales
.filter((row) => row.order_item_id === orderItemId && row.status === "refunded")
.reduce((sum, row) => sum + row.amount_minor, 0);
return Math.max(0, lineAmount - refunded);
}
function copyAftersale(row: Aftersale): Aftersale {
return { ...row, reason: { ...row.reason }, evidence: [...row.evidence] };
}
function aftersaleDetail(id: string): AftersaleDetail {
const row = findAftersale(id);
const found = findOrderItem(row.order_item_id);
if (!found || found.order.id !== row.order_id) throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
return {
...copyAftersale(row),
item: {
product_name: { ...found.item.product_name },
sku_code: found.item.sku_code,
image: found.item.image,
unit_price_minor: found.item.unit_price_minor,
qty: found.item.qty,
},
messages: state.aftersaleMessages
.filter((message) => message.aftersale_id === row.id)
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.map((message) => ({ ...message, content: { ...message.content }, evidence: [...message.evidence] })),
remaining_refundable_minor: remainingFor(row.order_item_id, found.item.unit_price_minor * found.item.qty),
};
}
return {
register: () => Promise.resolve(tokens()),
@@ -573,6 +707,7 @@ export function createMockApi(): ApiClient {
currency,
total_minor: subtotal - discount,
discount_minor: discount,
refund_total_minor: 0,
coupon_id: couponId ?? null,
group_activity_id: groupHere ? activity.id : null,
group_id: groupHere ? `g-${Date.now()}` : null,
@@ -687,6 +822,119 @@ export function createMockApi(): ApiClient {
},
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
applyForAftersale: async (body: AftersaleApplyBody): Promise<AftersaleDetail> => {
if (body.kind !== "refund_only" && body.kind !== "return_refund") {
throw new ApiError(400, "BAD_REQUEST", "Invalid aftersale kind");
}
if (!body.reason.en?.trim() || !body.reason.zh?.trim()) {
throw new ApiError(400, "BAD_REQUEST", "reason needs non-empty en and zh");
}
if (!Number.isInteger(body.amount_minor) || body.amount_minor <= 0) {
throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive");
}
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
const found = findOrderItem(body.order_item_id);
if (!found || found.order.user_id !== MOCK_USER.id) throw new ApiError(404, "NOT_FOUND", "order item");
if (!eligibleOrder(found.order)) throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
const active = state.aftersales.some((row) => row.order_item_id === body.order_item_id &&
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status));
if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
const remaining = remainingFor(body.order_item_id, found.item.unit_price_minor * found.item.qty);
if (body.amount_minor > remaining) throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
state.aftersaleSeq += 1;
const now = new Date().toISOString();
const row: Aftersale = {
id: `as-${state.aftersaleSeq}`,
order_id: found.order.id,
order_item_id: body.order_item_id,
shop_id: found.order.shop_id,
user_id: MOCK_USER.id,
kind: body.kind,
status: "pending",
reason: { en: body.reason.en.trim(), zh: body.reason.zh.trim() },
amount_minor: body.amount_minor,
currency: found.order.currency,
evidence,
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: now,
updated_at: now,
};
state.aftersales = [row, ...state.aftersales];
persist();
return aftersaleDetail(row.id);
},
listMyAftersales: async (): Promise<Aftersale[]> => state.aftersales
.filter((row) => row.user_id === MOCK_USER.id)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(copyAftersale),
getAftersale: async (id: string): Promise<AftersaleDetail> => aftersaleDetail(id),
cancelAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (!["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status)) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be cancelled");
}
row.status = "cancelled";
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
reopenAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (row.status !== "rejected" || row.reopened) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be reopened");
}
row.status = "pending";
row.reopened = true;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
submitAftersaleReturnTracking: async (id: string, body: AftersaleReturnTrackingBody): Promise<Aftersale> => {
const carrier = body.carrier.trim();
const trackingNo = body.tracking_no.trim();
if (!carrier || !trackingNo) throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
const row = findAftersale(id);
if (row.kind !== "return_refund") throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
if (row.status !== "approved") throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
row.status = "buyer_shipping";
row.return_carrier = carrier;
row.return_tracking_no = trackingNo;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
addAftersaleMessage: async (id: string, body: AftersaleMessageBody): Promise<AftersaleMessage> => {
const row = findAftersale(id);
const en = body.content.en?.trim() ?? "";
const zh = body.content.zh?.trim() ?? "";
if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text");
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
state.aftersaleMessageSeq += 1;
const now = new Date().toISOString();
const message: AftersaleMessage = {
id: `asm-${state.aftersaleMessageSeq}`,
aftersale_id: row.id,
author_role: "buyer",
author_id: MOCK_USER.id,
content: { en: en || zh, zh: zh || en },
evidence,
created_at: now,
};
state.aftersaleMessages.push(message);
row.updated_at = now;
persist();
return { ...message, content: { ...message.content }, evidence: [...message.evidence] };
},
// Mirror of the seeded storefront-content rows, so the home page renders
// identically when every domain is configured to fixed data.
@@ -974,6 +1222,13 @@ export function createMockApi(): ApiClient {
createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(),
updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(),
deleteGroupBuyingActivity: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
approveAftersale: (_id: string) => unsupported(),
rejectAftersale: (_id: string) => unsupported(),
confirmAftersaleReceipt: (_id: string) => unsupported(),
refundAftersale: (_id: string) => unsupported(),
addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(),
},
admin: {
listUsers: () => unsupported(),
@@ -997,6 +1252,9 @@ export function createMockApi(): ApiClient {
listPointsRedemptions: (_page?: number) => unsupported(),
fulfillRedemption: (_id: string) => unsupported(),
cancelRedemption: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(),
},
};
}
+2 -1
View File
@@ -705,6 +705,7 @@ export function seedOrders(userId: string): MockOrderSeed {
currency: BASE_CURRENCY,
total_minor: total,
discount_minor: 0,
refund_total_minor: 0,
coupon_id: null,
group_activity_id: null,
group_id: null,
@@ -714,7 +715,7 @@ export function seedOrders(userId: string): MockOrderSeed {
};
};
const orders: Order[] = [
mk("o1", "VM20260910001", [0], "shipped", "2026-09-10T10:00:00.000Z"),
mk("o1", "VM20260910001", [0, 1], "shipped", "2026-09-10T10:00:00.000Z"),
mk("o2", "VM20260912002", [2, 6], "completed", "2026-09-12T15:30:00.000Z"),
mk("o3", "VM20260915003", [20], "pending_payment", "2026-09-15T09:12:00.000Z"),
];