forked from boos/BoosAPI
feat(billing): comfy recharge to points + admin credit adjust
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+110
-4
@@ -2308,6 +2308,7 @@ pub async fn admin_cancel_user_recharge(db: &SqlitePool, public_id: &str) -> App
|
||||
/// Idempotent: a record already `completed` is returned unchanged.
|
||||
pub async fn admin_confirm_user_recharge(
|
||||
db: &SqlitePool,
|
||||
config: &PaymentConfig,
|
||||
public_id: &str,
|
||||
) -> AppResult<Value> {
|
||||
let public_id = public_id.trim();
|
||||
@@ -2342,6 +2343,46 @@ pub async fn admin_confirm_user_recharge(
|
||||
}
|
||||
|
||||
crate::services::billing::ledger::ensure_ledger_schema(db).await?;
|
||||
|
||||
if config.comfy_recharge_to_points {
|
||||
// Points mode: grant workflow points (amount_usd * rate). apply_points_delta
|
||||
// opens its own transaction, so we grant first (sqlx has no nested
|
||||
// transactions), then mark the record completed in a separate statement.
|
||||
// Granting before the status flip keeps the invariant "completed ⇒ points
|
||||
// granted": if the status update fails the record stays pending and the
|
||||
// admin can retry, rather than a paid recharge yielding zero points.
|
||||
let rate = if config.usd_to_points_rate.is_finite() && config.usd_to_points_rate > 0.0 {
|
||||
config.usd_to_points_rate
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
let grant_points = amount_usd * rate;
|
||||
crate::services::billing::ledger::apply_points_delta(
|
||||
db,
|
||||
user_id,
|
||||
grant_points,
|
||||
"recharge",
|
||||
None,
|
||||
Some("admin"),
|
||||
Some("recharge_record"),
|
||||
Some(public_id),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE user_recharge_records
|
||||
SET status = 'completed',
|
||||
note = '管理员已确认到账,积分已发放到用户账户。',
|
||||
completed_at = COALESCE(completed_at, datetime('now')),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(record_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
return fetch_recharge_record_value(db, record_id).await;
|
||||
}
|
||||
|
||||
// Legacy USD mode: credit the user's downstream key and flip status in one tx.
|
||||
let mut tx = db.begin().await?;
|
||||
let credited_key = credit_user_balance(&mut tx, user_id, amount_usd).await?;
|
||||
if credited_key.is_none() {
|
||||
@@ -3283,6 +3324,9 @@ mod tests {
|
||||
async fn admin_confirm_user_recharge_credits_key_and_is_idempotent() {
|
||||
let pool = payment_test_pool().await;
|
||||
create_user_recharge_records_table(&pool).await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// A pending comfy_cloud recharge record for user 9 ($25).
|
||||
sqlx::query(
|
||||
"INSERT INTO user_recharge_records
|
||||
@@ -3293,8 +3337,16 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// USD mode: switch off points so the legacy max_cost path runs.
|
||||
let config = PaymentConfig {
|
||||
comfy_recharge_to_points: false,
|
||||
..PaymentConfig::default()
|
||||
};
|
||||
|
||||
// Confirm → user key 7 max_cost goes 10 → 35, record completed.
|
||||
let record = admin_confirm_user_recharge(&pool, "rch_test_25").await.unwrap();
|
||||
let record = admin_confirm_user_recharge(&pool, &config, "rch_test_25")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(record["status"], "completed");
|
||||
let max_cost: f64 =
|
||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE id = 7")
|
||||
@@ -3304,7 +3356,9 @@ mod tests {
|
||||
assert_eq!(max_cost, 35.0);
|
||||
|
||||
// Idempotent: confirming again does not double-credit.
|
||||
let again = admin_confirm_user_recharge(&pool, "rch_test_25").await.unwrap();
|
||||
let again = admin_confirm_user_recharge(&pool, &config, "rch_test_25")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(again["status"], "completed");
|
||||
let max_cost_after: f64 =
|
||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE id = 7")
|
||||
@@ -3327,12 +3381,64 @@ mod tests {
|
||||
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_confirm_user_recharge_grants_points_by_default() {
|
||||
let pool = payment_test_pool().await;
|
||||
create_user_recharge_records_table(&pool).await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// A pending comfy_cloud recharge record for user 9 ($25).
|
||||
sqlx::query(
|
||||
"INSERT INTO user_recharge_records
|
||||
(public_id, user_id, amount_usd, provider, checkout_url, status)
|
||||
VALUES ('rch_points_25', 9, 25.0, 'comfy_cloud_checkout', 'https://checkout/x', 'pending')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Default config → comfy_recharge_to_points = true, rate 100.
|
||||
let config = PaymentConfig::default();
|
||||
let record = admin_confirm_user_recharge(&pool, &config, "rch_points_25")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(record["status"], "completed");
|
||||
|
||||
// Ledger gets a points/recharge row of +25*100 = 2500.
|
||||
let row: (String, String, f64) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount FROM credit_ledger
|
||||
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row, ("points".into(), "recharge".into(), 2500.0));
|
||||
|
||||
// user_credits cache reflects the granted points.
|
||||
let cached: f64 =
|
||||
sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cached, 2500.0);
|
||||
|
||||
// USD key max_cost untouched (was 10.0).
|
||||
let max_cost: f64 =
|
||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE id = 7")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(max_cost, 10.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_confirm_user_recharge_rejects_cancelled_and_missing() {
|
||||
let pool = payment_test_pool().await;
|
||||
create_user_recharge_records_table(&pool).await;
|
||||
let config = PaymentConfig::default();
|
||||
// Missing record → NotFound.
|
||||
let missing = admin_confirm_user_recharge(&pool, "rch_nope").await;
|
||||
let missing = admin_confirm_user_recharge(&pool, &config, "rch_nope").await;
|
||||
assert!(matches!(missing, Err(AppError::NotFound(_))));
|
||||
|
||||
// Cancelled record → BadRequest, no credit.
|
||||
@@ -3344,7 +3450,7 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let cancelled = admin_confirm_user_recharge(&pool, "rch_cancelled").await;
|
||||
let cancelled = admin_confirm_user_recharge(&pool, &config, "rch_cancelled").await;
|
||||
assert!(matches!(cancelled, Err(AppError::BadRequest(_))));
|
||||
let max_cost: f64 =
|
||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE id = 7")
|
||||
|
||||
Reference in New Issue
Block a user