feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)
This commit is contained in:
+262
-4
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user