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.
557 lines
19 KiB
Rust
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);
|
|
}
|