One balance row per (user, kind, currency): available and frozen carry the platform base currency, points carries none. Monetary and points rows use separate partial unique indexes because a plain UNIQUE lets NULL repeat. Balance changes go through transactional primitives that debits guard with a conditional update, credits add atomically, and freeze/release move both sides in one transaction after locking rows by primary key. Every change appends an immutable entry holding its resulting balance. Registration and a migration backfill create the zero rows; GET /api/me/stats is the only public surface and no endpoint mutates a balance. The mall buyer center and points page drop the USER_STATS fixture for the shared contract; the fixture stays exported so the fixed-data adapter can still serve the account domain as a rollback path. Implements openspec change add-customer-accounts.
235 lines
6.9 KiB
Rust
235 lines
6.9 KiB
Rust
mod common;
|
|
|
|
use common::{client, register_customer, spawn_app};
|
|
use serial_test::serial;
|
|
use uuid::Uuid;
|
|
use vmall_api::error::ApiError;
|
|
use vmall_api::models::{AccountKind, CustomerAccountEntry};
|
|
use vmall_api::modules::account::service;
|
|
use vmall_api::state::AppState;
|
|
|
|
async fn summary(app: &common::TestApp, token: &str) -> serde_json::Value {
|
|
let res = client()
|
|
.get(app.url("/api/me/stats"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "stats failed: {:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
fn user_uuid(id: &str) -> Uuid {
|
|
Uuid::parse_str(id).unwrap()
|
|
}
|
|
|
|
async fn credit_points(state: &AppState, user_id: Uuid, amount: i64) -> CustomerAccountEntry {
|
|
let mut tx = state.db.begin().await.unwrap();
|
|
let entry = service::credit(
|
|
&mut tx,
|
|
user_id,
|
|
AccountKind::Points,
|
|
None,
|
|
amount,
|
|
"test_credit",
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
entry
|
|
}
|
|
|
|
/// One debit in its own transaction, so two calls can race.
|
|
async fn debit_points(state: &AppState, user_id: Uuid, amount: i64) -> Result<(), ApiError> {
|
|
let mut tx = state.db.begin().await.unwrap();
|
|
match service::debit(
|
|
&mut tx,
|
|
user_id,
|
|
AccountKind::Points,
|
|
None,
|
|
amount,
|
|
"test_debit",
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
tx.commit().await.unwrap();
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
tx.rollback().await.ok();
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn entry_count(state: &AppState, user_id: Uuid) -> i64 {
|
|
sqlx::query_scalar(
|
|
"SELECT COUNT(*)
|
|
FROM customer_account_entries e
|
|
JOIN customer_accounts a ON a.id = e.account_id
|
|
WHERE a.user_id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn new_customer_summary_is_zero() {
|
|
let app = spawn_app().await;
|
|
let (token, user_id) = register_customer(&app, "acct-zero").await;
|
|
|
|
let stats = summary(&app, &token).await;
|
|
assert_eq!(stats["balance_minor"], 0);
|
|
assert_eq!(stats["frozen_minor"], 0);
|
|
assert_eq!(stats["points"], 0);
|
|
assert_eq!(stats["currency"], "USD");
|
|
|
|
// Registration created the three rows; the summary did not invent them.
|
|
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM customer_accounts WHERE user_id = $1")
|
|
.bind(user_uuid(&user_id))
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn summary_requires_customer() {
|
|
let app = spawn_app().await;
|
|
let admin = common::login_admin(&app).await;
|
|
|
|
let res = client()
|
|
.get(app.url("/api/me/stats"))
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 403, "platform admin is not a customer");
|
|
|
|
let res = client().get(app.url("/api/me/stats")).send().await.unwrap();
|
|
assert_eq!(res.status(), 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn summary_is_owned_and_entries_are_append_only() {
|
|
let app = spawn_app().await;
|
|
let (token_a, user_a) = register_customer(&app, "acct-a").await;
|
|
let (token_b, _user_b) = register_customer(&app, "acct-b").await;
|
|
|
|
let entry = credit_points(&app.state, user_uuid(&user_a), 500).await;
|
|
assert_eq!(entry.delta_minor, 500);
|
|
assert_eq!(entry.balance_minor, 500);
|
|
assert_eq!(entry.reason, "test_credit");
|
|
|
|
let stats_a = summary(&app, &token_a).await;
|
|
assert_eq!(stats_a["points"], 500, "owner sees the credit");
|
|
let stats_b = summary(&app, &token_b).await;
|
|
assert_eq!(stats_b["points"], 0, "another customer sees their own zero");
|
|
|
|
// Reads never append ledger rows, and no public API mutates balances.
|
|
assert_eq!(entry_count(&app.state, user_uuid(&user_a)).await, 1);
|
|
let _ = summary(&app, &token_a).await;
|
|
assert_eq!(entry_count(&app.state, user_uuid(&user_a)).await, 1);
|
|
|
|
for (method, status) in [("POST", 405), ("PUT", 405), ("DELETE", 405)] {
|
|
let res = client()
|
|
.request(method.parse().unwrap(), app.url("/api/me/stats"))
|
|
.bearer_auth(&token_a)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), status, "{method} /api/me/stats must not mutate");
|
|
}
|
|
let res = client()
|
|
.get(app.url("/api/me/stats/entries"))
|
|
.bearer_auth(&token_a)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 404, "the public ledger listing stays out of scope");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn freeze_moves_available_to_frozen_in_balance() {
|
|
let app = spawn_app().await;
|
|
let (token, user_id) = register_customer(&app, "acct-freeze").await;
|
|
let user = user_uuid(&user_id);
|
|
|
|
let mut tx = app.state.db.begin().await.unwrap();
|
|
service::credit(
|
|
&mut tx,
|
|
user,
|
|
AccountKind::Available,
|
|
None,
|
|
1_000,
|
|
"test_credit",
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
let mut tx = app.state.db.begin().await.unwrap();
|
|
let (out, into) = service::freeze(&mut tx, user, 400, "test_freeze", None)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
assert_eq!(out.delta_minor, -400);
|
|
assert_eq!(out.balance_minor, 600);
|
|
assert_eq!(into.delta_minor, 400);
|
|
assert_eq!(into.balance_minor, 400);
|
|
|
|
let stats = summary(&app, &token).await;
|
|
assert_eq!(stats["balance_minor"], 600);
|
|
assert_eq!(stats["frozen_minor"], 400, "the pair stays balanced");
|
|
// One credit plus the two sides of the transfer.
|
|
assert_eq!(entry_count(&app.state, user).await, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn competing_debits_never_go_negative() {
|
|
let app = spawn_app().await;
|
|
let (_token, user_id) = register_customer(&app, "acct-race").await;
|
|
let user = user_uuid(&user_id);
|
|
credit_points(&app.state, user, 100).await;
|
|
|
|
let first = {
|
|
let state = app.state.clone();
|
|
async move { debit_points(&state, user, 60).await }
|
|
};
|
|
let second = {
|
|
let state = app.state.clone();
|
|
async move { debit_points(&state, user, 60).await }
|
|
};
|
|
let (a, b) = tokio::join!(first, second);
|
|
|
|
let wins = [a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count();
|
|
assert_eq!(wins, 1, "exactly one debit fits in the balance");
|
|
let loser = a.err().or_else(|| b.err()).unwrap();
|
|
assert!(matches!(loser, ApiError::Conflict(_)), "got {loser:?}");
|
|
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT balance_minor FROM customer_accounts
|
|
WHERE user_id = $1 AND kind = 'points'",
|
|
)
|
|
.bind(user)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 40);
|
|
assert!(stored >= 0, "no negative balance is ever stored");
|
|
// Credit plus the single successful debit.
|
|
assert_eq!(entry_count(&app.state, user).await, 2);
|
|
}
|