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
+32
View File
@@ -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<Uuid>,
/// Group-buying activity this order joined, if any.
@@ -198,6 +200,36 @@ pub struct OrderItem {
pub flash_sale_item_id: Option<Uuid>,
}
#[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,
+20
View File
@@ -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<AccountSummary> {
let mut tx = state.db.begin().await?;
+224
View File
@@ -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<AppState> {
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<AftersaleStatus>,
}
// --- customer ---
async fn apply(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<ApplyBody>,
) -> ApiResult<(StatusCode, Json<AftersaleDetailView>)> {
auth.require(&[UserRole::Customer])?;
Ok((
StatusCode::CREATED,
Json(service::apply(&state, auth.id, body).await?),
))
}
async fn list_mine(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<AftersaleRow>>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(service::list_mine(&state, auth.id).await?))
}
async fn get_mine(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleDetailView>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(service::get_mine(&state, auth.id, id).await?))
}
async fn cancel(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(service::cancel(&state, auth.id, id).await?))
}
async fn reopen(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(service::reopen(&state, auth.id, id).await?))
}
async fn return_tracking(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ReturnTrackingBody>,
) -> ApiResult<Json<AftersaleRow>> {
auth.require(&[UserRole::Customer])?;
Ok(Json(
service::submit_return_tracking(&state, auth.id, id, body).await?,
))
}
async fn add_buyer_message(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<MessageBody>,
) -> ApiResult<(StatusCode, Json<AftersaleMessageRow>)> {
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<AppState>,
auth: AuthUser,
Query(q): Query<StatusQuery>,
) -> ApiResult<Json<Vec<AftersaleRow>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleDetailView>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::get_for_shop(&state, shop_id, id).await?))
}
async fn shop_approve(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::approve(&state, shop_id, id).await?))
}
async fn shop_reject(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::reject(&state, shop_id, id).await?))
}
async fn shop_confirm_receipt(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::confirm_receipt(&state, shop_id, id).await?))
}
async fn shop_refund(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleRow>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::refund(&state, shop_id, id).await?))
}
async fn add_merchant_message(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<MessageBody>,
) -> ApiResult<(StatusCode, Json<AftersaleMessageRow>)> {
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<AppState>,
auth: AuthUser,
Query(q): Query<StatusQuery>,
) -> ApiResult<Json<Vec<AftersaleRow>>> {
auth.require_admin()?;
Ok(Json(service::list_all(&state, q.status).await?))
}
async fn admin_get(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AftersaleDetailView>> {
auth.require_admin()?;
Ok(Json(service::get_any(&state, id).await?))
}
#[derive(Deserialize)]
struct ArbitrateBody {
outcome: String,
}
async fn admin_arbitrate(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ArbitrateBody>,
) -> ApiResult<Json<AftersaleRow>> {
auth.require_admin()?;
Ok(Json(service::arbitrate(&state, id, &body.outcome).await?))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod handlers;
pub mod service;
pub use handlers::router;
+590
View File
@@ -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<String>,
pub return_tracking_no: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// 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<Utc>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AftersaleItemSummary {
pub product_name: Value,
pub sku_code: String,
pub image: Option<String>,
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<AftersaleMessageRow>,
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<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct MessageBody {
pub content: Value,
pub evidence: Option<Vec<String>>,
}
#[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<Vec<String>>) -> ApiResult<Value> {
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<AftersaleRow> {
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<AftersaleStatus> = 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<i64> {
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<i64> {
// 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<AftersaleDetailView> {
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<AftersaleDetailView> {
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<Vec<AftersaleRow>> {
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<AftersaleDetailView> {
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<AftersaleRow> {
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<AftersaleRow> {
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<AftersaleRow> {
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<AftersaleMessageRow> {
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<AftersaleStatus>) -> ApiResult<Vec<AftersaleRow>> {
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<AftersaleDetailView> {
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<AftersaleRow> {
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<AftersaleRow> {
shop_transition(state, shop_id, id, &[AftersaleStatus::Pending], AftersaleStatus::Approved).await
}
pub async fn reject(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<AftersaleRow> {
shop_transition(state, shop_id, id, &[AftersaleStatus::Pending], AftersaleStatus::Rejected).await
}
pub async fn confirm_receipt(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<AftersaleRow> {
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<AftersaleRow> {
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<AftersaleRow> {
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<AftersaleStatus>) -> ApiResult<Vec<AftersaleRow>> {
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<AftersaleDetailView> {
let mut conn = state.db.acquire().await?;
detail(&mut conn, id).await
}
pub async fn arbitrate(state: &AppState, id: Uuid, outcome: &str) -> ApiResult<AftersaleRow> {
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())),
}
}
+2
View File
@@ -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<AppState> {
Router::new()
.merge(health::router())
.merge(aftersale::router())
.merge(account::router())
.merge(address::router())
.merge(identity::router())
+1 -1
View File
@@ -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";