From 7a2745fb164c6810d9f9d3d69903f49e29a25e65 Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 18 Sep 2026 11:54:53 +0000 Subject: [PATCH] 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. --- .../api/migrations/0010_customer_accounts.sql | 56 +++++ apps/api/src/models.rs | 44 ++++ apps/api/src/modules/account/dto.rs | 12 + apps/api/src/modules/account/handlers.rs | 19 ++ apps/api/src/modules/account/mod.rs | 12 + apps/api/src/modules/account/repo.rs | 136 ++++++++++ apps/api/src/modules/account/service.rs | 213 ++++++++++++++++ apps/api/src/modules/identity/service.rs | 8 +- apps/api/src/modules/mod.rs | 2 + apps/api/tests/accounts.rs | 234 ++++++++++++++++++ apps/api/tests/common/mod.rs | 4 +- apps/mall/mock/api.ts | 9 + apps/mall/nuxt.config.ts | 2 +- apps/mall/pages/integral.vue | 26 +- apps/mall/pages/user/index.vue | 23 +- apps/mall/plugins/api.ts | 3 + .../changes/add-customer-accounts/tasks.md | 20 +- packages/shared/src/api.ts | 4 + packages/shared/src/types.ts | 12 + 19 files changed, 818 insertions(+), 21 deletions(-) create mode 100644 apps/api/migrations/0010_customer_accounts.sql create mode 100644 apps/api/src/modules/account/dto.rs create mode 100644 apps/api/src/modules/account/handlers.rs create mode 100644 apps/api/src/modules/account/mod.rs create mode 100644 apps/api/src/modules/account/repo.rs create mode 100644 apps/api/src/modules/account/service.rs create mode 100644 apps/api/tests/accounts.rs diff --git a/apps/api/migrations/0010_customer_accounts.sql b/apps/api/migrations/0010_customer_accounts.sql new file mode 100644 index 0000000..1261853 --- /dev/null +++ b/apps/api/migrations/0010_customer_accounts.sql @@ -0,0 +1,56 @@ +-- Customer accounts: one balance row per (user, kind, currency) plus an +-- append-only entry ledger. Monetary kinds carry a currency; points do not. +-- The CHECK plus the two partial unique indexes keep those pairs honest and +-- prevent duplicates (a plain UNIQUE would let NULL currencies repeat). + +CREATE TYPE customer_account_kind AS ENUM ('available', 'frozen', 'points'); + +CREATE TABLE customer_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + kind customer_account_kind NOT NULL, + currency CHAR(3) REFERENCES currencies (code), + balance_minor BIGINT NOT NULL DEFAULT 0 CHECK (balance_minor >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT customer_accounts_kind_currency CHECK ( + (kind = 'points' AND currency IS NULL) + OR (kind <> 'points' AND currency IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX customer_accounts_monetary_idx + ON customer_accounts (user_id, kind, currency) + WHERE currency IS NOT NULL; + +CREATE UNIQUE INDEX customer_accounts_points_idx + ON customer_accounts (user_id, kind) + WHERE currency IS NULL; + +CREATE TABLE customer_account_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES customer_accounts (id) ON DELETE CASCADE, + delta_minor BIGINT NOT NULL CHECK (delta_minor <> 0), + balance_minor BIGINT NOT NULL CHECK (balance_minor >= 0), + reason TEXT NOT NULL, + reference_type TEXT, + reference_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX customer_account_entries_account_idx + ON customer_account_entries (account_id, created_at DESC); + +-- Backfill zero rows for users that already exist; new registrations create +-- their own rows in the same transaction as the user insert. +INSERT INTO customer_accounts (user_id, kind) +SELECT id, 'points' FROM users +ON CONFLICT DO NOTHING; + +INSERT INTO customer_accounts (user_id, kind, currency) +SELECT u.id, k.kind, c.code + FROM users u + CROSS JOIN (VALUES ('available'::customer_account_kind), + ('frozen'::customer_account_kind)) AS k (kind) + CROSS JOIN (SELECT code FROM currencies WHERE is_base LIMIT 1) AS c +ON CONFLICT DO NOTHING; diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 107b7b1..cc3738a 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -242,6 +242,50 @@ pub struct Invoice { pub created_at: DateTime, } +/// Balance bucket. `available`/`frozen` are monetary and carry a currency; +/// `points` is an integer loyalty balance with no currency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "customer_account_kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + Available, + Frozen, + Points, +} + +impl AccountKind { + pub fn is_monetary(self) -> bool { + !matches!(self, AccountKind::Points) + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct CustomerAccount { + pub id: Uuid, + pub user_id: Uuid, + pub kind: AccountKind, + pub currency: Option, + pub balance_minor: i64, +} + +pub const CUSTOMER_ACCOUNT_COLUMNS: &str = "id, user_id, kind, currency, balance_minor"; + +/// One immutable balance movement. Written in the same transaction as the +/// guarded account update it describes. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct CustomerAccountEntry { + pub id: Uuid, + pub account_id: Uuid, + pub delta_minor: i64, + pub balance_minor: i64, + pub reason: String, + pub reference_type: Option, + pub reference_id: Option, + pub created_at: DateTime, +} + +pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at"; + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AddressBookEntry { pub id: Uuid, diff --git a/apps/api/src/modules/account/dto.rs b/apps/api/src/modules/account/dto.rs new file mode 100644 index 0000000..2178b50 --- /dev/null +++ b/apps/api/src/modules/account/dto.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +/// Response of `GET /api/me/stats`: the customer's own money and points. +/// Money is minor units in `currency`; points are integer units with no +/// currency of their own. +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummary { + pub balance_minor: i64, + pub frozen_minor: i64, + pub currency: String, + pub points: i64, +} diff --git a/apps/api/src/modules/account/handlers.rs b/apps/api/src/modules/account/handlers.rs new file mode 100644 index 0000000..7722e10 --- /dev/null +++ b/apps/api/src/modules/account/handlers.rs @@ -0,0 +1,19 @@ +use axum::{extract::State, routing::get, Json, Router}; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::dto::AccountSummary; +use super::service; + +/// Read-only: balances are granted or spent by business flows, never by a +/// client-facing mutation endpoint. +pub fn router() -> Router { + Router::new().route("/me/stats", get(stats)) +} + +async fn stats(State(state): State, auth: AuthUser) -> ApiResult> { + auth.require_customer()?; + Ok(Json(service::summary(&state, auth.id).await?)) +} diff --git a/apps/api/src/modules/account/mod.rs b/apps/api/src/modules/account/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/account/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/account/repo.rs b/apps/api/src/modules/account/repo.rs new file mode 100644 index 0000000..43bd9fe --- /dev/null +++ b/apps/api/src/modules/account/repo.rs @@ -0,0 +1,136 @@ +use sqlx::{PgConnection, PgExecutor}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{ + CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, + CUSTOMER_ACCOUNT_ENTRY_COLUMNS, +}; + +/// Monetary accounts are opened in the platform base currency. Multi-currency +/// wallets would add rows here rather than change the summary contract. +pub async fn base_currency<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult { + sqlx::query_scalar("SELECT code FROM currencies WHERE is_base LIMIT 1") + .fetch_optional(exec) + .await? + .ok_or_else(|| ApiError::Conflict("no base currency configured".into())) +} + +/// Idempotently create the user's zero-balance rows. Safe to re-run: the +/// partial unique indexes make a repeated call a no-op. +pub async fn ensure_accounts( + tx: &mut PgConnection, + user_id: Uuid, + base_currency: &str, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO customer_accounts (user_id, kind, currency) + VALUES ($1, 'points', NULL), + ($1, 'available', $2), + ($1, 'frozen', $2) + ON CONFLICT DO NOTHING", + ) + .bind(user_id) + .bind(base_currency) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn list_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CustomerAccount>(&format!( + "SELECT {CUSTOMER_ACCOUNT_COLUMNS} + FROM customer_accounts + WHERE user_id = $1 + ORDER BY kind" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +/// Lock every account row for one user before a two-sided transfer. Ordered by +/// primary key so concurrent transfers take locks in the same order. +pub async fn lock_for_user( + tx: &mut PgConnection, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CustomerAccount>(&format!( + "SELECT {CUSTOMER_ACCOUNT_COLUMNS} + FROM customer_accounts + WHERE user_id = $1 + ORDER BY id + FOR UPDATE" + )) + .bind(user_id) + .fetch_all(&mut *tx) + .await?) +} + +/// Conditional decrement: the predicate proves sufficient balance, so a stale +/// read cannot drive the stored balance negative. +pub async fn debit_guarded( + tx: &mut PgConnection, + account_id: Uuid, + amount_minor: i64, +) -> ApiResult { + sqlx::query_as::<_, CustomerAccount>(&format!( + "UPDATE customer_accounts + SET balance_minor = balance_minor - $2, updated_at = now() + WHERE id = $1 AND balance_minor >= $2 + RETURNING {CUSTOMER_ACCOUNT_COLUMNS}" + )) + .bind(account_id) + .bind(amount_minor) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("insufficient balance".into())) +} + +/// Unconditional atomic addition. Backfills use this shape, never a read-then-set. +pub async fn credit_atomic( + tx: &mut PgConnection, + account_id: Uuid, + amount_minor: i64, +) -> ApiResult { + Ok(sqlx::query_as::<_, CustomerAccount>(&format!( + "UPDATE customer_accounts + SET balance_minor = balance_minor + $2, updated_at = now() + WHERE id = $1 + RETURNING {CUSTOMER_ACCOUNT_COLUMNS}" + )) + .bind(account_id) + .bind(amount_minor) + .fetch_one(&mut *tx) + .await?) +} + +/// Append the audit fact for a balance update that already happened in `tx`. +#[allow(clippy::too_many_arguments)] +pub async fn insert_entry( + tx: &mut PgConnection, + account_id: Uuid, + delta_minor: i64, + balance_minor: i64, + reason: &str, + reference_type: Option<&str>, + reference_id: Option, +) -> ApiResult { + Ok(sqlx::query_as::<_, CustomerAccountEntry>(&format!( + "INSERT INTO customer_account_entries + (account_id, delta_minor, balance_minor, reason, reference_type, reference_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING {CUSTOMER_ACCOUNT_ENTRY_COLUMNS}" + )) + .bind(account_id) + .bind(delta_minor) + .bind(balance_minor) + .bind(reason) + .bind(reference_type) + .bind(reference_id) + .fetch_one(&mut *tx) + .await?) +} diff --git a/apps/api/src/modules/account/service.rs b/apps/api/src/modules/account/service.rs new file mode 100644 index 0000000..ab66fbe --- /dev/null +++ b/apps/api/src/modules/account/service.rs @@ -0,0 +1,213 @@ +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{AccountKind, CustomerAccount, CustomerAccountEntry}; +use crate::state::AppState; + +use super::dto::AccountSummary; +use super::repo; + +/// Optional business reference stored on a ledger entry, e.g. +/// `("integral_order", order_id)`. +pub type EntryRef<'a> = Option<(&'a str, Uuid)>; + +/// Create the zero-balance rows a customer needs, inside the caller's +/// transaction. Registration calls this next to the user insert. +pub async fn ensure_accounts(tx: &mut PgConnection, user_id: Uuid) -> ApiResult<()> { + let base = repo::base_currency(&mut *tx).await?; + repo::ensure_accounts(tx, user_id, &base).await +} + +/// `GET /api/me/stats`. There is no public balance mutation endpoint. +pub async fn summary(state: &AppState, user_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + // Defensive: a user created outside registration still gets a summary. + ensure_accounts(&mut tx, user_id).await?; + let accounts = repo::list_for_user(&mut *tx, user_id).await?; + tx.commit().await?; + summarize(&accounts) +} + +/// Add `amount_minor` and append the matching entry. Caller owns the transaction +/// so a business flow can commit the balance with its own rows. +pub async fn credit( + tx: &mut PgConnection, + user_id: Uuid, + kind: AccountKind, + currency: Option<&str>, + amount_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult { + let amount = positive(amount_minor)?; + let account = account_for(tx, user_id, kind, currency).await?; + let updated = repo::credit_atomic(tx, account.id, amount).await?; + append(tx, account.id, amount, updated.balance_minor, reason, reference).await +} + +/// Subtract `amount_minor` only when the account can cover it; 409 otherwise. +pub async fn debit( + tx: &mut PgConnection, + user_id: Uuid, + kind: AccountKind, + currency: Option<&str>, + amount_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult { + let amount = positive(amount_minor)?; + let account = account_for(tx, user_id, kind, currency).await?; + let updated = repo::debit_guarded(tx, account.id, amount).await?; + append(tx, account.id, -amount, updated.balance_minor, reason, reference).await +} + +/// Move money from `available` to `frozen`; returns (available, frozen) entries. +pub async fn freeze( + tx: &mut PgConnection, + user_id: Uuid, + amount_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult<(CustomerAccountEntry, CustomerAccountEntry)> { + transfer( + tx, + user_id, + AccountKind::Available, + AccountKind::Frozen, + amount_minor, + reason, + reference, + ) + .await +} + +/// Move money from `frozen` back to `available`. +pub async fn release( + tx: &mut PgConnection, + user_id: Uuid, + amount_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult<(CustomerAccountEntry, CustomerAccountEntry)> { + transfer( + tx, + user_id, + AccountKind::Frozen, + AccountKind::Available, + amount_minor, + reason, + reference, + ) + .await +} + +/// Two-sided move. Locks both rows first, then debits the source with a guard +/// and credits the destination atomically, so the pair always balances. +async fn transfer( + tx: &mut PgConnection, + user_id: Uuid, + from_kind: AccountKind, + to_kind: AccountKind, + amount_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult<(CustomerAccountEntry, CustomerAccountEntry)> { + let amount = positive(amount_minor)?; + let accounts = repo::lock_for_user(tx, user_id).await?; + let from = find(&accounts, from_kind)?.id; + let to = find(&accounts, to_kind)?.id; + + let from_updated = repo::debit_guarded(tx, from, amount).await?; + let to_updated = repo::credit_atomic(tx, to, amount).await?; + + let out = append(tx, from, -amount, from_updated.balance_minor, reason, reference).await?; + let into = append(tx, to, amount, to_updated.balance_minor, reason, reference).await?; + Ok((out, into)) +} + +async fn append( + tx: &mut PgConnection, + account_id: Uuid, + delta_minor: i64, + balance_minor: i64, + reason: &str, + reference: EntryRef<'_>, +) -> ApiResult { + let (reference_type, reference_id) = match reference { + Some((kind, id)) => (Some(kind), Some(id)), + None => (None, None), + }; + repo::insert_entry( + tx, + account_id, + delta_minor, + balance_minor, + reason, + reference_type, + reference_id, + ) + .await +} + +async fn account_for( + tx: &mut PgConnection, + user_id: Uuid, + kind: AccountKind, + currency: Option<&str>, +) -> ApiResult { + let wanted = resolve_currency(tx, kind, currency).await?; + repo::list_for_user(&mut *tx, user_id) + .await? + .into_iter() + .find(|account| account.kind == kind && account.currency == wanted) + .ok_or_else(|| ApiError::NotFound("customer account".into())) +} + +async fn resolve_currency( + tx: &mut PgConnection, + kind: AccountKind, + currency: Option<&str>, +) -> ApiResult> { + if !kind.is_monetary() { + return Ok(None); + } + match currency { + Some(code) => Ok(Some(code.to_uppercase())), + None => Ok(Some(repo::base_currency(&mut *tx).await?)), + } +} + +fn summarize(accounts: &[CustomerAccount]) -> ApiResult { + let available = find(accounts, AccountKind::Available)?; + let frozen = find(accounts, AccountKind::Frozen)?; + let points = find(accounts, AccountKind::Points)?; + let currency = available + .currency + .clone() + .ok_or_else(|| { + ApiError::Internal(anyhow::anyhow!("available account has no currency")) + })?; + Ok(AccountSummary { + balance_minor: available.balance_minor, + frozen_minor: frozen.balance_minor, + currency, + points: points.balance_minor, + }) +} + +fn find(accounts: &[CustomerAccount], kind: AccountKind) -> ApiResult<&CustomerAccount> { + accounts + .iter() + .find(|account| account.kind == kind) + .ok_or_else(|| ApiError::NotFound("customer account".into())) +} + +fn positive(amount_minor: i64) -> ApiResult { + if amount_minor <= 0 { + return Err(ApiError::BadRequest( + "amount_minor must be positive".into(), + )); + } + Ok(amount_minor) +} diff --git a/apps/api/src/modules/identity/service.rs b/apps/api/src/modules/identity/service.rs index c69eeb7..f6dd2b9 100644 --- a/apps/api/src/modules/identity/service.rs +++ b/apps/api/src/modules/identity/service.rs @@ -5,6 +5,7 @@ use crate::auth::{hash_password, issue_token, verify_password}; use crate::error::{unique_conflict, ApiError, ApiResult}; use crate::http::Paged; use crate::models::{User, UserPublic, UserRole}; +use crate::modules::account; use crate::state::AppState; use super::repo; @@ -29,9 +30,14 @@ pub async fn register(state: &AppState, input: RegisterInput) -> ApiResult<(User return Err(ApiError::BadRequest("display_name is required".into())); } let hash = hash_password(&input.password)?; - let user = repo::insert_customer(&state.db, &email, &hash, input.display_name.trim()) + // User and their zero-balance account rows commit together so a registered + // customer always has a summary. + let mut tx = state.db.begin().await?; + let user = repo::insert_customer(&mut *tx, &email, &hash, input.display_name.trim()) .await .map_err(|e| unique_conflict(e, "email already registered"))?; + account::service::ensure_accounts(&mut tx, user.id).await?; + tx.commit().await?; let payload = auth_payload(state, &user)?; Ok((UserPublic::from(user), payload)) } diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 16696c8..13ea016 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod address; pub mod billing; pub mod cart; @@ -17,6 +18,7 @@ use crate::state::AppState; pub fn api_router() -> Router { Router::new() .merge(health::router()) + .merge(account::router()) .merge(address::router()) .merge(identity::router()) .merge(currency::router()) diff --git a/apps/api/tests/accounts.rs b/apps/api/tests/accounts.rs new file mode 100644 index 0000000..613cf08 --- /dev/null +++ b/apps/api/tests/accounts.rs @@ -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); +} diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index 0f6dd74..73a2f84 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -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, } } diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index b820b97..d6bbccc 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -4,6 +4,7 @@ import { ApiError } from "@vmall/shared"; import type { + AccountSummary, Address, AddressBookEntry, AddressInput, @@ -30,6 +31,7 @@ import { MOCK_QUICK_LINKS, MOCK_STORES, MOCK_USER, + USER_STATS, MOCK_ADDRESSES, defaultAddress, mockConvertMinor, @@ -179,6 +181,13 @@ export function createMockApi(): ApiClient { register: () => Promise.resolve(tokens()), login: () => Promise.resolve(tokens()), me: (): Promise => Promise.resolve(MOCK_USER), + getAccountSummary: (): Promise => + Promise.resolve({ + balance_minor: USER_STATS.balanceMinor, + frozen_minor: USER_STATS.frozenMinor, + currency: BASE_CURRENCY, + points: USER_STATS.points, + }), listProducts: (q = {}) => Promise.resolve( diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index b95c960..12bc8b6 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices", "addresses"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses"], appName: "mall", }, }, diff --git a/apps/mall/pages/integral.vue b/apps/mall/pages/integral.vue index 93d901d..dd1ba10 100644 --- a/apps/mall/pages/integral.vue +++ b/apps/mall/pages/integral.vue @@ -1,11 +1,28 @@