- payment domain: payments/memberships/attempts/receipts/refunds/commands/events, legacy-demo provenance (0024), checkout header-hash idempotency (0025) - wallet collection all-or-none across shop orders; group-wide cancellation; checkout cutover, status-only payOrder removed - Stripe crypto-only Checkout adapter, raw-body HMAC webhooks, durable command worker with leases - original-method refunds with receipt/allocation budgets; refund_pending/refund_failed aftersale states - admin channel CRUD/revisions/audit/verify + operations list/detail/reconcile; secret-ref allowlist (VMALL_PAYMENT_*), env-fixed demo/sandbox/live - mall/mobile checkout pay surfaces, admin console pages; shared contract updated - pre-launch leftovers recorded in docs/TBD-payments.md cargo test -p vmall-api green twice; four frontend builds green; browser smoke: mall wallet checkout paid, admin channels/operations verified
747 lines
26 KiB
Rust
747 lines
26 KiB
Rust
mod common;
|
|
|
|
use common::{add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp};
|
|
use serial_test::serial;
|
|
use uuid::Uuid;
|
|
|
|
fn address() -> serde_json::Value {
|
|
serde_json::json!({
|
|
"recipient": "Payment Test", "phone": "1", "country": "US",
|
|
"region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131"
|
|
})
|
|
}
|
|
|
|
async fn checkout_payment(app: &TestApp, token: &str) -> serde_json::Value {
|
|
let res = client()
|
|
.post(app.url("/api/orders/checkout"))
|
|
.bearer_auth(token)
|
|
.header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
|
|
.json(&serde_json::json!({
|
|
"shipping_address": address(),
|
|
"currency": "USD",
|
|
"coupon_by_shop": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
async fn recharge(app: &TestApp, token: &str, amount_minor: i64) {
|
|
let res = client()
|
|
.post(app.url("/api/wallet/recharges"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({ "amount_minor": amount_minor }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 201, "recharge: {:?}", res.text().await);
|
|
}
|
|
|
|
async fn wallet_balance(app: &TestApp, token: &str) -> i64 {
|
|
let res = client()
|
|
.get(app.url("/api/wallet"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200);
|
|
res.json::<serde_json::Value>().await.unwrap()["available_minor"]
|
|
.as_i64()
|
|
.unwrap()
|
|
}
|
|
|
|
async fn methods(app: &TestApp, token: &str, payment_id: &str) -> Vec<serde_json::Value> {
|
|
let res = client()
|
|
.get(app.url(&format!("/api/payments/{payment_id}/methods")))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "methods: {:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
fn wallet_method(methods: &[serde_json::Value]) -> serde_json::Value {
|
|
methods
|
|
.iter()
|
|
.find(|m| m["adapter"] == "wallet")
|
|
.cloned()
|
|
.expect("wallet method must be available")
|
|
}
|
|
|
|
async fn attempt(
|
|
app: &TestApp,
|
|
token: &str,
|
|
payment_id: &str,
|
|
channel_id: &str,
|
|
key: &str,
|
|
) -> reqwest::Response {
|
|
client()
|
|
.post(app.url(&format!("/api/payments/{payment_id}/attempts")))
|
|
.bearer_auth(token)
|
|
.header("Idempotency-Key", key)
|
|
.json(&serde_json::json!({ "channel_id": channel_id, "return_surface": "mall" }))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn order_status(app: &TestApp, order_id: &str) -> String {
|
|
sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1")
|
|
.bind(Uuid::parse_str(order_id).unwrap())
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn checkout_returns_one_payment_for_all_shop_orders() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-split-a", 1000, 5).await;
|
|
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-split-b", 2000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-split").await;
|
|
add_to_cart(&app, &buyer, &sku_a, 2).await;
|
|
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
|
|
|
let result = checkout_payment(&app, &buyer).await;
|
|
assert!(result.is_object(), "checkout must return a payment wrapper");
|
|
let payment_id = result["payment_id"].as_str().unwrap();
|
|
let orders = result["orders"].as_array().unwrap();
|
|
assert_eq!(orders.len(), 2);
|
|
|
|
let res = client()
|
|
.get(app.url(&format!("/api/payments/{payment_id}")))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "detail: {:?}", res.text().await);
|
|
let payment: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(payment["currency"], "USD");
|
|
assert_eq!(payment["total_minor"], 4000);
|
|
assert_eq!(payment["orders"].as_array().unwrap().len(), 2);
|
|
assert_eq!(payment["status"], "unapplied");
|
|
|
|
let (other, _) = register_customer(&app, "pm-other").await;
|
|
let res = client()
|
|
.get(app.url(&format!("/api/payments/{payment_id}")))
|
|
.bearer_auth(&other)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 404);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn wallet_payment_applies_every_shop_order_and_debits_once() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-wallet-a", 1000, 5).await;
|
|
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-wallet-b", 2000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-wallet").await;
|
|
add_to_cart(&app, &buyer, &sku_a, 1).await;
|
|
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
|
recharge(&app, &buyer, 4000).await;
|
|
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let order_ids: Vec<String> = checkout["orders"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| o["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
let methods = methods(&app, &buyer, payment_id).await;
|
|
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
|
|
|
|
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-wallet-1").await;
|
|
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
|
|
let attempt: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(attempt["status"], "settled");
|
|
assert_eq!(attempt["payment"]["status"], "applied");
|
|
|
|
assert_eq!(wallet_balance(&app, &buyer).await, 1000);
|
|
for order_id in &order_ids {
|
|
assert_eq!(order_status(&app, order_id).await, "paid");
|
|
}
|
|
let entries: serde_json::Value = client()
|
|
.get(app.url("/api/wallet/entries"))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let payment_debits = entries["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|entry| entry["reason"] == "payment_wallet_debit")
|
|
.count();
|
|
assert_eq!(payment_debits, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn insufficient_wallet_payment_rolls_back_all_orders() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-short-a", 1000, 5).await;
|
|
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-short-b", 2000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-short").await;
|
|
add_to_cart(&app, &buyer, &sku_a, 1).await;
|
|
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
|
recharge(&app, &buyer, 2999).await;
|
|
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let order_ids: Vec<String> = checkout["orders"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| o["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
let methods = methods(&app, &buyer, payment_id).await;
|
|
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
|
|
|
|
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-short-1").await;
|
|
assert_eq!(res.status(), 409);
|
|
assert_eq!(wallet_balance(&app, &buyer).await, 2999);
|
|
for order_id in &order_ids {
|
|
assert_eq!(order_status(&app, order_id).await, "pending_payment");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn payment_attempt_idempotency_reuses_only_identical_requests() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem", 1000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-idem").await;
|
|
add_to_cart(&app, &buyer, &sku, 1).await;
|
|
recharge(&app, &buyer, 1000).await;
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let methods = methods(&app, &buyer, payment_id).await;
|
|
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
|
|
|
|
let first = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await;
|
|
assert_eq!(first.status(), 200);
|
|
let first: serde_json::Value = first.json().await.unwrap();
|
|
let second = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await;
|
|
assert_eq!(second.status(), 200);
|
|
let second: serde_json::Value = second.json().await.unwrap();
|
|
assert_eq!(first["id"], second["id"]);
|
|
assert_eq!(wallet_balance(&app, &buyer).await, 0);
|
|
|
|
let res = client()
|
|
.post(app.url(&format!("/api/payments/{payment_id}/attempts")))
|
|
.bearer_auth(&buyer)
|
|
.header("Idempotency-Key", "pm-idem-1")
|
|
.json(&serde_json::json!({ "channel_id": Uuid::new_v4() }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 409);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn admin_channel_revisions_are_audited_and_redacted() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let key_ref = format!("VMALL_PAYMENT_TEST_{}", Uuid::new_v4().simple());
|
|
let body = serde_json::json!({
|
|
"adapter": "stripe_crypto",
|
|
"label": {"en": "Crypto", "zh": "加密支付"},
|
|
"display_order": 20,
|
|
"enabled": false,
|
|
"supported_currencies": ["USD"],
|
|
"min_amount_minor": 100,
|
|
"max_amount_minor": 1000000,
|
|
"provider_account_id": "acct_test",
|
|
"secret_key_ref": key_ref,
|
|
"webhook_secret_ref": format!("{key_ref}_WEBHOOK")
|
|
});
|
|
|
|
let res = client()
|
|
.post(app.url("/api/admin/payments/channels"))
|
|
.bearer_auth(&admin)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 201, "create: {:?}", res.text().await);
|
|
let channel: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(channel["adapter"], "stripe_crypto");
|
|
assert_eq!(channel["revision"], 1);
|
|
assert!(channel.get("secret_key").is_none());
|
|
assert!(channel.get("secret_key_ref").is_some());
|
|
assert!(channel.get("webhook_secret_ref").is_some());
|
|
let channel_id = channel["id"].as_str().unwrap();
|
|
|
|
let mut update = body.clone();
|
|
update["enabled"] = serde_json::json!(false);
|
|
update["display_order"] = serde_json::json!(21);
|
|
update["expected_revision"] = serde_json::json!(1);
|
|
let res = client()
|
|
.put(app.url(&format!("/api/admin/payments/channels/{channel_id}")))
|
|
.bearer_auth(&admin)
|
|
.json(&update)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "update: {:?}", res.text().await);
|
|
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["revision"], 2);
|
|
|
|
update["expected_revision"] = serde_json::json!(1);
|
|
let res = client()
|
|
.put(app.url(&format!("/api/admin/payments/channels/{channel_id}")))
|
|
.bearer_auth(&admin)
|
|
.json(&update)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 409);
|
|
|
|
let res = client()
|
|
.get(app.url(&format!("/api/admin/payments/channels/{channel_id}/audit")))
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200);
|
|
let audit: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(audit.as_array().unwrap().len(), 2);
|
|
assert!(audit.to_string().contains(&key_ref));
|
|
assert!(!audit.to_string().contains("sk_test_"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn forged_stripe_webhook_is_rejected_without_payment_effects() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-hook", 1000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-hook").await;
|
|
add_to_cart(&app, &buyer, &sku, 1).await;
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let order_id = checkout["orders"][0]["id"].as_str().unwrap();
|
|
|
|
let res = client()
|
|
.post(app.url(&format!(
|
|
"/api/payments/webhooks/stripe/{}",
|
|
Uuid::new_v4()
|
|
)))
|
|
.header("stripe-signature", "t=1,v1=forged")
|
|
.body(format!(r#"{{"id":"evt_forged","type":"payment_intent.succeeded","data":{{"object":{{"metadata":{{"payment_id":"{payment_id}"}}}}}}}}"#))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(res.status().is_client_error());
|
|
assert_eq!(order_status(&app, order_id).await, "pending_payment");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn checkout_idempotency_replays_and_rejects_changed_payload() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem-checkout", 1000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-idem-checkout").await;
|
|
add_to_cart(&app, &buyer, &sku, 1).await;
|
|
|
|
let key = Uuid::new_v4().to_string();
|
|
let first = client()
|
|
.post(app.url("/api/orders/checkout"))
|
|
.bearer_auth(&buyer)
|
|
.header("Idempotency-Key", &key)
|
|
.json(&serde_json::json!({
|
|
"shipping_address": address(),
|
|
"currency": "USD",
|
|
"coupon_by_shop": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), 201, "checkout: {:?}", first.text().await);
|
|
let first: serde_json::Value = first.json().await.unwrap();
|
|
let payment_id = first["payment_id"].as_str().unwrap();
|
|
let first_order_ids: Vec<String> = first["orders"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| o["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
|
|
// Replay with the same key and payload returns the original result.
|
|
let replay = client()
|
|
.post(app.url("/api/orders/checkout"))
|
|
.bearer_auth(&buyer)
|
|
.header("Idempotency-Key", &key)
|
|
.json(&serde_json::json!({
|
|
"shipping_address": address(),
|
|
"currency": "USD",
|
|
"coupon_by_shop": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(replay.status(), 201, "replay: {:?}", replay.text().await);
|
|
let replay: serde_json::Value = replay.json().await.unwrap();
|
|
assert_eq!(replay["payment_id"], payment_id);
|
|
let replay_order_ids: Vec<String> = replay["orders"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| o["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
assert_eq!(first_order_ids, replay_order_ids);
|
|
|
|
// The cart was cleared once; replaying did not decrement stock again.
|
|
let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
|
|
.bind(&sku)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stock, 4);
|
|
|
|
// Reusing the key with a changed payload is rejected.
|
|
let conflict = client()
|
|
.post(app.url("/api/orders/checkout"))
|
|
.bearer_auth(&buyer)
|
|
.header("Idempotency-Key", &key)
|
|
.json(&serde_json::json!({
|
|
"shipping_address": address(),
|
|
"currency": "JPY",
|
|
"coupon_by_shop": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(conflict.status(), 409);
|
|
|
|
// A checkout without the required idempotency key is rejected.
|
|
let missing = client()
|
|
.post(app.url("/api/orders/checkout"))
|
|
.bearer_auth(&buyer)
|
|
.json(&serde_json::json!({
|
|
"shipping_address": address(),
|
|
"currency": "USD",
|
|
"coupon_by_shop": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing.status(), 400);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn cancelling_one_member_cancels_the_whole_unpaid_payment_group() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-cancel-a", 1000, 5).await;
|
|
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-cancel-b", 2000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-cancel").await;
|
|
add_to_cart(&app, &buyer, &sku_a, 1).await;
|
|
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let order_ids: Vec<String> = checkout["orders"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| o["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
assert_eq!(order_ids.len(), 2);
|
|
|
|
// Cancelling any single member cancels the entire group atomically.
|
|
let res = client()
|
|
.post(app.url(&format!("/api/orders/{}/cancel", order_ids[0])))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await);
|
|
|
|
for order_id in &order_ids {
|
|
assert_eq!(order_status(&app, order_id).await, "cancelled");
|
|
}
|
|
|
|
// Stock is restored exactly once for both members.
|
|
let stock_a: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
|
|
.bind(&sku_a)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
let stock_b: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
|
|
.bind(&sku_b)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stock_a, 5);
|
|
assert_eq!(stock_b, 5);
|
|
|
|
// The payment detail now reports the closed group.
|
|
let res = client()
|
|
.get(app.url(&format!("/api/payments/{payment_id}")))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200);
|
|
let payment: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(payment["status"], "closed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn wallet_aftersale_refund_completes_on_original_method() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, shop_id, _, sku) = setup_sellable(&app, &admin, "pm-refund", 1000, 5).await;
|
|
let (buyer, user_id) = register_customer(&app, "pm-refund").await;
|
|
add_to_cart(&app, &buyer, &sku, 1).await;
|
|
recharge(&app, &buyer, 1000).await;
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let order = checkout["orders"][0].clone();
|
|
let order_id = order["id"].as_str().unwrap();
|
|
let methods = methods(&app, &buyer, payment_id).await;
|
|
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
|
|
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-refund-1").await;
|
|
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
|
|
assert_eq!(wallet_balance(&app, &buyer).await, 0);
|
|
|
|
let detail: serde_json::Value = client()
|
|
.get(app.url(&format!("/api/orders/{order_id}")))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let item_id = detail["items"][0]["id"].as_str().unwrap();
|
|
|
|
let apply = client()
|
|
.post(app.url("/api/aftersales"))
|
|
.bearer_auth(&buyer)
|
|
.json(&serde_json::json!({
|
|
"order_item_id": item_id,
|
|
"kind": "refund_only",
|
|
"reason": {"en": "not as described", "zh": "与描述不符"},
|
|
"amount_minor": 1000,
|
|
"evidence": ["https://example.com/evidence.png"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(apply.status(), 201, "apply: {:?}", apply.text().await);
|
|
let aftersale_id = apply.json::<serde_json::Value>().await.unwrap()["id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
// The merchant token for this shop.
|
|
let admin2 = login_admin(&app).await;
|
|
let owner_token = common::make_shop_owner(&app, &admin2, &shop_id).await;
|
|
|
|
let approve = client()
|
|
.post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/approve")))
|
|
.bearer_auth(&owner_token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(approve.status(), 200, "approve: {:?}", approve.text().await);
|
|
|
|
let refund = client()
|
|
.post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/refund")))
|
|
.bearer_auth(&owner_token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refund.status(), 200, "refund: {:?}", refund.text().await);
|
|
let refund_body: serde_json::Value = refund.json().await.unwrap();
|
|
// Wallet refunds complete synchronously on the original method.
|
|
assert_eq!(refund_body["status"], "refunded");
|
|
assert_eq!(wallet_balance(&app, &buyer).await, 1000);
|
|
|
|
let refund_total: i64 = sqlx::query_scalar(
|
|
"SELECT refund_total_minor FROM orders WHERE id = $1::uuid",
|
|
)
|
|
.bind(order_id)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refund_total, 1000);
|
|
|
|
let entries: serde_json::Value = client()
|
|
.get(app.url("/api/wallet/entries"))
|
|
.bearer_auth(&buyer)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let refund_credits = entries["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|entry| entry["reason"] == "aftersale_refund")
|
|
.count();
|
|
assert_eq!(refund_credits, 1);
|
|
let _ = user_id;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn admin_operations_list_detail_and_reconcile_are_exposed() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-ops", 1000, 5).await;
|
|
let (buyer, _) = register_customer(&app, "pm-ops").await;
|
|
add_to_cart(&app, &buyer, &sku, 1).await;
|
|
recharge(&app, &buyer, 1000).await;
|
|
let checkout = checkout_payment(&app, &buyer).await;
|
|
let payment_id = checkout["payment_id"].as_str().unwrap();
|
|
let methods = methods(&app, &buyer, payment_id).await;
|
|
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
|
|
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-ops-1").await;
|
|
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
|
|
|
|
// Paginated operations listing discriminates payment rows.
|
|
let res = client()
|
|
.get(app.url("/api/admin/payments"))
|
|
.query(&[("kind", "payment"), ("per_page", "50")])
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "ops: {:?}", res.text().await);
|
|
let ops: serde_json::Value = res.json().await.unwrap();
|
|
let found = ops["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|row| row["payment_id"] == payment_id && row["kind"] == "payment");
|
|
assert!(found, "expected a payment operation row for {payment_id}");
|
|
|
|
// Admin detail matches the customer detail shape plus channel audit.
|
|
let res = client()
|
|
.get(app.url(&format!("/api/admin/payments/{payment_id}")))
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "admin detail: {:?}", res.text().await);
|
|
let detail: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(detail["status"], "applied");
|
|
assert!(detail.get("audit").is_some());
|
|
|
|
// Reconcile queues a durable evidence-fetch command without collection.
|
|
let res = client()
|
|
.post(app.url(&format!("/api/admin/payments/{payment_id}/reconcile")))
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "reconcile: {:?}", res.text().await);
|
|
let command: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(command["payment_id"], payment_id);
|
|
assert!(matches!(
|
|
command["status"].as_str().unwrap(),
|
|
"queued" | "running" | "succeeded" | "failed"
|
|
));
|
|
|
|
// Merchants cannot reach platform recovery endpoints.
|
|
let owner = common::make_shop_owner(
|
|
&app,
|
|
&admin,
|
|
&detail["orders"][0]["shop_id"].as_str().unwrap(),
|
|
)
|
|
.await;
|
|
let res = client()
|
|
.get(app.url("/api/admin/payments"))
|
|
.bearer_auth(&owner)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 403);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn demo_recharge_is_refused_outside_demo_environment() {
|
|
// A sandbox-configured API refuses the simulated recharge server-side.
|
|
let config = vmall_api::config::Config {
|
|
database_url: common::TEST_DB_URL.into(),
|
|
redis_url: common::TEST_REDIS_URL.into(),
|
|
jwt_secret: "test-secret".into(),
|
|
port: 0,
|
|
jwt_ttl_secs: 3600,
|
|
payment: vmall_api::config::PaymentConfig {
|
|
environment: vmall_api::modules::payment::PaymentEnvironment::Sandbox,
|
|
..Default::default()
|
|
},
|
|
};
|
|
let db = sqlx::PgPool::connect(common::TEST_DB_URL).await.unwrap();
|
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
|
let state = vmall_api::state::assemble(config, db).await.unwrap();
|
|
vmall_api::seed::ensure_platform_admin(&state).await.unwrap();
|
|
let app = vmall_api::build_router(state);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
let base = format!("http://127.0.0.1:{port}");
|
|
|
|
let email = format!("pm-sandbox-{}@test.local", uuid::Uuid::new_v4());
|
|
let res = client()
|
|
.post(format!("{base}/api/auth/register"))
|
|
.json(&serde_json::json!({
|
|
"email": email,
|
|
"password": "password123",
|
|
"display_name": "pm-sandbox",
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 201);
|
|
let token = res.json::<serde_json::Value>().await.unwrap()["token"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
let res = client()
|
|
.post(format!("{base}/api/wallet/recharges"))
|
|
.bearer_auth(&token)
|
|
.json(&serde_json::json!({ "amount_minor": 5000 }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 403);
|
|
let balance = client()
|
|
.get(format!("{base}/api/wallet"))
|
|
.bearer_auth(&token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(balance["demo_recharge_enabled"], false);
|
|
assert_eq!(balance["environment"], "sandbox");
|
|
}
|