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:
@@ -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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub const TEST_REDIS_URL: &str = "redis://127.0.0.1:6379/";
|
||||
pub struct TestApp {
|
||||
pub base: String,
|
||||
pub db: sqlx::PgPool,
|
||||
pub state: vmall_api::state::AppState,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
@@ -42,7 +43,8 @@ pub async fn spawn_app() -> TestApp {
|
||||
});
|
||||
TestApp {
|
||||
base: format!("http://127.0.0.1:{port}"),
|
||||
db: state.db,
|
||||
db: state.db.clone(),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user