Files
vmall/apps/api/tests/settlement.rs
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

557 lines
19 KiB
Rust

mod common;
use common::{
add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app,
TestApp,
};
use serial_test::serial;
// Settlement suite: each test builds its own shop, product, and completed
// order, then backdates completion into a closed period. Only rows this test
// created are asserted on; the shared test database is never truncated.
struct Fixture {
admin: String,
owner: String,
customer: String,
shop_id: String,
order_id: String,
item_id: String,
total_minor: i64,
}
/// A paid, shipped, buyer-confirmed order (status `completed`).
async fn completed_order(app: &TestApp, label: &str, price_minor: i64, qty: i32) -> Fixture {
let admin = login_admin(app).await;
let (owner, shop_id, _product, sku_id) =
setup_sellable(app, &admin, label, price_minor, 50).await;
let (customer, _) = register_customer(app, label).await;
add_to_cart(app, &customer, &sku_id, qty).await;
let orders = checkout(app, &customer).await;
let order_id = orders[0]["id"].as_str().unwrap().to_string();
pay(app, &customer, &order_id).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/shop/orders/{order_id}")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
let total_minor = detail["total_minor"].as_i64().unwrap();
let res = client()
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "T1",
"items": [{"order_item_id": item_id, "qty": qty}]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let ship_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
client()
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.status(),
200
);
let res = client()
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
Fixture {
admin,
owner,
customer,
shop_id,
order_id,
item_id,
total_minor,
}
}
/// Move the order's completion instant into a closed period. Refund totals are
/// untouched, so this must run after any after-sale operations.
async fn backdate(app: &TestApp, order_id: &str, date: &str) {
sqlx::query(
"UPDATE orders
SET completed_at = ($2::date + time '12:00') AT TIME ZONE 'UTC',
created_at = ($2::date + time '12:00') AT TIME ZONE 'UTC',
updated_at = ($2::date + time '12:00') AT TIME ZONE 'UTC'
WHERE id = $1::uuid",
)
.bind(order_id)
.bind(date)
.execute(&app.db)
.await
.unwrap();
}
async fn set_rate(app: &TestApp, admin: &str, bps: i64) -> reqwest::Response {
client()
.put(app.url("/api/admin/settlement/commission-rate"))
.bearer_auth(admin)
.json(&serde_json::json!({ "commission_rate_bps": bps }))
.send()
.await
.unwrap()
}
async fn admin_generate(
app: &TestApp,
admin: &str,
shop_id: &str,
kind: &str,
date: &str,
) -> reqwest::Response {
client()
.post(app.url("/api/admin/settlement/statements"))
.bearer_auth(admin)
.json(&serde_json::json!({
"shop_id": shop_id,
"period_kind": kind,
"period_start": date,
}))
.send()
.await
.unwrap()
}
async fn shop_generate(app: &TestApp, token: &str, kind: &str, date: &str) -> reqwest::Response {
client()
.post(app.url("/api/shop/settlement/statements"))
.bearer_auth(token)
.json(&serde_json::json!({ "period_kind": kind, "period_start": date }))
.send()
.await
.unwrap()
}
async fn statement(app: &TestApp, token: &str, id: &str) -> serde_json::Value {
let res = client()
.get(app.url(&format!("/api/admin/settlement/statements/{id}")))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
res.json().await.unwrap()
}
async fn admin_statements(
app: &TestApp,
admin: &str,
shop_id: &str,
) -> serde_json::Value {
let res = client()
.get(app.url("/api/admin/settlement/statements"))
.query(&[("shop_id", shop_id)])
.bearer_auth(admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
res.json().await.unwrap()
}
async fn wallet_available(app: &TestApp, token: &str) -> i64 {
client()
.get(app.url("/api/wallet"))
.bearer_auth(token)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap()["available_minor"]
.as_i64()
.unwrap()
}
async fn entries(app: &TestApp, token: &str) -> Vec<serde_json::Value> {
client()
.get(app.url("/api/wallet/entries"))
.query(&[("per_page", "100")])
.bearer_auth(token)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap()["items"]
.as_array()
.unwrap()
.clone()
}
#[tokio::test]
#[serial]
async fn generation_is_idempotent_and_normalizes_the_period() {
let app = spawn_app().await;
let f = completed_order(&app, "st-idem", 1000, 1).await;
backdate(&app, &f.order_id, "2024-03-10").await;
assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200);
// Any date inside March resolves to the same month period.
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-03-15").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let first: serde_json::Value = res.json().await.unwrap();
assert_eq!(first["period_start"], "2024-03-01");
assert_eq!(first["period_end"], "2024-03-31");
assert_eq!(first["order_count"], 1);
assert_eq!(first["gross_minor"], f.total_minor);
assert_eq!(first["status"], "pending");
let id = first["id"].as_str().unwrap().to_string();
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-03-28").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let again: serde_json::Value = res.json().await.unwrap();
assert_eq!(again["id"], first["id"]);
assert_eq!(again["gross_minor"], first["gross_minor"]);
// Exactly one row exists for the shop and period.
let listed = admin_statements(&app, &f.admin, &f.shop_id).await;
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["id"], first["id"]);
// A week period is normalized to its Monday..Sunday bounds.
assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200);
let res = shop_generate(&app, &f.owner, "week", "2024-05-15").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let week: serde_json::Value = res.json().await.unwrap();
assert_eq!(week["period_start"], "2024-05-13");
assert_eq!(week["period_end"], "2024-05-19");
assert_eq!(week["order_count"], 0);
// A period that has not closed cannot be generated.
let today = chrono::Utc::now().date_naive().to_string();
assert_eq!(
admin_generate(&app, &f.admin, &f.shop_id, "month", &today)
.await
.status(),
409
);
let _ = id;
}
#[tokio::test]
#[serial]
async fn refunds_reduce_the_payable_snapshot() {
let app = spawn_app().await;
let f = completed_order(&app, "st-refund", 1000, 2).await;
assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200);
// A completed after-sale refund of 500 on the order line.
let res = client()
.post(app.url("/api/aftersales"))
.bearer_auth(&f.customer)
.json(&serde_json::json!({
"order_item_id": f.item_id,
"kind": "refund_only",
"reason": {"en": "damaged", "zh": "破损"},
"amount_minor": 500
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let aftersale_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
for action in ["approve", "refund"] {
let res = client()
.post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/{action}")))
.bearer_auth(&f.owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{action}: {:?}", res.text().await);
}
backdate(&app, &f.order_id, "2024-04-10").await;
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-04-01").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let st: serde_json::Value = res.json().await.unwrap();
assert_eq!(st["gross_minor"], f.total_minor);
assert_eq!(st["refund_minor"], 500);
// Integral basis-point arithmetic, floor division.
let net = st["gross_minor"].as_i64().unwrap() - st["refund_minor"].as_i64().unwrap();
let expected_commission = net * 500 / 10_000;
assert_eq!(st["commission_rate_bps"], 500);
assert_eq!(st["commission_minor"], expected_commission);
assert_eq!(st["payable_minor"], net - expected_commission);
let detail = statement(&app, &f.admin, st["id"].as_str().unwrap()).await;
assert_eq!(detail["orders"].as_array().unwrap().len(), 1);
assert_eq!(detail["orders"][0]["order_id"], f.order_id);
assert_eq!(detail["orders"][0]["refund_minor"], 500);
assert_eq!(detail["orders"][0]["gross_minor"], f.total_minor);
}
#[tokio::test]
#[serial]
async fn commission_rate_change_only_affects_new_statements() {
let app = spawn_app().await;
let f = completed_order(&app, "st-rate", 10000, 1).await;
backdate(&app, &f.order_id, "2024-02-10").await;
assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200);
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-02-05").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let st: serde_json::Value = res.json().await.unwrap();
let id = st["id"].as_str().unwrap().to_string();
// gross 10000, no refunds -> commission 500, payable 9500.
assert_eq!(st["gross_minor"], 10000);
assert_eq!(st["commission_rate_bps"], 500);
assert_eq!(st["commission_minor"], 500);
assert_eq!(st["payable_minor"], 9500);
// Raising the platform rate leaves the existing snapshot untouched.
assert_eq!(set_rate(&app, &f.admin, 2000).await.status(), 200);
let after = statement(&app, &f.admin, &id).await;
assert_eq!(after["commission_rate_bps"], 500);
assert_eq!(after["commission_minor"], 500);
assert_eq!(after["payable_minor"], 9500);
// A statement generated afterwards snapshots the new rate.
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-01-05").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let later: serde_json::Value = res.json().await.unwrap();
assert_eq!(later["commission_rate_bps"], 2000);
assert_eq!(later["order_count"], 0);
assert_ne!(later["id"], id);
// Out-of-range rates are rejected.
assert_eq!(set_rate(&app, &f.admin, 10_001).await.status(), 400);
assert_eq!(set_rate(&app, &f.admin, -1).await.status(), 400);
}
#[tokio::test]
#[serial]
async fn confirmation_pays_the_owner_exactly_once() {
let app = spawn_app().await;
let f = completed_order(&app, "st-confirm", 8000, 1).await;
backdate(&app, &f.order_id, "2024-06-10").await;
assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200);
let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-06-01").await;
let st: serde_json::Value = res.json().await.unwrap();
let id = st["id"].as_str().unwrap().to_string();
let payable = st["payable_minor"].as_i64().unwrap();
assert_eq!(payable, 7600);
let before = wallet_available(&app, &f.owner).await;
let res = client()
.post(app.url(&format!("/api/admin/settlement/statements/{id}/confirm")))
.bearer_auth(&f.admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let confirmed: serde_json::Value = res.json().await.unwrap();
assert_eq!(confirmed["status"], "confirmed");
assert!(confirmed["confirmed_at"].is_string());
assert_eq!(wallet_available(&app, &f.owner).await, before + payable);
let payout: Vec<serde_json::Value> = entries(&app, &f.owner)
.await
.into_iter()
.filter(|e| e["reason"] == "settlement_payout")
.collect();
assert_eq!(payout.len(), 1);
assert_eq!(payout[0]["delta_minor"], payable);
assert_eq!(payout[0]["reference_type"], "settlement_statement");
assert_eq!(payout[0]["reference_id"], id);
// A repeat confirmation is a 409 and writes no second entry.
let res = client()
.post(app.url(&format!("/api/admin/settlement/statements/{id}/confirm")))
.bearer_auth(&f.admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "{:?}", res.text().await);
assert_eq!(wallet_available(&app, &f.owner).await, before + payable);
let payouts = entries(&app, &f.owner)
.await
.into_iter()
.filter(|e| e["reason"] == "settlement_payout")
.count();
assert_eq!(payouts, 1);
}
#[tokio::test]
#[serial]
async fn statements_are_shop_scoped() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let a = completed_order(&app, "st-shop-a", 1000, 1).await;
let b = completed_order(&app, "st-shop-b", 2000, 1).await;
backdate(&app, &a.order_id, "2024-07-10").await;
backdate(&app, &b.order_id, "2024-07-11").await;
assert_eq!(set_rate(&app, &admin, 500).await.status(), 200);
let a_st: serde_json::Value = admin_generate(&app, &admin, &a.shop_id, "month", "2024-07-01")
.await
.json()
.await
.unwrap();
let b_st: serde_json::Value = admin_generate(&app, &admin, &b.shop_id, "month", "2024-07-01")
.await
.json()
.await
.unwrap();
assert_ne!(a_st["id"], b_st["id"]);
assert_eq!(a_st["shop_id"], a.shop_id);
assert_eq!(b_st["shop_id"], b.shop_id);
// The merchant list only ever shows the own shop.
let listed: serde_json::Value = client()
.get(app.url("/api/shop/settlement/statements"))
.bearer_auth(&a.owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["id"], a_st["id"]);
assert_eq!(listed["items"][0]["shop_id"], a.shop_id);
// The other shop's statement is invisible, and its detail is a 404.
let res = client()
.get(app.url(&format!(
"/api/shop/settlement/statements/{}",
b_st["id"].as_str().unwrap()
)))
.bearer_auth(&a.owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404, "{:?}", res.text().await);
// A merchant generation request is forced onto the own shop.
let res = client()
.post(app.url("/api/shop/settlement/statements"))
.bearer_auth(&a.owner)
.json(&serde_json::json!({
"shop_id": b.shop_id,
"period_kind": "month",
"period_start": "2024-07-15"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let own: serde_json::Value = res.json().await.unwrap();
assert_eq!(own["shop_id"], a.shop_id);
assert_eq!(own["id"], a_st["id"]);
// Platform admins see both, optionally narrowed to one shop.
let filtered = admin_statements(&app, &admin, &a.shop_id).await;
assert_eq!(filtered["total"], 1);
assert_eq!(filtered["items"][0]["shop_id"], a.shop_id);
assert_eq!(admin_statements(&app, &admin, &b.shop_id).await["total"], 1);
// Customers hold no shop scope at all.
assert_eq!(
client()
.get(app.url("/api/shop/settlement/statements"))
.bearer_auth(&a.customer)
.send()
.await
.unwrap()
.status(),
403
);
}
#[tokio::test]
#[serial]
async fn shop_owner_reads_and_withdraws_from_the_shop_account() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let f = completed_order(&app, "st-owner-wallet", 1000, 1).await;
backdate(&app, &f.order_id, "2024-08-10").await;
assert_eq!(set_rate(&app, &admin, 0).await.status(), 200);
// Zero commission: payable equals gross, paid into the owner's wallet.
let res = admin_generate(&app, &admin, &f.shop_id, "month", "2024-08-01").await;
let st: serde_json::Value = res.json().await.unwrap();
let payable = st["payable_minor"].as_i64().unwrap();
assert_eq!(payable, f.total_minor);
let res = client()
.post(app.url(&format!(
"/api/admin/settlement/statements/{}/confirm",
st["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(wallet_available(&app, &f.owner).await, payable);
// The shop-owner role (not just customers) can withdraw from that account.
let res = client()
.post(app.url("/api/wallet/withdrawals"))
.bearer_auth(&f.owner)
.json(&serde_json::json!({
"amount_minor": payable,
"account_details": { "method": "bank", "account": "shop-acct-1" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let wallet: serde_json::Value = client()
.get(app.url("/api/wallet"))
.bearer_auth(&f.owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(wallet["available_minor"], 0);
assert_eq!(wallet["frozen_minor"], payable);
// Platform rejection returns the shop account to available balance.
let res = client()
.post(app.url(&format!("/api/admin/wallet/withdrawals/{id}/review")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "outcome": "reject" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(wallet_available(&app, &f.owner).await, payable);
}