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); }