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::().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::() .await .unwrap()["available_minor"] .as_i64() .unwrap() } async fn entries(app: &TestApp, token: &str) -> Vec { client() .get(app.url("/api/wallet/entries")) .query(&[("per_page", "100")]) .bearer_auth(token) .send() .await .unwrap() .json::() .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::().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 = 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::().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); }