feat(api): add customer accounts with live summary and append-only ledger

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.
This commit is contained in:
2026-09-18 11:54:53 +00:00
parent bcd97ab48f
commit 7a2745fb16
19 changed files with 818 additions and 21 deletions
+234
View File
@@ -0,0 +1,234 @@
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);
}