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
+47
View File
@@ -1,5 +1,13 @@
import type {
AccountSummary,
Aftersale,
AftersaleApplyBody,
AftersaleArbitration,
AftersaleDetail,
AftersaleMessage,
AftersaleMessageBody,
AftersaleReturnTrackingBody,
AftersaleStatus,
Address,
AddressBookEntry,
AuthTokens,
@@ -231,6 +239,16 @@ export interface ApiClient {
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
listMyCoupons(): Promise<Coupon[]>;
claimCoupon(templateId: string): Promise<Coupon>;
applyForAftersale(body: AftersaleApplyBody): Promise<AftersaleDetail>;
listMyAftersales(): Promise<Aftersale[]>;
getAftersale(id: string): Promise<AftersaleDetail>;
cancelAftersale(id: string): Promise<Aftersale>;
reopenAftersale(id: string): Promise<Aftersale>;
submitAftersaleReturnTracking(
id: string,
body: AftersaleReturnTrackingBody,
): Promise<Aftersale>;
addAftersaleMessage(id: string, body: AftersaleMessageBody): Promise<AftersaleMessage>;
/** Public points catalog: published products only. */
listPointsProducts(): Promise<IntegralProduct[]>;
listMyRedemptions(): Promise<IntegralOrder[]>;
@@ -280,6 +298,13 @@ export interface ApiClient {
body: GroupBuyingActivityInput,
): Promise<GroupBuyingActivity>;
deleteGroupBuyingActivity(id: string): Promise<void>;
listAftersales(status?: AftersaleStatus): Promise<Aftersale[]>;
getAftersale(id: string): Promise<AftersaleDetail>;
approveAftersale(id: string): Promise<Aftersale>;
rejectAftersale(id: string): Promise<Aftersale>;
confirmAftersaleReceipt(id: string): Promise<Aftersale>;
refundAftersale(id: string): Promise<Aftersale>;
addAftersaleMessage(id: string, body: AftersaleMessageBody): Promise<AftersaleMessage>;
};
admin: {
listUsers(page?: number): Promise<Paged<User>>;
@@ -298,6 +323,9 @@ export interface ApiClient {
kind: K,
items: ContentInputByKind[K],
): Promise<HomeContent>;
listAftersales(status?: AftersaleStatus): Promise<Aftersale[]>;
getAftersale(id: string): Promise<AftersaleDetail>;
arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise<Aftersale>;
};
}
@@ -353,6 +381,14 @@ export function createApi(opts: ApiClientOptions): ApiClient {
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
listMyCoupons: () => r("GET", "/me/coupons"),
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
applyForAftersale: (body) => r("POST", "/aftersales", body),
listMyAftersales: () => r("GET", "/aftersales"),
getAftersale: (id) => r("GET", `/aftersales/${id}`),
cancelAftersale: (id) => r("POST", `/aftersales/${id}/cancel`),
reopenAftersale: (id) => r("POST", `/aftersales/${id}/reopen`),
submitAftersaleReturnTracking: (id, body) =>
r("POST", `/aftersales/${id}/return-tracking`, body),
addAftersaleMessage: (id, body) => r("POST", `/aftersales/${id}/messages`, body),
listPointsProducts: () => r("GET", "/points/products"),
listMyRedemptions: () => r("GET", "/points/redemptions"),
redeemPoints: (body) => r("POST", "/points/redemptions", body),
@@ -397,6 +433,13 @@ export function createApi(opts: ApiClientOptions): ApiClient {
updateGroupBuyingActivity: (id, body) =>
r("PUT", `/shop/group-buying-activities/${id}`, body),
deleteGroupBuyingActivity: (id) => r("DELETE", `/shop/group-buying-activities/${id}`),
listAftersales: (status) => r("GET", "/shop/aftersales", undefined, { status }),
getAftersale: (id) => r("GET", `/shop/aftersales/${id}`),
approveAftersale: (id) => r("POST", `/shop/aftersales/${id}/approve`),
rejectAftersale: (id) => r("POST", `/shop/aftersales/${id}/reject`),
confirmAftersaleReceipt: (id) => r("POST", `/shop/aftersales/${id}/confirm-receipt`),
refundAftersale: (id) => r("POST", `/shop/aftersales/${id}/refund`),
addAftersaleMessage: (id, body) => r("POST", `/shop/aftersales/${id}/messages`, body),
},
admin: {
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
@@ -417,6 +460,10 @@ export function createApi(opts: ApiClientOptions): ApiClient {
/** Replaces the whole ordered brand list. */
getBrands: () => r("GET", "/brands"),
replaceBrands: (items) => r("PUT", "/admin/brands", items),
listAftersales: (status) => r("GET", "/admin/aftersales", undefined, { status }),
getAftersale: (id) => r("GET", `/admin/aftersales/${id}`),
arbitrateAftersale: (id, outcome) =>
r("POST", `/admin/aftersales/${id}/arbitrate`, { outcome }),
listPointsProducts: () => r("GET", "/admin/points/products"),
createPointsProduct: (body) => r("POST", "/admin/points/products", body),
updatePointsProduct: (id, body) => r("PUT", `/admin/points/products/${id}`, body),
+82
View File
@@ -159,6 +159,8 @@ export interface Order {
total_minor: number;
/** Server-calculated coupon discount, already applied to `total_minor`. */
discount_minor: number;
/** Authoritative sum of completed refunds, integer minor units. */
refund_total_minor: number;
/** The coupon this order redeemed, or null. */
coupon_id: string | null;
/** The group-buying activity this order joined, or null. */
@@ -618,3 +620,83 @@ export type ShopProfileSelfInput = Omit<
"score_rating" | "score_agreement" | "score_service" | "score_speed"
>;
// ---- aftersale ----
export type AftersaleKind = "refund_only" | "return_refund";
export type AftersaleStatus =
| "pending"
| "approved"
| "rejected"
| "buyer_shipping"
| "merchant_confirmed"
| "refunded"
| "cancelled";
export interface Aftersale {
id: string;
order_id: string;
order_item_id: string;
shop_id: string;
user_id: string;
kind: AftersaleKind;
status: AftersaleStatus;
reason: LocalizedText;
amount_minor: number;
currency: string;
evidence: string[];
reopened: boolean;
return_carrier: string | null;
return_tracking_no: string | null;
created_at: string;
updated_at: string;
}
export type AftersaleAuthorRole = "buyer" | "merchant" | "platform";
/** One append-only bilateral message. */
export interface AftersaleMessage {
id: string;
aftersale_id: string;
author_role: AftersaleAuthorRole;
author_id: string;
content: LocalizedText;
evidence: string[];
created_at: string;
}
export interface AftersaleItemSummary {
product_name: LocalizedText;
sku_code: string;
image: string | null;
unit_price_minor: number;
qty: number;
}
/** Detail payload: the aftersale plus its item snapshot, messages, and the
* remaining refundable minor-unit balance for the order line. */
export interface AftersaleDetail extends Aftersale {
item: AftersaleItemSummary;
messages: AftersaleMessage[];
remaining_refundable_minor: number;
}
export interface AftersaleApplyBody {
order_item_id: string;
kind: AftersaleKind;
reason: LocalizedText;
amount_minor: number;
evidence?: string[];
}
export interface AftersaleMessageBody {
content: LocalizedText;
evidence?: string[];
}
export interface AftersaleReturnTrackingBody {
carrier: string;
tracking_no: string;
}
export type AftersaleArbitration = "refund" | "reject";