feat(billing): user ledger list + summary queries
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
use sqlx::{SqlitePool, Transaction};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::{Row, SqlitePool, Transaction};
|
||||||
|
|
||||||
/// One balance-changing event. Written to `credit_ledger` in the same
|
/// One balance-changing event. Written to `credit_ledger` in the same
|
||||||
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
||||||
@@ -143,6 +144,77 @@ pub async fn apply_points_delta(
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
|
||||||
|
pub async fn list_user_ledger(
|
||||||
|
db: &SqlitePool,
|
||||||
|
user_id: i64,
|
||||||
|
track: Option<&str>,
|
||||||
|
entry_type: Option<&str>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
|
ensure_ledger_schema(db).await?;
|
||||||
|
let limit = limit.clamp(1, 500);
|
||||||
|
let offset = offset.max(0);
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
|
||||||
|
FROM credit_ledger
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (? IS NULL OR track = ?)
|
||||||
|
AND (? IS NULL OR entry_type = ?)
|
||||||
|
ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(track)
|
||||||
|
.bind(track)
|
||||||
|
.bind(entry_type)
|
||||||
|
.bind(entry_type)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"track": r.try_get::<String, _>("track").unwrap_or_default(),
|
||||||
|
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
|
||||||
|
"amount": r.try_get::<f64, _>("amount").unwrap_or(0.0),
|
||||||
|
"balanceAfter": r.try_get::<f64, _>("balance_after").unwrap_or(0.0),
|
||||||
|
"model": r.try_get::<Option<String>, _>("model").ok().flatten(),
|
||||||
|
"operator": r.try_get::<Option<String>, _>("operator").ok().flatten(),
|
||||||
|
"refType": r.try_get::<Option<String>, _>("ref_type").ok().flatten(),
|
||||||
|
"note": r.try_get::<Option<String>, _>("note").ok().flatten(),
|
||||||
|
"createdAt": r.try_get::<Option<String>, _>("created_at").ok().flatten(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
|
||||||
|
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
|
||||||
|
ensure_ledger_schema(db).await?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
|
||||||
|
FROM credit_ledger WHERE user_id = ?
|
||||||
|
GROUP BY track, entry_type ORDER BY track, entry_type",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"track": r.try_get::<String, _>("track").unwrap_or_default(),
|
||||||
|
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
|
||||||
|
"total": r.try_get::<f64, _>("total").unwrap_or(0.0),
|
||||||
|
"count": r.try_get::<i64, _>("cnt").unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -207,4 +279,17 @@ mod tests {
|
|||||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
|
||||||
assert_eq!(n, 2);
|
assert_eq!(n, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_and_summarize_user_ledger() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
|
||||||
|
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
|
||||||
|
// 明细:user 9 两行,track=points
|
||||||
|
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
// 聚合:points 轨 consume 合计 -10
|
||||||
|
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
|
||||||
|
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user