From 2c2b54c21cca8fe683c7573fe821c8fd0fdf9345 Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Wed, 23 Sep 2026 16:56:30 +0800 Subject: [PATCH] feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds) --- apps/admin/app.vue | 1 + apps/admin/locales-extra.ts | 114 ++ apps/admin/pages/aftersales.vue | 419 ++++++ apps/api/migrations/0016_aftersales.sql | 55 + apps/api/src/models.rs | 32 + apps/api/src/modules/account/service.rs | 20 + apps/api/src/modules/aftersale/handlers.rs | 224 +++ apps/api/src/modules/aftersale/mod.rs | 4 + apps/api/src/modules/aftersale/service.rs | 590 ++++++++ apps/api/src/modules/mod.rs | 2 + apps/api/src/modules/order/repo.rs | 2 +- apps/api/tests/aftersales.rs | 535 +++++++ apps/mall/locales/user.ts | 90 ++ apps/mall/mock/api.ts | 266 +++- apps/mall/mock/data.ts | 3 +- apps/mall/nuxt.config.ts | 2 +- apps/mall/pages/user/aftersales/[id].vue | 217 +++ apps/mall/pages/user/aftersales/apply.vue | 110 ++ apps/mall/pages/user/aftersales/index.vue | 73 + apps/mall/pages/user/orders/[id].vue | 18 +- apps/mall/plugins/api.ts | 13 +- apps/shop-admin/app.vue | 1 + apps/shop-admin/locales-extra.ts | 100 ++ apps/shop-admin/pages/aftersales/[id].vue | 264 ++++ apps/shop-admin/pages/aftersales/index.vue | 96 ++ openspec/MIGRATION-PLAN.md | 2 +- .../proposal.md | 0 .../specs/aftersale/spec.md | 0 .../specs/frontend-admin/spec.md | 0 .../specs/frontend-mall/spec.md | 0 .../specs/frontend-shop-admin/spec.md | 0 .../specs/order/spec.md | 0 .../tasks.md | 34 +- openspec/specs/aftersale/spec.md | 83 ++ openspec/specs/frontend-admin/spec.md | 15 + openspec/specs/frontend-mall/spec.md | 23 + openspec/specs/frontend-shop-admin/spec.md | 19 + openspec/specs/order/spec.md | 29 +- package-lock.json | 1322 +++++++++++++++++ package.json | 4 +- packages/shared/src/api.ts | 47 + packages/shared/src/types.ts | 82 + skills-lock.json | 10 + 43 files changed, 4889 insertions(+), 32 deletions(-) create mode 100644 apps/admin/pages/aftersales.vue create mode 100644 apps/api/migrations/0016_aftersales.sql create mode 100644 apps/api/src/modules/aftersale/handlers.rs create mode 100644 apps/api/src/modules/aftersale/mod.rs create mode 100644 apps/api/src/modules/aftersale/service.rs create mode 100644 apps/api/tests/aftersales.rs create mode 100644 apps/mall/pages/user/aftersales/[id].vue create mode 100644 apps/mall/pages/user/aftersales/apply.vue create mode 100644 apps/mall/pages/user/aftersales/index.vue create mode 100644 apps/shop-admin/pages/aftersales/[id].vue create mode 100644 apps/shop-admin/pages/aftersales/index.vue rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/proposal.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/specs/aftersale/spec.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/specs/frontend-admin/spec.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/specs/frontend-mall/spec.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/specs/frontend-shop-admin/spec.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/specs/order/spec.md (100%) rename openspec/changes/{add-aftersale-refunds => archive/2026-09-23-add-aftersale-refunds}/tasks.md (72%) create mode 100644 openspec/specs/aftersale/spec.md create mode 100644 package-lock.json create mode 100644 skills-lock.json diff --git a/apps/admin/app.vue b/apps/admin/app.vue index d681897..e1afc8b 100644 --- a/apps/admin/app.vue +++ b/apps/admin/app.vue @@ -15,6 +15,7 @@ const navItems = [ { to: "/users", label: "nav.users" }, { to: "/shops", label: "nav.shops" }, { to: "/orders", label: "nav.orders" }, + { to: "/aftersales", label: "nav.aftersales" }, { to: "/content", label: "nav.content" }, { to: "/brands", label: "nav.brands" }, { to: "/currencies", label: "nav.currencies" }, diff --git a/apps/admin/locales-extra.ts b/apps/admin/locales-extra.ts index 04f669c..57cc3a1 100644 --- a/apps/admin/locales-extra.ts +++ b/apps/admin/locales-extra.ts @@ -10,6 +10,7 @@ export const enExtra = { pointsOrders: "Point redemptions", content: "Content", brands: "Brands", + aftersales: "After-sales", }, admin: { dashboardTitle: "Platform overview", @@ -88,6 +89,62 @@ export const enExtra = { brandNameRequired: "Both language names are required.", brandSlugDuplicate: "Duplicate slug.", brandsSaved: "Brands saved.", + aftersaleFilterShop: "Shop ID", + aftersaleShopIdPlaceholder: "shop id", + aftersaleBuyerIdPlaceholder: "buyer id", + aftersaleFilterBuyer: "Buyer ID", + aftersaleKind: "Type", + aftersaleAmount: "Amount", + aftersaleId: "Application", + aftersaleShop: "Shop", + aftersaleBuyer: "Buyer", + aftersaleDetail: "After-sale detail", + aftersaleView: "Detail", + aftersaleClose: "Close", + aftersaleCreated: "Created", + aftersaleUpdated: "Updated", + aftersaleReason: "Reason", + aftersaleEvidence: "Evidence", + aftersaleNoEvidence: "No evidence attached", + aftersaleItem: "Order item", + aftersaleOrder: "Order", + aftersaleOrderTotal: "Order total", + aftersaleOrderRefunded: "Order refunded total", + aftersaleRemaining: "Remaining refundable", + aftersaleReturnTracking: "Return tracking", + aftersaleMessages: "Messages", + aftersaleNoMessages: "No messages yet", + aftersaleReopened: "Reopened once", + aftersaleArbitrate: "Arbitration", + aftersaleArbitrateRefund: "Arbitrate: refund", + aftersaleArbitrateReject: "Arbitrate: reject", + aftersaleConfirmRefund: "Grant a terminal refund to the buyer? This completes the refund and cannot be undone.", + aftersaleConfirmReject: "Reject this dispute terminally? Only the buyer's one-time reopen can resume it.", + aftersaleArbitratedRefund: "Arbitration complete: refunded.", + aftersaleArbitratedReject: "Arbitration complete: rejected.", + aftersaleOutcomeRefunded: "Refunded: the customer ledger is credited and the order refund total updated.", + aftersaleOutcomeRejected: "Rejected: only the buyer's one-time reopen can resume it.", + aftersaleOutcomeCancelled: "Cancelled by the buyer.", + aftersaleConflict: "The application state changed; refresh and try again", + aftersaleOrderNotFound: "Order not found in the order list.", + aftersaleKinds: { + refund_only: "Refund only", + return_refund: "Return & refund", + }, + aftersaleStatuses: { + pending: "Pending review", + approved: "Approved", + rejected: "Rejected", + buyer_shipping: "Buyer shipping", + merchant_confirmed: "Return received", + refunded: "Refunded", + cancelled: "Cancelled", + }, + aftersaleRoles: { + buyer: "Buyer", + merchant: "Merchant", + platform: "Platform", + }, }, } as const; @@ -101,6 +158,7 @@ export const zhExtra = { pointsOrders: "积分兑换单", content: "内容", brands: "品牌", + aftersales: "售后仲裁", }, admin: { dashboardTitle: "平台概览", @@ -179,5 +237,61 @@ export const zhExtra = { brandNameRequired: "中英文名称均为必填项。", brandSlugDuplicate: "别名重复。", brandsSaved: "品牌已保存。", + aftersaleFilterShop: "店铺 ID", + aftersaleShopIdPlaceholder: "店铺 ID", + aftersaleBuyerIdPlaceholder: "买家 ID", + aftersaleFilterBuyer: "买家 ID", + aftersaleKind: "类型", + aftersaleAmount: "金额", + aftersaleId: "售后单", + aftersaleShop: "店铺", + aftersaleBuyer: "买家", + aftersaleDetail: "售后详情", + aftersaleView: "详情", + aftersaleClose: "收起", + aftersaleCreated: "创建时间", + aftersaleUpdated: "更新时间", + aftersaleReason: "原因", + aftersaleEvidence: "凭证", + aftersaleNoEvidence: "无凭证", + aftersaleItem: "订单商品", + aftersaleOrder: "订单", + aftersaleOrderTotal: "订单总额", + aftersaleOrderRefunded: "订单已退款总额", + aftersaleRemaining: "剩余可退金额", + aftersaleReturnTracking: "退货物流", + aftersaleMessages: "留言记录", + aftersaleNoMessages: "暂无留言", + aftersaleReopened: "已重新申请", + aftersaleArbitrate: "仲裁", + aftersaleArbitrateRefund: "仲裁退款", + aftersaleArbitrateReject: "仲裁驳回", + aftersaleConfirmRefund: "确定仲裁退款给买家吗?退款将立即完成且不可撤销。", + aftersaleConfirmReject: "确定仲裁驳回吗?仅买家一次重新申请可恢复。", + aftersaleArbitratedRefund: "仲裁完成:已退款。", + aftersaleArbitratedReject: "仲裁完成:已驳回。", + aftersaleOutcomeRefunded: "已退款:买家账户已入账,订单退款总额已更新。", + aftersaleOutcomeRejected: "已驳回:仅买家一次重新申请可恢复。", + aftersaleOutcomeCancelled: "买家已取消。", + aftersaleConflict: "售后单状态已变化,请刷新后重试", + aftersaleOrderNotFound: "订单列表中未找到该订单。", + aftersaleKinds: { + refund_only: "仅退款", + return_refund: "退货退款", + }, + aftersaleStatuses: { + pending: "待处理", + approved: "已通过", + rejected: "已驳回", + buyer_shipping: "买家退货中", + merchant_confirmed: "商家已收货", + refunded: "已退款", + cancelled: "已取消", + }, + aftersaleRoles: { + buyer: "买家", + merchant: "商家", + platform: "平台", + }, }, } as const; diff --git a/apps/admin/pages/aftersales.vue b/apps/admin/pages/aftersales.vue new file mode 100644 index 0000000..3e07cca --- /dev/null +++ b/apps/admin/pages/aftersales.vue @@ -0,0 +1,419 @@ + + + diff --git a/apps/api/migrations/0016_aftersales.sql b/apps/api/migrations/0016_aftersales.sql new file mode 100644 index 0000000..5898772 --- /dev/null +++ b/apps/api/migrations/0016_aftersales.sql @@ -0,0 +1,55 @@ +CREATE TYPE aftersale_kind AS ENUM ('refund_only', 'return_refund'); + +CREATE TYPE aftersale_status AS ENUM ( + 'pending', + 'approved', + 'rejected', + 'buyer_shipping', + 'merchant_confirmed', + 'refunded', + 'cancelled' +); + +-- Authoritative per-order refund total; every increment is a guarded UPDATE. +ALTER TABLE orders + ADD COLUMN refund_total_minor BIGINT NOT NULL DEFAULT 0 + CHECK (refund_total_minor >= 0); + +CREATE TABLE aftersales ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders (id), + order_item_id UUID NOT NULL REFERENCES order_items (id), + shop_id UUID NOT NULL REFERENCES shops (id), + user_id UUID NOT NULL REFERENCES users (id), + kind aftersale_kind NOT NULL, + status aftersale_status NOT NULL DEFAULT 'pending', + reason JSONB NOT NULL, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + evidence JSONB NOT NULL DEFAULT '[]', + reopened BOOLEAN NOT NULL DEFAULT false, + return_carrier TEXT, + return_tracking_no TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- At most one active (non-terminal) aftersale per order item. +CREATE UNIQUE INDEX aftersales_one_active_per_item + ON aftersales (order_item_id) + WHERE status NOT IN ('refunded', 'rejected', 'cancelled'); +CREATE INDEX idx_aftersales_user ON aftersales (user_id); +CREATE INDEX idx_aftersales_shop ON aftersales (shop_id); +CREATE INDEX idx_aftersales_order ON aftersales (order_id); + +-- Append-only bilateral message log; no UPDATE/DELETE paths exist. +CREATE TABLE aftersale_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + aftersale_id UUID NOT NULL REFERENCES aftersales (id) ON DELETE CASCADE, + author_role TEXT NOT NULL CHECK (author_role IN ('buyer', 'merchant', 'platform')), + author_id UUID NOT NULL REFERENCES users (id), + content JSONB NOT NULL, + evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_aftersale_messages_aftersale ON aftersale_messages (aftersale_id); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index c88c266..16b8571 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -173,6 +173,8 @@ pub struct Order { pub total_minor: i64, /// Realized coupon discount, converted to the order currency server-side. pub discount_minor: i64, + /// Authoritative sum of completed after-sale refunds. + pub refund_total_minor: i64, /// The coupon this order redeemed, if any. pub coupon_id: Option, /// Group-buying activity this order joined, if any. @@ -198,6 +200,36 @@ pub struct OrderItem { pub flash_sale_item_id: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "aftersale_kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum AftersaleKind { + RefundOnly, + ReturnRefund, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "aftersale_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum AftersaleStatus { + Pending, + Approved, + Rejected, + BuyerShipping, + MerchantConfirmed, + Refunded, + Cancelled, +} + +impl AftersaleStatus { + pub fn is_terminal(self) -> bool { + matches!( + self, + AftersaleStatus::Refunded | AftersaleStatus::Rejected | AftersaleStatus::Cancelled + ) + } +} + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct Shipment { pub id: Uuid, diff --git a/apps/api/src/modules/account/service.rs b/apps/api/src/modules/account/service.rs index c46a302..8b40d06 100644 --- a/apps/api/src/modules/account/service.rs +++ b/apps/api/src/modules/account/service.rs @@ -19,6 +19,26 @@ pub async fn ensure_accounts(tx: &mut PgConnection, user_id: Uuid) -> ApiResult< repo::ensure_accounts(tx, user_id, &base).await } +/// Create one zero-balance monetary row when missing. Refunds credit whatever +/// currency the order used, even one the customer never held before. +pub async fn ensure_monetary_account( + tx: &mut PgConnection, + user_id: Uuid, + kind: AccountKind, + currency: &str, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO customer_accounts (user_id, kind, currency) VALUES ($1, $2, $3) + ON CONFLICT (user_id, kind, currency) WHERE currency IS NOT NULL DO NOTHING", + ) + .bind(user_id) + .bind(kind) + .bind(currency) + .execute(&mut *tx) + .await?; + Ok(()) +} + /// `GET /api/me/stats`. There is no public balance mutation endpoint. pub async fn summary(state: &AppState, user_id: Uuid) -> ApiResult { let mut tx = state.db.begin().await?; diff --git a/apps/api/src/modules/aftersale/handlers.rs b/apps/api/src/modules/aftersale/handlers.rs new file mode 100644 index 0000000..2aec87a --- /dev/null +++ b/apps/api/src/modules/aftersale/handlers.rs @@ -0,0 +1,224 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::{AftersaleStatus, UserRole}; +use crate::state::AppState; + +use super::service::{ + self, AftersaleDetailView, AftersaleMessageRow, AftersaleRow, ApplyBody, MessageBody, + ReturnTrackingBody, +}; + +pub fn router() -> Router { + Router::new() + .route("/aftersales", post(apply).get(list_mine)) + .route("/aftersales/{id}", get(get_mine)) + .route("/aftersales/{id}/cancel", post(cancel)) + .route("/aftersales/{id}/reopen", post(reopen)) + .route("/aftersales/{id}/return-tracking", post(return_tracking)) + .route("/aftersales/{id}/messages", post(add_buyer_message)) + .route("/shop/aftersales", get(shop_list)) + .route("/shop/aftersales/{id}", get(shop_get)) + .route("/shop/aftersales/{id}/approve", post(shop_approve)) + .route("/shop/aftersales/{id}/reject", post(shop_reject)) + .route("/shop/aftersales/{id}/confirm-receipt", post(shop_confirm_receipt)) + .route("/shop/aftersales/{id}/refund", post(shop_refund)) + .route("/shop/aftersales/{id}/messages", post(add_merchant_message)) + .route("/admin/aftersales", get(admin_list)) + .route("/admin/aftersales/{id}", get(admin_get)) + .route("/admin/aftersales/{id}/arbitrate", post(admin_arbitrate)) +} + +#[derive(Deserialize)] +struct StatusQuery { + status: Option, +} + +// --- customer --- + +async fn apply( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require(&[UserRole::Customer])?; + Ok(( + StatusCode::CREATED, + Json(service::apply(&state, auth.id, body).await?), + )) +} + +async fn list_mine( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require(&[UserRole::Customer])?; + Ok(Json(service::list_mine(&state, auth.id).await?)) +} + +async fn get_mine( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require(&[UserRole::Customer])?; + Ok(Json(service::get_mine(&state, auth.id, id).await?)) +} + +async fn cancel( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require(&[UserRole::Customer])?; + Ok(Json(service::cancel(&state, auth.id, id).await?)) +} + +async fn reopen( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require(&[UserRole::Customer])?; + Ok(Json(service::reopen(&state, auth.id, id).await?)) +} + +async fn return_tracking( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require(&[UserRole::Customer])?; + Ok(Json( + service::submit_return_tracking(&state, auth.id, id, body).await?, + )) +} + +async fn add_buyer_message( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require(&[UserRole::Customer])?; + // Ownership check happens through the customer-scoped read. + service::get_mine(&state, auth.id, id).await?; + Ok(( + StatusCode::CREATED, + Json(service::add_message(&state, id, auth.id, "buyer", body).await?), + )) +} + +// --- merchant --- + +async fn shop_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id, q.status).await?)) +} + +async fn shop_get( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::get_for_shop(&state, shop_id, id).await?)) +} + +async fn shop_approve( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::approve(&state, shop_id, id).await?)) +} + +async fn shop_reject( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::reject(&state, shop_id, id).await?)) +} + +async fn shop_confirm_receipt( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::confirm_receipt(&state, shop_id, id).await?)) +} + +async fn shop_refund( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::refund(&state, shop_id, id).await?)) +} + +async fn add_merchant_message( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let shop_id = auth.require_shop()?; + service::get_for_shop(&state, shop_id, id).await?; + Ok(( + StatusCode::CREATED, + Json(service::add_message(&state, id, auth.id, "merchant", body).await?), + )) +} + +// --- platform admin --- + +async fn admin_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_all(&state, q.status).await?)) +} + +async fn admin_get( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::get_any(&state, id).await?)) +} + +#[derive(Deserialize)] +struct ArbitrateBody { + outcome: String, +} + +async fn admin_arbitrate( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::arbitrate(&state, id, &body.outcome).await?)) +} diff --git a/apps/api/src/modules/aftersale/mod.rs b/apps/api/src/modules/aftersale/mod.rs new file mode 100644 index 0000000..755adf0 --- /dev/null +++ b/apps/api/src/modules/aftersale/mod.rs @@ -0,0 +1,4 @@ +pub mod handlers; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/aftersale/service.rs b/apps/api/src/modules/aftersale/service.rs new file mode 100644 index 0000000..bf6ac3a --- /dev/null +++ b/apps/api/src/modules/aftersale/service.rs @@ -0,0 +1,590 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{AftersaleKind, AftersaleStatus, OrderStatus}; +use crate::modules::account; +use crate::state::AppState; + +/// Days after the order's last update during which items stay eligible. +pub const AFTERSALE_WINDOW_DAYS: i64 = 15; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AftersaleRow { + pub id: Uuid, + pub order_id: Uuid, + pub order_item_id: Uuid, + pub shop_id: Uuid, + pub user_id: Uuid, + pub kind: AftersaleKind, + pub status: AftersaleStatus, + pub reason: Value, + pub amount_minor: i64, + pub evidence: Value, + pub reopened: bool, + pub return_carrier: Option, + pub return_tracking_no: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Joined from the owning order for display and ledger currency. + pub currency: String, +} + +const COLS: &str = "a.id, a.order_id, a.order_item_id, a.shop_id, a.user_id, a.kind, a.status, + a.reason, a.amount_minor, a.evidence, a.reopened, a.return_carrier, a.return_tracking_no, + a.created_at, a.updated_at, o.currency"; +const FROM: &str = "aftersales a JOIN orders o ON o.id = a.order_id"; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AftersaleMessageRow { + pub id: Uuid, + pub aftersale_id: Uuid, + pub author_role: String, + pub author_id: Uuid, + pub content: Value, + pub evidence: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AftersaleItemSummary { + pub product_name: Value, + pub sku_code: String, + pub image: Option, + pub unit_price_minor: i64, + pub qty: i32, +} + +#[derive(Debug, Serialize)] +pub struct AftersaleDetailView { + #[serde(flatten)] + pub aftersale: AftersaleRow, + pub item: AftersaleItemSummary, + pub messages: Vec, + pub remaining_refundable_minor: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ApplyBody { + pub order_item_id: Uuid, + pub kind: AftersaleKind, + pub reason: Value, + pub amount_minor: i64, + pub evidence: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct MessageBody { + pub content: Value, + pub evidence: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct ReturnTrackingBody { + pub carrier: String, + pub tracking_no: String, +} + +fn localized(label: &Value, field: &str) -> ApiResult<()> { + let ok = ["en", "zh"].iter().all(|code| { + label + .get(code) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest(format!( + "{field} needs non-empty en and zh" + ))); + } + Ok(()) +} + +/// A message needs at least one non-empty locale; buyers write one language. +fn message_content(content: &Value) -> ApiResult<()> { + let ok = ["en", "zh"].iter().any(|code| { + content + .get(code) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest("content needs text".into())); + } + Ok(()) +} + +fn validate_evidence(evidence: &Option>) -> ApiResult { + let urls = evidence.clone().unwrap_or_default(); + if urls.iter().any(|u| u.trim().is_empty()) { + return Err(ApiError::BadRequest("evidence URLs must be non-empty".into())); + } + Ok(Value::from(urls)) +} + +async fn fetch(db: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, AftersaleRow>(&format!("SELECT {COLS} FROM {FROM} WHERE a.id = $1")) + .bind(id) + .fetch_optional(&mut *db) + .await? + .ok_or_else(|| ApiError::NotFound("aftersale".into())) +} +async fn transition( + tx: &mut PgConnection, + id: Uuid, + from: &[AftersaleStatus], + to: AftersaleStatus, +) -> ApiResult<()> { + let from_values: Vec = from.to_vec(); + let result = sqlx::query( + "UPDATE aftersales SET status = $1, updated_at = now() + WHERE id = $2 AND status = ANY($3)", + ) + .bind(to) + .bind(id) + .bind(&from_values) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "aftersale is not in the expected status".into(), + )); + } + Ok(()) +} + +/// Paid-and-not-fully-refunded amount for the order line. +async fn line_paid_minor(db: &mut PgConnection, order_item_id: Uuid) -> ApiResult { + sqlx::query_scalar::<_, i64>( + "SELECT unit_price_minor * qty FROM order_items WHERE id = $1", + ) + .bind(order_item_id) + .fetch_optional(&mut *db) + .await? + .ok_or_else(|| ApiError::NotFound("order item".into())) +} + +async fn refunded_minor(db: &mut PgConnection, order_item_id: Uuid) -> ApiResult { + // SUM(bigint) yields NUMERIC; cast back so the i64 decode succeeds. + Ok(sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount_minor), 0)::bigint FROM aftersales + WHERE order_item_id = $1 AND status = 'refunded'", + ) + .bind(order_item_id) + .fetch_one(&mut *db) + .await?) +} + +async fn detail(db: &mut PgConnection, id: Uuid) -> ApiResult { + let aftersale = fetch(db, id).await?; + let item = sqlx::query_as::<_, AftersaleItemSummary>( + "SELECT product_name, sku_code, image, unit_price_minor, qty FROM order_items WHERE id = $1", + ) + .bind(aftersale.order_item_id) + .fetch_one(&mut *db) + .await?; + let messages = sqlx::query_as::<_, AftersaleMessageRow>( + "SELECT id, aftersale_id, author_role, author_id, content, evidence, created_at + FROM aftersale_messages WHERE aftersale_id = $1 ORDER BY created_at, id", + ) + .bind(id) + .fetch_all(&mut *db) + .await?; + let remaining = line_paid_minor(db, aftersale.order_item_id).await? + - refunded_minor(db, aftersale.order_item_id).await?; + Ok(AftersaleDetailView { + aftersale, + item, + messages, + remaining_refundable_minor: remaining, + }) +} + + +// --- customer flows --- + +pub async fn apply( + state: &AppState, + user_id: Uuid, + body: ApplyBody, +) -> ApiResult { + localized(&body.reason, "reason")?; + if body.amount_minor <= 0 { + return Err(ApiError::BadRequest("amount_minor must be positive".into())); + } + let evidence = validate_evidence(&body.evidence)?; + + let mut tx = state.db.begin().await?; + // Lock the order row so concurrent applications on its lines serialize. + let order = sqlx::query_as::<_, crate::models::Order>( + "SELECT * FROM orders WHERE id = (SELECT order_id FROM order_items WHERE id = $1) + AND user_id = $2 FOR UPDATE", + ) + .bind(body.order_item_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("order item".into()))?; + + let eligible = matches!( + order.status, + OrderStatus::Paid | OrderStatus::Fulfilling | OrderStatus::Shipped | OrderStatus::Completed + ); + let window_end = order.updated_at + chrono::Duration::days(AFTERSALE_WINDOW_DAYS); + if !eligible || Utc::now() > window_end { + return Err(ApiError::Conflict( + "order item is not eligible for after-sale".into(), + )); + } + + let remaining = line_paid_minor(&mut tx, body.order_item_id).await? + - refunded_minor(&mut tx, body.order_item_id).await?; + if body.amount_minor > remaining { + return Err(ApiError::Conflict( + "amount exceeds the remaining refundable balance".into(), + )); + } + + let id: Uuid = sqlx::query_scalar( + "INSERT INTO aftersales (order_id, order_item_id, shop_id, user_id, kind, reason, + amount_minor, evidence) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id", + ) + .bind(order.id) + .bind(body.order_item_id) + .bind(order.shop_id) + .bind(user_id) + .bind(body.kind) + .bind(&body.reason) + .bind(body.amount_minor) + .bind(&evidence) + .fetch_one(&mut *tx) + .await + .map_err(|e| crate::error::unique_conflict(e, "an active aftersale already exists for this item"))?; + + let view = detail(&mut tx, id).await?; + tx.commit().await?; + Ok(view) +} + +pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, AftersaleRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE a.user_id = $1 ORDER BY a.created_at DESC" + )) + .bind(user_id) + .fetch_all(&state.db) + .await?) +} + +pub async fn get_mine(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut conn = state.db.acquire().await?; + let row = fetch(&mut conn, id).await?; + if row.user_id != user_id { + return Err(ApiError::NotFound("aftersale".into())); + } + detail(&mut conn, id).await +} + +pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.user_id != user_id { + return Err(ApiError::NotFound("aftersale".into())); + } + // Any non-terminal state may be cancelled before refund completion. + transition( + &mut tx, + id, + &[ + AftersaleStatus::Pending, + AftersaleStatus::Approved, + AftersaleStatus::BuyerShipping, + AftersaleStatus::MerchantConfirmed, + ], + AftersaleStatus::Cancelled, + ) + .await?; + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +pub async fn reopen(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.user_id != user_id { + return Err(ApiError::NotFound("aftersale".into())); + } + let result = sqlx::query( + "UPDATE aftersales SET status = 'pending', reopened = true, updated_at = now() + WHERE id = $1 AND status = 'rejected' AND NOT reopened", + ) + .bind(id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict("aftersale cannot be reopened".into())); + } + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +pub async fn submit_return_tracking( + state: &AppState, + user_id: Uuid, + id: Uuid, + body: ReturnTrackingBody, +) -> ApiResult { + if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() { + return Err(ApiError::BadRequest("carrier and tracking_no are required".into())); + } + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.user_id != user_id { + return Err(ApiError::NotFound("aftersale".into())); + } + if row.kind != AftersaleKind::ReturnRefund { + return Err(ApiError::Conflict("not a return-refund aftersale".into())); + } + let result = sqlx::query( + "UPDATE aftersales SET status = 'buyer_shipping', return_carrier = $2, + return_tracking_no = $3, updated_at = now() + WHERE id = $1 AND status = 'approved' AND kind = 'return_refund'", + ) + .bind(id) + .bind(body.carrier.trim()) + .bind(body.tracking_no.trim()) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "aftersale is not awaiting return shipping".into(), + )); + } + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +// --- shared message flow --- + +pub async fn add_message( + state: &AppState, + id: Uuid, + author_id: Uuid, + author_role: &str, + body: MessageBody, +) -> ApiResult { + message_content(&body.content)?; + let evidence = validate_evidence(&body.evidence)?; + let message = sqlx::query_as::<_, AftersaleMessageRow>( + "INSERT INTO aftersale_messages (aftersale_id, author_role, author_id, content, evidence) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, aftersale_id, author_role, author_id, content, evidence, created_at", + ) + .bind(id) + .bind(author_role) + .bind(author_id) + .bind(&body.content) + .bind(&evidence) + .fetch_one(&state.db) + .await?; + Ok(message) +} + +// --- merchant flows --- + +pub async fn list_for_shop(state: &AppState, shop_id: Uuid, status: Option) -> ApiResult> { + Ok(sqlx::query_as::<_, AftersaleRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE a.shop_id = $1 + AND ($2::aftersale_status IS NULL OR a.status = $2) ORDER BY a.created_at DESC" + )) + .bind(shop_id) + .bind(status) + .fetch_all(&state.db) + .await?) +} + +pub async fn get_for_shop(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let mut conn = state.db.acquire().await?; + let row = fetch(&mut conn, id).await?; + if row.shop_id != shop_id { + return Err(ApiError::NotFound("aftersale".into())); + } + detail(&mut conn, id).await +} + +async fn shop_transition( + state: &AppState, + shop_id: Uuid, + id: Uuid, + from: &[AftersaleStatus], + to: AftersaleStatus, +) -> ApiResult { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.shop_id != shop_id { + return Err(ApiError::NotFound("aftersale".into())); + } + transition(&mut tx, id, from, to).await?; + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +pub async fn approve(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + shop_transition(state, shop_id, id, &[AftersaleStatus::Pending], AftersaleStatus::Approved).await +} + +pub async fn reject(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + shop_transition(state, shop_id, id, &[AftersaleStatus::Pending], AftersaleStatus::Rejected).await +} + +pub async fn confirm_receipt(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.shop_id != shop_id { + return Err(ApiError::NotFound("aftersale".into())); + } + transition(&mut tx, id, &[AftersaleStatus::BuyerShipping], AftersaleStatus::MerchantConfirmed) + .await?; + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) +} + +/// The one guarded path to `refunded`: status flip + order total bump + +/// ledger credit in a single transaction; retries hit the status guard. +async fn complete_refund(tx: &mut PgConnection, id: Uuid) -> ApiResult { + let row = fetch(&mut *tx, id).await?; + // refund_only completes from approved; return_refund from merchant_confirmed. + let allowed = match row.kind { + AftersaleKind::RefundOnly => &[AftersaleStatus::Approved][..], + AftersaleKind::ReturnRefund => &[AftersaleStatus::MerchantConfirmed][..], + }; + + let result = sqlx::query( + "UPDATE aftersales SET status = 'refunded', updated_at = now() + WHERE id = $1 AND status = ANY($2)", + ) + .bind(id) + .bind(allowed) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "aftersale is not refundable in its current status".into(), + )); + } + let result = sqlx::query( + "UPDATE orders SET refund_total_minor = refund_total_minor + $2, updated_at = now() + WHERE id = $1 AND refund_total_minor + $2 <= total_minor", + ) + .bind(row.order_id) + .bind(row.amount_minor) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "refund would exceed the order total".into(), + )); + } + account::service::ensure_monetary_account( + tx, + row.user_id, + crate::models::AccountKind::Available, + &row.currency, + ) + .await?; + account::service::credit( + tx, + row.user_id, + crate::models::AccountKind::Available, + Some(&row.currency), + row.amount_minor, + "aftersale_refund", + Some(("aftersale", row.id)), + ) + .await?; + fetch(tx, id).await +} + +pub async fn refund(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + if row.shop_id != shop_id { + return Err(ApiError::NotFound("aftersale".into())); + } + let out = complete_refund(&mut tx, id).await?; + tx.commit().await?; + // Hook point: refund_completed side effects (notifications) attach here. + tracing::info!(aftersale_id = %out.id, "refund_completed"); + Ok(out) +} + +// --- platform arbitration --- + +pub async fn list_all(state: &AppState, status: Option) -> ApiResult> { + Ok(sqlx::query_as::<_, AftersaleRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE ($1::aftersale_status IS NULL OR a.status = $1) + ORDER BY a.created_at DESC" + )) + .bind(status) + .fetch_all(&state.db) + .await?) +} + +pub async fn get_any(state: &AppState, id: Uuid) -> ApiResult { + let mut conn = state.db.acquire().await?; + detail(&mut conn, id).await +} + +pub async fn arbitrate(state: &AppState, id: Uuid, outcome: &str) -> ApiResult { + match outcome { + "reject" => { + let mut tx = state.db.begin().await?; + transition(&mut tx, id, &[AftersaleStatus::Pending], AftersaleStatus::Rejected).await?; + let out = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(out) + } + "refund" => { + let mut tx = state.db.begin().await?; + let row = fetch(&mut tx, id).await?; + // Drive the application to the kind's refundable state first: + // arbitration substitutes for merchant processing and return shipping. + match row.kind { + AftersaleKind::RefundOnly if row.status == AftersaleStatus::Pending => { + transition(&mut tx, id, &[AftersaleStatus::Pending], AftersaleStatus::Approved) + .await?; + } + AftersaleKind::ReturnRefund => match row.status { + AftersaleStatus::Pending => { + transition(&mut tx, id, &[AftersaleStatus::Pending], AftersaleStatus::Approved).await?; + transition(&mut tx, id, &[AftersaleStatus::Approved], AftersaleStatus::MerchantConfirmed).await?; + } + AftersaleStatus::Approved | AftersaleStatus::BuyerShipping => { + transition( + &mut tx, + id, + &[AftersaleStatus::Approved, AftersaleStatus::BuyerShipping], + AftersaleStatus::MerchantConfirmed, + ) + .await?; + } + _ => {} + }, + _ => {} + } + let out = complete_refund(&mut tx, id).await?; + tx.commit().await?; + tracing::info!(aftersale_id = %out.id, "refund_completed"); + Ok(out) + } + _ => Err(ApiError::BadRequest("outcome must be refund or reject".into())), + } +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index a76c530..c32c949 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod aftersale; pub mod address; pub mod billing; pub mod brand; @@ -25,6 +26,7 @@ use crate::state::AppState; pub fn api_router() -> Router { Router::new() .merge(health::router()) + .merge(aftersale::router()) .merge(account::router()) .merge(address::router()) .merge(identity::router()) diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index c898aad..6447a48 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -9,7 +9,7 @@ use crate::models::{Order, OrderItem, OrderStatus}; use super::dto::{OrderScope, OrderView}; const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor, - discount_minor, coupon_id, group_activity_id, group_id, shipping_address, + discount_minor, refund_total_minor, coupon_id, group_activity_id, group_id, shipping_address, created_at, updated_at"; const ORDER_ITEM_COLS: &str = "id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty, flash_sale_item_id"; diff --git a/apps/api/tests/aftersales.rs b/apps/api/tests/aftersales.rs new file mode 100644 index 0000000..749cbce --- /dev/null +++ b/apps/api/tests/aftersales.rs @@ -0,0 +1,535 @@ +mod common; + +use common::{ + checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer, + setup_sellable, spawn_app, TestApp, +}; +use serial_test::serial; + +/// After-sale suite: every test builds its own shop/product/order fixtures and +/// only asserts on ids it created. + +/// Buy one paid order line; returns (customer_token, order, order_item_id). +async fn paid_order_line(app: &TestApp, label: &str, price_minor: i64, qty: i32) -> (String, serde_json::Value, String) { + let admin = login_admin(app).await; + let (_owner, _shop, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 100).await; + let (customer, _) = register_customer(app, label).await; + common::add_to_cart(app, &customer, &sku_id, qty).await; + let orders = checkout(app, &customer).await; + let order = orders.into_iter().next().unwrap(); + pay(app, &customer, order["id"].as_str().unwrap()).await; + let detail = client() + .get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap()))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + (customer, detail, item_id) +} + +async fn apply( + app: &TestApp, + customer: &str, + item_id: &str, + kind: &str, + amount: i64, +) -> reqwest::Response { + client() + .post(app.url("/api/aftersales")) + .bearer_auth(customer) + .json(&serde_json::json!({ + "order_item_id": item_id, + "kind": kind, + "reason": {"en": "not as described", "zh": "与描述不符"}, + "amount_minor": amount, + "evidence": ["https://example.com/evidence.png"] + })) + .send() + .await + .unwrap() +} + +async fn shop_token(app: &TestApp, shop_id: &str) -> String { + let admin = login_admin(app).await; + make_shop_owner(app, &admin, shop_id).await +} + +async fn shop_action(app: &TestApp, token: &str, id: &str, action: &str) -> reqwest::Response { + client() + .post(app.url(&format!("/api/shop/aftersales/{id}/{action}"))) + .bearer_auth(token) + .send() + .await + .unwrap() +} + +async fn balance(app: &TestApp, customer: &str) -> i64 { + client() + .get(app.url("/api/me/stats")) + .bearer_auth(customer) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["balance_minor"] + .as_i64() + .unwrap() +} + +#[tokio::test] +#[serial] +async fn refund_only_lifecycle_credits_balance_and_order_total() { + let app = spawn_app().await; + let (customer, order, item_id) = paid_order_line(&app, "as-full", 1000, 2).await; + let before = balance(&app, &customer).await; + + let res = apply(&app, &customer, &item_id, "refund_only", 500).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let created: serde_json::Value = res.json().await.unwrap(); + assert_eq!(created["status"], "pending"); + assert_eq!(created["evidence"][0], "https://example.com/evidence.png"); + assert_eq!(created["remaining_refundable_minor"], 2000); + let id = created["id"].as_str().unwrap().to_string(); + + // Shop approves and completes the refund. + let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await; + assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200); + let res = shop_action(&app, &shop, &id, "refund").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["status"], "refunded"); + + assert_eq!(balance(&app, &customer).await, before + 500); + let after: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap()))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(after["refund_total_minor"], 500); + + // Retrying the completion is idempotent: guarded transition rejects it. + assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409); + assert_eq!(balance(&app, &customer).await, before + 500); + + // Remaining refundable balance reflects the completed refund. + let res = client() + .get(app.url(&format!("/api/aftersales/{id}"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + let status = res.status(); + let detail: serde_json::Value = res.json().await.unwrap(); + assert_eq!(status, 200, "{detail}"); + assert_eq!(detail["remaining_refundable_minor"], 1500); +} + +#[tokio::test] +#[serial] +async fn amount_beyond_line_balance_is_rejected() { + let app = spawn_app().await; + let (customer, _order, item_id) = paid_order_line(&app, "as-amount", 1000, 2).await; + + let res = apply(&app, &customer, &item_id, "refund_only", 2001).await; + assert_eq!(res.status(), 409, "over the line paid amount must be 409"); + let res = apply(&app, &customer, &item_id, "refund_only", 0).await; + assert_eq!(res.status(), 400, "zero amount must be 400"); +} + +#[tokio::test] +#[serial] +async fn one_active_aftersale_per_item_and_history_does_not_block() { + let app = spawn_app().await; + let (customer, _order, item_id) = paid_order_line(&app, "as-unique", 1000, 1).await; + + assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 201); + assert_eq!( + apply(&app, &customer, &item_id, "refund_only", 100).await.status(), + 409, + "a second active application must conflict" + ); + + // Cancel frees the line for a new application. + let list: serde_json::Value = client() + .get(app.url("/api/aftersales")) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let id = list[0]["id"].as_str().unwrap(); + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/cancel"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 201); +} + +#[tokio::test] +#[serial] +async fn return_refund_flow_with_guarded_transitions() { + let app = spawn_app().await; + let (customer, order, item_id) = paid_order_line(&app, "as-return", 800, 1).await; + let res = apply(&app, &customer, &item_id, "return_refund", 800).await; + assert_eq!(res.status(), 201); + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await; + + // Illegal: refund before approval and receipt. + assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409); + assert_eq!(shop_action(&app, &shop, &id, "confirm-receipt").await.status(), 409); + + assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200); + + // Buyer submits return tracking. + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/return-tracking"))) + .bearer_auth(&customer) + .json(&serde_json::json!({ "carrier": "UPS", "tracking_no": "1Z999" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let row: serde_json::Value = res.json().await.unwrap(); + assert_eq!(row["status"], "buyer_shipping"); + assert_eq!(row["return_tracking_no"], "1Z999"); + + // Refund still blocked before merchant confirmation. + assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409); + + let before = balance(&app, &customer).await; + assert_eq!(shop_action(&app, &shop, &id, "confirm-receipt").await.status(), 200); + let res = shop_action(&app, &shop, &id, "refund").await; + assert_eq!(res.status(), 200); + assert_eq!(res.json::().await.unwrap()["status"], "refunded"); + assert_eq!(balance(&app, &customer).await, before + 800); +} + +#[tokio::test] +#[serial] +async fn reject_reopen_once_then_terminal() { + let app = spawn_app().await; + let (customer, order, item_id) = paid_order_line(&app, "as-reopen", 500, 1).await; + let res = apply(&app, &customer, &item_id, "refund_only", 500).await; + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await; + + assert_eq!(shop_action(&app, &shop, &id, "reject").await.status(), 200); + + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/reopen"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(res.json::().await.unwrap()["status"], "pending"); + + // Second rejection then second reopen must fail. + assert_eq!(shop_action(&app, &shop, &id, "reject").await.status(), 200); + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/reopen"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "reopen is a one-time appeal"); +} + +#[tokio::test] +#[serial] +async fn ownership_and_role_boundaries() { + let app = spawn_app().await; + let (customer, order, item_id) = paid_order_line(&app, "as-owner", 500, 1).await; + let res = apply(&app, &customer, &item_id, "refund_only", 100).await; + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Another customer sees nothing. + let (other, _) = register_customer(&app, "as-owner-other").await; + let res = client() + .get(app.url(&format!("/api/aftersales/{id}"))) + .bearer_auth(&other) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/cancel"))) + .bearer_auth(&other) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + // A different shop cannot read or act on it. + let admin = login_admin(&app).await; + let other_shop_id = create_shop(&app, &admin, "as-owner-other-shop").await; + let other_shop = make_shop_owner(&app, &admin, &other_shop_id).await; + let res = client() + .get(app.url(&format!("/api/shop/aftersales/{id}"))) + .bearer_auth(&other_shop) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + assert_eq!(shop_action(&app, &other_shop, &id, "approve").await.status(), 404); + + // Customers cannot use shop endpoints; platform admin can list all. + let res = client() + .get(app.url("/api/shop/aftersales")) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); + let res = client() + .get(app.url("/api/admin/aftersales")) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let all: serde_json::Value = res.json().await.unwrap(); + assert!(all.as_array().unwrap().iter().any(|a| a["id"] == id)); + + let _ = order; +} + +#[tokio::test] +#[serial] +async fn bilateral_messages_are_scoped() { + let app = spawn_app().await; + let (customer, order, item_id) = paid_order_line(&app, "as-msg", 500, 1).await; + let res = apply(&app, &customer, &item_id, "refund_only", 100).await; + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await; + + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/messages"))) + .bearer_auth(&customer) + .json(&serde_json::json!({ "content": {"en": "please hurry"} })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201); + let res = client() + .post(app.url(&format!("/api/shop/aftersales/{id}/messages"))) + .bearer_auth(&shop) + .json(&serde_json::json!({ "content": {"zh": "马上处理"} })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201); + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/aftersales/{id}"))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let messages = detail["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["author_role"], "buyer"); + assert_eq!(messages[1]["author_role"], "merchant"); + + // Empty message rejected. + let res = client() + .post(app.url(&format!("/api/aftersales/{id}/messages"))) + .bearer_auth(&customer) + .json(&serde_json::json!({ "content": {"en": " ", "zh": ""} })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); +} + +#[tokio::test] +#[serial] +async fn platform_arbitration_refunds_or_rejects() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (customer, _order, item_id) = paid_order_line(&app, "as-arb", 900, 1).await; + let res = apply(&app, &customer, &item_id, "refund_only", 400).await; + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Grant a refund straight from pending. + let before = balance(&app, &customer).await; + let res = client() + .post(app.url(&format!("/api/admin/aftersales/{id}/arbitrate"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "outcome": "refund" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["status"], "refunded"); + assert_eq!(balance(&app, &customer).await, before + 400); + + // Reject arbitration is terminal for a new application. + let res = apply(&app, &customer, &item_id, "refund_only", 100).await; + let status = res.status(); + let body = res.text().await.unwrap(); + assert_eq!(status, 201, "{body}"); + let id2 = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let res = client() + .post(app.url(&format!("/api/admin/aftersales/{id2}/arbitrate"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "outcome": "reject" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let res = client() + .post(app.url(&format!("/api/admin/aftersales/{id2}/arbitrate"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "outcome": "refund" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "rejected aftersale cannot be refunded"); +} + +#[tokio::test] +#[serial] +async fn unpaid_or_expired_orders_are_ineligible() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "as-window", 500, 10).await; + let (customer, _) = register_customer(&app, "as-window").await; + common::add_to_cart(&app, &customer, &sku_id, 1).await; + let orders = checkout(&app, &customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + + // Unpaid order line is not eligible. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{order_id}"))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 409); + + // Paid but outside the window is not eligible either. + pay(&app, &customer, &order_id).await; + sqlx::query("UPDATE orders SET updated_at = now() - interval '30 days' WHERE id = $1::uuid") + .bind(&order_id) + .execute(&app.db) + .await + .unwrap(); + assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 409); +} + +#[tokio::test] +#[serial] +async fn refund_in_order_currency_creates_missing_account() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "as-fx", 1000, 10).await; + let (customer, user_id) = register_customer(&app, "as-fx").await; + common::add_to_cart(&app, &customer, &sku_id, 1).await; + + // Checkout in JPY while the customer only holds the registration currency. + let res = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(&customer) + .json(&serde_json::json!({ + "shipping_address": { + "recipient": "FX Recipient", + "phone": "123456", + "country": "US", + "region": "CA", + "city": "San Jose", + "line1": "1 Test Way", + "postal_code": "95131" + }, + "currency": "JPY" + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let order = res.json::>().await.unwrap().remove(0); + pay(&app, &customer, order["id"].as_str().unwrap()).await; + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap()))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + let amount = detail["total_minor"].as_i64().unwrap(); + + let res = apply(&app, &customer, &item_id, "refund_only", amount).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await; + assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200); + let res = shop_action(&app, &shop, &id, "refund").await; + assert_eq!(res.status(), 200, "refund must create the JPY account: {:?}", res.text().await); + + // The refund landed on a lazily created JPY account with a ledger entry. + let balance: i64 = sqlx::query_scalar( + "SELECT balance_minor FROM customer_accounts + WHERE user_id = $1::uuid AND kind = 'available' AND currency = 'JPY'", + ) + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(balance, amount); + let entries: i64 = sqlx::query_scalar( + "SELECT count(*) FROM customer_account_entries e + JOIN customer_accounts c ON c.id = e.account_id + WHERE c.user_id = $1::uuid AND e.reason = 'aftersale_refund'", + ) + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(entries, 1); +} diff --git a/apps/mall/locales/user.ts b/apps/mall/locales/user.ts index 0f41eac..06e32e7 100644 --- a/apps/mall/locales/user.ts +++ b/apps/mall/locales/user.ts @@ -19,6 +19,51 @@ export default { pendingReceipt: "To receive", completed: "Completed", afterSale: "After-sale", + aftersalesTitle: "My after-sales", + aftersaleDetail: "After-sale details", + aftersaleApply: "Apply for after-sale", + aftersaleType: "Request type", + refundOnly: "Refund only", + returnRefund: "Return and refund", + aftersaleReason: "Reason", + aftersaleAmount: "Refund amount", + aftersaleRemaining: "Remaining refundable", + aftersaleEvidence: "Evidence URLs", + aftersaleEvidenceHint: "One URL per line", + submitAftersale: "Submit application", + aftersaleStatus: "Status", + aftersaleCreatedAt: "Created", + aftersaleOrder: "Order", + aftersaleItem: "Item", + aftersaleMessages: "Messages", + aftersaleMessagePlaceholder: "Write a message", + sendMessage: "Send message", + cancelAftersale: "Cancel application", + reopenAftersale: "Reopen application", + returnShipping: "Return shipping", + submitReturnTracking: "Submit tracking", + aftersaleRefunded: "Refund completed", + aftersaleRefundResult: "Refunded amount", + aftersaleCarrier: "Carrier", + aftersaleTrackingNo: "Tracking number", + aftersaleReasonHint: "Enter the same reason in both languages", + aftersaleAmountHint: "Enter a major-unit amount; it cannot exceed the remaining balance.", + aftersaleLoadFailed: "Unable to load after-sale data.", + aftersaleSubmitFailed: "Unable to submit the after-sale request.", + aftersaleActionFailed: "Unable to update this after-sale request.", + noAftersales: "No after-sale requests yet.", + applyAftersaleForItem: "Apply for after-sale", + aftersaleViewEvidence: "View evidence", + buyer: "Buyer", + merchant: "Merchant", + platform: "Platform", + aftersaleStatus_pending: "Pending", + aftersaleStatus_approved: "Approved", + aftersaleStatus_rejected: "Rejected", + aftersaleStatus_buyer_shipping: "Buyer shipping", + aftersaleStatus_merchant_confirmed: "Merchant confirmed", + aftersaleStatus_refunded: "Refunded", + aftersaleStatus_cancelled: "Cancelled", recentOrders: "Recent orders", favoriteProducts: "Favorite products", viewAll: "View all", @@ -118,6 +163,51 @@ export default { pendingReceipt: "待收货", completed: "已完成", afterSale: "售后中", + aftersalesTitle: "我的售后", + aftersaleDetail: "售后详情", + aftersaleApply: "申请售后", + aftersaleType: "售后类型", + refundOnly: "仅退款", + returnRefund: "退货退款", + aftersaleReason: "申请原因", + aftersaleAmount: "退款金额", + aftersaleRemaining: "剩余可退", + aftersaleEvidence: "凭证链接", + aftersaleEvidenceHint: "每行填写一个 URL", + submitAftersale: "提交申请", + aftersaleStatus: "状态", + aftersaleCreatedAt: "申请时间", + aftersaleOrder: "订单", + aftersaleItem: "商品", + aftersaleMessages: "留言", + aftersaleMessagePlaceholder: "请输入留言", + sendMessage: "发送留言", + cancelAftersale: "取消申请", + reopenAftersale: "重新申诉", + returnShipping: "退货物流", + submitReturnTracking: "提交物流", + aftersaleRefunded: "退款已完成", + aftersaleRefundResult: "退款金额", + aftersaleCarrier: "承运商", + aftersaleTrackingNo: "物流单号", + aftersaleReasonHint: "中英文输入相同内容即可", + aftersaleAmountHint: "请输入主币种金额,不得超过剩余可退金额。", + aftersaleLoadFailed: "售后数据加载失败。", + aftersaleSubmitFailed: "售后申请提交失败。", + aftersaleActionFailed: "售后操作失败。", + noAftersales: "暂无售后申请。", + applyAftersaleForItem: "申请售后", + aftersaleViewEvidence: "查看凭证", + buyer: "买家", + merchant: "商家", + platform: "平台", + aftersaleStatus_pending: "待审核", + aftersaleStatus_approved: "已同意", + aftersaleStatus_rejected: "已拒绝", + aftersaleStatus_buyer_shipping: "买家寄回中", + aftersaleStatus_merchant_confirmed: "商家已收货", + aftersaleStatus_refunded: "已退款", + aftersaleStatus_cancelled: "已取消", recentOrders: "最近订单", favoriteProducts: "收藏的宝贝", viewAll: "查看全部", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index af2bb5b..69b0038 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -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 => { + 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 => 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(id), + + cancelAftersale: async (id: string): Promise => { + 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 => { + 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 => { + 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 => { + 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(), }, }; } diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index 2e29162..6694d82 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -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"), ]; diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 837f0ef..42ed67c 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -12,7 +12,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites", "aftersales"], appName: "mall", }, }, diff --git a/apps/mall/pages/user/aftersales/[id].vue b/apps/mall/pages/user/aftersales/[id].vue new file mode 100644 index 0000000..9c7cdb5 --- /dev/null +++ b/apps/mall/pages/user/aftersales/[id].vue @@ -0,0 +1,217 @@ + + +