From 37c6cf9e2a33de396f8fe5eb9b80f4105712ba7e Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 15 Oct 2026 14:07:27 +0100 Subject: [PATCH 1/2] feat: account credit closes #145 --- ADMIN_API_ENDPOINTS.md | 85 + API_CHANGELOG.md | 2 + API_DOCUMENTATION.md | 86 +- lnvps_api/src/api/credit.rs | 282 +++ lnvps_api/src/api/mod.rs | 76 + lnvps_api/src/api/model.rs | 72 + lnvps_api/src/api/routes.rs | 60 +- lnvps_api/src/api/subscriptions.rs | 2 + lnvps_api/src/bin/api.rs | 1 + lnvps_api/src/dvm/lnvps.rs | 3 + lnvps_api/src/payments/invoice.rs | 41 + lnvps_api/src/payments/onchain.rs | 113 +- lnvps_api/src/refund.rs | 288 ++- lnvps_api/src/subscription/mod.rs | 1765 ++++++++++++++--- lnvps_api/src/worker.rs | 41 +- lnvps_api_admin/src/admin/credit.rs | 208 ++ lnvps_api_admin/src/admin/mod.rs | 2 + lnvps_api_common/src/mock.rs | 654 ++++++ lnvps_api_common/src/pricing.rs | 52 +- .../20261015092358_account_credit.sql | 101 + ...092359_account_credit_rbac_permissions.sql | 20 + lnvps_db/src/lib.rs | 79 + lnvps_db/src/model.rs | 89 + lnvps_db/src/mysql.rs | 384 +++- lnvps_e2e/src/credit.rs | 410 ++++ lnvps_e2e/src/db.rs | 6 + lnvps_e2e/src/lib.rs | 1 + work/account-credit.md | 45 + 28 files changed, 4611 insertions(+), 357 deletions(-) create mode 100644 lnvps_api/src/api/credit.rs create mode 100644 lnvps_api_admin/src/admin/credit.rs create mode 100644 lnvps_db/migrations/20261015092358_account_credit.sql create mode 100644 lnvps_db/migrations/20261015092359_account_credit_rbac_permissions.sql create mode 100644 lnvps_e2e/src/credit.rs create mode 100644 work/account-credit.md diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index abac771c..30c8eda1 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -198,6 +198,90 @@ Response: Returns `404` if the user does not exist. +#### Get User Credit Balance + +``` +GET /api/admin/v1/users/{id}/credit +``` + +Required Permission: `account_credit::view` + +The user's account credit balance. + +```json +{ + "data": { + "user_id": 42, + "balance": 5000000, + "currency": "BTC", + "auto_apply": true + } +} +``` + +- `balance`: minor units of `currency` (msat for BTC). Never negative. +- `currency`: the balance's base currency. A quote converts it into the + payment currency at the current rate. +- `auto_apply`: whether the customer opted into spending the balance against + quotes automatically. + +A user who has never topped up returns a zero row, not a 404. + +#### Adjust User Credit + +``` +PATCH /api/admin/v1/users/{id}/credit +``` + +Required Permission: `account_credit::update` + +Shifts the balance and records the movement in the ledger. The request body +is `{ "delta": 5000000, "kind": "topup", "note": "goodwill grant" }`. +`delta` is signed msat (positive credits, negative claws back) and `kind` is +one of `topup`, `refund`, `admin`; a negative delta must use `admin`. Refuses +(`400`) a delta of 0, an unknown `kind`, a negative `topup`/`refund`, or a +clawback larger than the balance not held by pending orders. The response is the updated row in the same +shape as the GET. + +#### List User Credit Ledger + +``` +GET /api/admin/v1/users/{id}/credit/events +``` + +Required Permission: `account_credit::view` + +The append-only ledger behind the balance, newest first, paginated with the +standard `limit`/`offset`. Each row: + +```json +{ + "data": [ + { + "id": 7, + "kind": "spend", + "amount": -1000000, + "currency": "BTC", + "subscription_payment_id": "a1b2c3…", + "note": null, + "created": "2026-10-15T12:00:00Z" + } + ], + "total": 7, + "limit": 50, + "offset": 0 +} +``` + +- `kind`: `topup`, `spend`, `refund` or `admin`. `amount` is the signed change + to the balance: `spend` rows are negative, `topup` and `refund` positive, + `admin` either. +- `subscription_payment_id`: the payment the row is about, for `spend`; null + otherwise. A VM refund that returns credit writes a `refund` row whose + `note` names the payment. +- The balance always equals the ledger sum, so a mismatch is a bug and worth + reporting. + #### Revoke User Passkey ``` @@ -5878,6 +5962,7 @@ The RBAC system uses the following permission format: `resource::action` - `marketplace_operator` - Marketplace operator revenue share and payout configuration - `support_agent` - Support-agent transcripts and the agent's memory of a customer - `discount` - Discount campaigns: codes, their rules and redemption reporting +- `account_credit` - Customer account credit balances: read the balance and ledger, and adjust them ### Actions: diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index a4266431..c5a40512 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Account credit**: customers hold a balance (msat) and spend it against purchases, renewals and upgrades. `POST /api/v1/account/credit/topup {amount}` issues a Lightning invoice and credits the balance when it settles. `GET /api/v1/account/credit` returns the balance, currency and `auto_apply`; `PATCH /api/v1/account/credit` toggles it; `GET /api/v1/account/credit/events` lists the signed ledger. When opted in, renewal and upgrade quotes (`GET /api/v1/vm/{id}/renew/quote`, `GET /api/v1/subscriptions/{id}/renew/quote`, `POST /api/v1/vm/{id}/upgrade/quote`) and the payments they create carry a `credit` object with the amount covered in the bill's currency; the external invoice is for the remainder and the processing fee is charged only on it. A fully credit-funded payment settles immediately and its `data` is `"credit"`. Credit on an unpaid order is held until the order expires. Auto-renewal uses credit when it covers the whole renewal. Refunds of credit-funded VM payments return the credit share to the balance. Admins read and adjust balances through `GET`/`PATCH /api/admin/v1/users/{id}/credit` and `GET /api/admin/v1/users/{id}/credit/events`, gated by the `account_credit` permission (granted to `super_admin`). + - **Upgrade options**: `GET /api/v1/vm/{id}/upgrade/options` returns the `CustomTemplateParams` of the plan an upgrade of the VM is priced and limited by, so an upgrade form can bound its inputs without guessing the plan. - **Bandwidth in custom pricing**: custom VMs can be ordered and upgraded with more monthly transfer and network speed. `POST /api/v1/vm/custom-template`, `POST /api/v1/vm/custom-template/price` and `POST /api/admin/v1/vms/custom` accept `transfer_gb` and `network_mbps`; `POST /api/v1/vm/{id}/upgrade` and `/upgrade/quote` accept the same two fields, and an upgrade that changes only bandwidth no longer restarts the VM. `CustomTemplateParams` on `GET /api/v1/vm/templates` gains `transfer` and `network` ranges. Custom pricing plans gain `min_transfer_gb`, `max_transfer_gb`, `transfer_cost_per_tb`, `min_network_mbps`, `max_network_mbps` and `network_cost_per_gbps` on `GET`/`POST`/`PATCH /api/admin/v1/custom_pricing` and the copy endpoint. Existing plans keep a fixed range at their current value and a cost of 0, so no price changes until one is set. diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index a1914b49..2456acda 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -791,6 +791,90 @@ and React example. `{provider}` is one of the enabled tags (`google`, `github`, - **Notes**: Removes the stored number, clears verification state and sets `contact_whatsapp` to `false`. - **Response**: `null` +### Account Credit + +A balance you top up and spend against purchases, renewals and upgrades, in the +same flow as a saved wallet. The balance is held in msat and converted into +the payment currency of whatever you buy at the current rate. + +#### Get Credit Balance +- **GET** `/api/v1/account/credit` +- **Auth**: Required +- **Response**: +```json +{ + "data": { + "balance": 5000000, + "currency": "BTC", + "auto_apply": true + } +} +``` +- `balance`: your available credit, in msat. Never negative. +- `auto_apply`: when true, every quote automatically spends as much credit as + the bill allows; when false the balance waits for you to opt in. + +#### Toggle Auto-Apply +- **PATCH** `/api/v1/account/credit` +- **Auth**: Required +- **Body**: `{ "auto_apply": bool }` +- **Response**: the balance object above, reflecting the new flag. + +#### Top Up +- **POST** `/api/v1/account/credit/topup` +- **Auth**: Required +- **Body**: `{ "amount": 10000000 }` (msat, rounded up to a whole sat; minimum 1000) +- **Notes**: Issues a Lightning invoice. Pay it with any wallet; on settlement + the balance rises by the amount paid. A top-up is not tied to any + subscription and never appears on a VM's payment history. +- **Response**: +```json +{ + "data": { + "pr": "lnbc...", + "payment_hash": "...", + "amount": 10000000, + "currency": "BTC", + "expires": "2026-10-15T12:10:00Z" + } +} +``` + +#### Credit Ledger +- **GET** `/api/v1/account/credit/events?limit=50&offset=0` +- **Auth**: Required +- **Notes**: Every movement of your balance, newest first. `kind` is one of + `topup`, `spend`, `refund`, `admin`; `amount` is the signed change to the + balance (`spend` is negative). `subscription_payment_id` is set on `spend` + rows to the payment the credit went to. +- **Response**: standard paginated wrapper of + `{ kind, amount, currency, subscription_payment_id, note, created }` rows. + +#### How credit is spent + +When `auto_apply` is on and you hold a balance, quotes for renewals, purchases +and upgrades show a `credit` line (renewal quotes) or a `credit` object on the +payment response: + +```json +{ "amount": 500000, "currency": "BTC" } +``` + +That is the part of the bill your balance covers, in the bill's currency. The +invoice you are asked to pay covers the remainder, and the processing fee is +charged only on that remainder. A bill fully covered by credit needs no payment +at all: it settles the moment it is ordered and its payment `data` is +`"credit"`. If the remainder would fall below the payment method's minimum, +less credit is used so the remainder meets it. + +Credit applied to an unpaid order is held until the order expires, so the same +credit is never offered to two orders at once. If an order is paid after it +expired and the credit has since gone elsewhere, only what is left is spent and +the rest of the bill buys less time (on-chain) or is absorbed. Auto-renewal +uses credit when it covers the whole renewal, otherwise your saved wallet or +card pays the remainder. A refund of a VM paid partly with credit returns the +credit share to your balance and the cash share to your invoice. + ### Automatic Renewal with Nostr Wallet Connect The LNVPS platform supports automatic VM renewal using Nostr Wallet Connect (NWC). This feature allows users to set up their Lightning wallets to automatically pay for VM renewals before expiration. @@ -983,7 +1067,7 @@ historical range is needed; a usage bar can be rendered from this object alone. - `method`: Optional payment method ('lightning' | 'revolut' | 'paypal'). Defaults to 'lightning' - **Body**: `VmUpgradeRequest` - **Response**: `VmUpgradeQuote` -- **Description**: Calculate the pro-rated upgrade cost for remaining VM time and the new monthly renewal cost after upgrade. Available for both standard template VMs and custom template VMs. Like the upgrade endpoint itself, the request must strictly increase at least one resource (no downgrades). Cost is calculated in the currency appropriate for the selected payment method. The response includes the upgrade cost (cost_difference), new renewal cost, and the discount amount representing the value of remaining time at the old pricing rate. +- **Description**: Calculate the pro-rated upgrade cost for remaining VM time and the new monthly renewal cost after upgrade. Available for both standard template VMs and custom template VMs. Like the upgrade endpoint itself, the request must strictly increase at least one resource (no downgrades). Cost is calculated in the currency appropriate for the selected payment method. The response includes the upgrade cost (cost_difference), new renewal cost, and the discount amount representing the value of remaining time at the old pricing rate. When account credit is auto-applied the response also carries `credit` (`{ amount, currency }`), and `processing_fee` is the fee on the remainder. #### Create VM Upgrade Payment - **POST** `/api/v1/vm/{id}/upgrade?method={payment_method}` diff --git a/lnvps_api/src/api/credit.rs b/lnvps_api/src/api/credit.rs new file mode 100644 index 00000000..f36c5e7e --- /dev/null +++ b/lnvps_api/src/api/credit.rs @@ -0,0 +1,282 @@ +//! Customer-facing account credit: the balance a user tops up and spends +//! against purchases, renewals and upgrades. See `work/account-credit.md` and +//! issue #145. +//! +//! A top-up is a plain Lightning invoice the customer pays. It is not a +//! `subscription_payment`: it belongs to no subscription and extends nothing, +//! so it lives in its own table and is settled by the same Lightning listener +//! that settles subscription payments. When the invoice settles the user's +//! `account_credit.balance` increases and a `topup` ledger event is written. + +use axum::extract::{Query, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use payments_rs::lightning::AddInvoiceRequest; +use serde::{Deserialize, Serialize}; + +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, Nip98Auth, PageQuery, + round_msat_to_sat, +}; +use lnvps_db::{AccountCredit, AccountCreditEvent, AccountCreditTopup}; + +use crate::api::RouterState; + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/account/credit", + get(v1_get_credit).patch(v1_patch_credit), + ) + .route("/api/v1/account/credit/topup", post(v1_topup_credit)) + .route("/api/v1/account/credit/events", get(v1_list_credit_events)) +} + +/// The customer's account credit balance and opt-in flag. +#[derive(Serialize)] +pub struct ApiAccountCredit { + /// Minor units of `currency` (msat for BTC). + pub balance: u64, + /// ISO currency code of the balance. + pub currency: String, + /// Whether the balance is auto-applied against quotes. + pub auto_apply: bool, +} + +impl From for ApiAccountCredit { + fn from(c: AccountCredit) -> Self { + Self { + balance: c.balance, + currency: c.currency, + auto_apply: c.auto_apply, + } + } +} + +/// One row of the account credit ledger, as shown to the customer. +#[derive(Serialize)] +pub struct ApiAccountCreditEvent { + pub kind: String, + /// Signed change to the balance. + pub amount: i64, + pub currency: String, + /// The `subscription_payment` this event is about, for `spend` and + /// `refund` rows. `null` for `topup` and `admin` rows. + pub subscription_payment_id: Option, + pub note: Option, + pub created: chrono::DateTime, +} + +impl From for ApiAccountCreditEvent { + fn from(e: AccountCreditEvent) -> Self { + Self { + kind: e.kind, + amount: e.amount, + currency: e.currency, + subscription_payment_id: e.subscription_payment_id.as_ref().map(hex::encode), + note: e.note, + created: e.created, + } + } +} + +/// The top-up response: a Lightning invoice the customer pays to increase +/// their balance. +#[derive(Serialize)] +pub struct ApiCreditTopup { + /// BOLT11 payment request to pay. + pub pr: String, + /// The top-up's payment hash, for reference and later status lookups. + pub payment_hash: String, + /// Msat the invoice is for. + pub amount: u64, + pub currency: String, + /// ISO-8601 time the invoice expires. + pub expires: chrono::DateTime, +} + +#[derive(Deserialize)] +struct TopupRequest { + /// Msat to top up, rounded up to a whole sat. + amount: u64, +} + +#[derive(Deserialize)] +struct PatchCreditRequest { + auto_apply: bool, +} + +/// Top up the account credit balance with a Lightning invoice. +async fn v1_topup_credit( + auth: Nip98Auth, + State(this): State, + Json(req): Json, +) -> ApiResult { + const MIN_TOPUP_MSAT: u64 = 1_000; + if req.amount < MIN_TOPUP_MSAT { + return ApiData::err(&format!( + "Top-up amount must be at least {MIN_TOPUP_MSAT} msat" + )); + } + let amount = round_msat_to_sat(req.amount); + + let pubkey = auth.pubkey(); + let uid = this.db.upsert_user(&pubkey).await?; + + let credit = this.db.get_account_credit(uid).await?; + let currency = if credit.currency.is_empty() { + "BTC".to_string() + } else { + credit.currency + }; + + let invoice = this + .sub_handler + .node() + .add_invoice(AddInvoiceRequest { + memo: Some("LNVPS credit top-up".to_string()), + amount, + expire: Some(600), + }) + .await + .map_err(|e| ApiError::new(format!("Failed to create invoice: {e}")))?; + + let hash = hex::decode(invoice.payment_hash()) + .map_err(|e| ApiError::new(format!("Failed to decode payment hash: {e}")))?; + + let topup = AccountCreditTopup { + user_id: uid, + amount, + currency: currency.clone(), + invoice_hash: hash.clone(), + external_data: invoice.pr(), + ..Default::default() + }; + this.db.insert_account_credit_topup(&topup).await?; + + ApiData::ok(ApiCreditTopup { + pr: invoice.pr(), + payment_hash: hex::encode(&hash), + amount, + currency, + expires: chrono::Utc::now() + chrono::Duration::seconds(600), + }) +} + +/// The customer's current balance and opt-in flag. +async fn v1_get_credit( + auth: Nip98Auth, + State(this): State, +) -> ApiResult { + let pubkey = auth.pubkey(); + let uid = this.db.upsert_user(&pubkey).await?; + let credit = this.db.get_account_credit(uid).await?; + ApiData::ok(ApiAccountCredit::from(credit)) +} + +/// Toggle auto-apply for the customer's balance. +async fn v1_patch_credit( + auth: Nip98Auth, + State(this): State, + Json(req): Json, +) -> ApiResult { + let pubkey = auth.pubkey(); + let uid = this.db.upsert_user(&pubkey).await?; + let credit = this + .db + .set_account_credit_auto_apply(uid, req.auto_apply) + .await?; + ApiData::ok(ApiAccountCredit::from(credit)) +} + +/// The customer's account credit ledger, newest first, paginated. +async fn v1_list_credit_events( + auth: Nip98Auth, + State(this): State, + Query(q): Query, +) -> ApiPaginatedResult { + let pubkey = auth.pubkey(); + let uid = this.db.upsert_user(&pubkey).await?; + let limit = q.limit.unwrap_or(50).min(100); + let offset = q.offset.unwrap_or(0); + + let (events, total) = this + .db + .list_account_credit_events(uid, limit, offset) + .await + .map_err(|e| lnvps_api_common::ApiError::new(e.to_string()))?; + let out: Vec = events + .into_iter() + .map(ApiAccountCreditEvent::from) + .collect(); + ApiPaginatedData::ok(out, total, limit, offset) +} + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_api_common::MockDb; + use lnvps_db::LNVpsDbBase; + use std::sync::Arc; + + /// The API view of a fresh user's credit is the zero-balance row. + #[tokio::test] + async fn api_view_of_unknown_user_is_zero() { + let db = Arc::new(MockDb::default()); + let uid = db.upsert_user(&[1u8; 32]).await.unwrap(); + let api = ApiAccountCredit::from(db.get_account_credit(uid).await.unwrap()); + assert_eq!(api.balance, 0); + assert_eq!(api.currency, "BTC"); + assert!(!api.auto_apply); + } + + /// Toggling auto-apply is reflected in the API view. + #[tokio::test] + async fn api_view_reflects_auto_apply() { + let db = Arc::new(MockDb::default()); + let uid = db.upsert_user(&[2u8; 32]).await.unwrap(); + let api = + ApiAccountCredit::from(db.set_account_credit_auto_apply(uid, true).await.unwrap()); + assert!(api.auto_apply); + } + + /// A ledger row's payment hash is hex-encoded in the API view, and a topup + /// (no payment) shows `null`. + #[tokio::test] + async fn api_event_encodes_payment_hash() { + let spend = AccountCreditEvent { + kind: "spend".to_string(), + amount: 500, + subscription_payment_id: Some(vec![7u8; 32]), + ..Default::default() + }; + let api = ApiAccountCreditEvent::from(spend); + assert_eq!( + api.subscription_payment_id.as_deref(), + Some(hex::encode(vec![7u8; 32]).as_str()) + ); + + let topup = AccountCreditEvent { + kind: "topup".to_string(), + ..Default::default() + }; + assert!( + ApiAccountCreditEvent::from(topup) + .subscription_payment_id + .is_none() + ); + } + + /// The pagination parameters clamp to [1..=100] for the ledger. + #[test] + fn credit_event_page_clamps() { + let q = PageQuery { + limit: Some(1000), + offset: None, + }; + let limit = q.limit.unwrap_or(50).min(100); + let offset = q.offset.unwrap_or(0); + assert_eq!(limit, 100); + assert_eq!(offset, 0); + } +} diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index 123160c1..1c510b73 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -1,6 +1,7 @@ mod app_backups; mod apps; mod contact; +mod credit; mod docs; mod ip_space; mod legal; @@ -29,6 +30,7 @@ use crate::subscription::SubscriptionHandler; pub use app_backups::router as app_backups_router; pub use apps::router as apps_router; pub use contact::router as contacts_router; +pub use credit::router as credit_router; pub use docs::router as docs_router; pub use ip_space::router as ip_space_router; pub use legal::router as legal_router; @@ -197,6 +199,22 @@ pub(crate) async fn discount_line( }) } +/// The account credit covering part of a payment, in the payment's currency. +pub(crate) async fn credit_line( + db: &std::sync::Arc, + payment: &lnvps_db::SubscriptionPayment, +) -> Option { + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .ok() + .flatten()?; + Some(crate::api::model::ApiCredit { + amount: debit.payment_amount, + currency: payment.currency.clone(), + }) +} + /// Resolve a payment-method query for *pricing only*. /// /// Same mapping as [`resolve_payment_mode`] — `nwc` prices as Lightning, `saved` @@ -437,6 +455,7 @@ mod tests { amount_off: 100, currency: Currency::EUR, }), + credit: None, }; let out: ApiRenewalQuote = quote.into(); assert_eq!(out.amount, 900); @@ -457,6 +476,62 @@ mod tests { assert!(!serde_json::to_string(&out).unwrap().contains("discount")); } + #[tokio::test] + async fn credit_line_reports_the_payment_currency() { + use lnvps_db::LNVpsDbBase; + let mock = lnvps_api_common::MockDb::default(); + mock.adjust_account_credit(1, 5_000_000, "topup", None) + .await + .unwrap(); + let id = mock + .reserve_account_credit(&lnvps_db::AccountCreditDebit { + user_id: 1, + amount: 5_000_000, + currency: "BTC".to_string(), + payment_amount: 500, + expires: chrono::Utc::now() + chrono::Duration::hours(1), + ..Default::default() + }) + .await + .unwrap() + .unwrap(); + mock.attach_account_credit_debit(id, &vec![3u8; 32], chrono::Utc::now()) + .await + .unwrap(); + let db: std::sync::Arc = std::sync::Arc::new(mock); + let mut payment = lnvps_db::SubscriptionPayment { + id: vec![3u8; 32], + subscription_id: 1, + user_id: 1, + created: chrono::Utc::now(), + expires: chrono::Utc::now(), + amount: 1000, + currency: "EUR".to_string(), + payment_method: lnvps_db::PaymentMethod::Revolut, + payment_type: lnvps_db::SubscriptionPaymentType::Renewal, + external_data: "{}".to_string().into(), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: None, + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + refunded_payment_id: None, + renewal_source: None, + }; + let line = credit_line(&db, &payment).await.expect("credit line"); + assert_eq!((line.amount, line.currency.as_str()), (500, "EUR")); + payment.id = vec![4u8; 32]; + assert!(credit_line(&db, &payment).await.is_none()); + } + fn quote_without_discount() -> crate::subscription::RenewalQuote { crate::subscription::RenewalQuote { amount: 1_000, @@ -468,6 +543,7 @@ mod tests { payment_type: lnvps_db::SubscriptionPaymentType::Renewal, tax_lines: vec![], discount: None, + credit: None, } } } diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 5bba20a6..8bae7dad 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -419,6 +419,11 @@ pub struct ApiVmPayment { /// further reduction to apply. #[serde(skip_serializing_if = "Option::is_none")] pub discount: Option, + /// Account credit applied to this payment, when the balance covered part of + /// it. A payment method, not a price change: `amount` is the full (discounted) + /// bill; `credit.amount` is the part the balance covered. + #[serde(skip_serializing_if = "Option::is_none")] + pub credit: Option, } /// A priced renewal order, computed without creating a payment. @@ -443,6 +448,12 @@ pub struct ApiRenewalQuote { /// further reduction to apply. #[serde(skip_serializing_if = "Option::is_none")] pub discount: Option, + /// Account credit that would be applied, when the user opted in and has a + /// positive balance. A payment method, not a price change: `amount`, `tax` + /// and `processing_fee` are the full bill; `credit.amount` is the part + /// covered by the balance, so the external invoice is for the remainder. + #[serde(skip_serializing_if = "Option::is_none")] + pub credit: Option, } impl From for ApiRenewalQuote { @@ -457,10 +468,21 @@ impl From for ApiRenewalQuote { code: d.code, amount_off: d.amount_off, }), + credit: q.credit.map(|c| ApiCredit { + amount: c.amount, + currency: c.currency.to_string(), + }), } } } +/// Account credit covering part of a bill, in the bill's currency. +#[derive(Serialize, Deserialize)] +pub struct ApiCredit { + pub amount: u64, + pub currency: String, +} + /// A discount applied to a payment. #[derive(Serialize, Deserialize)] pub struct ApiDiscount { @@ -570,6 +592,8 @@ impl ApiVmPayment { // payment list down with it (this converter is collected with `?`). let data = if is_refund { ApiPaymentData::Lightning(String::new()) + } else if value.external_data.as_str() == crate::subscription::CREDIT_FUNDED_MARKER { + ApiPaymentData::Credit } else { match &value.payment_method { PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data.into()), @@ -622,6 +646,7 @@ impl ApiVmPayment { is_refund, // Filled by handlers that have the database to look it up. discount: None, + credit: None, }) } } @@ -685,6 +710,8 @@ pub enum ApiPaymentData { #[serde(skip_serializing_if = "Option::is_none")] outpoint: Option, }, + /// Paid in full from account credit; nothing to pay externally. + Credit, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -916,6 +943,9 @@ pub struct ApiVmUpgradeQuote { pub tax: ApiPrice, /// Payment processing fee added on top (zero for Lightning). pub processing_fee: ApiPrice, + /// Account credit that would cover part of the upgrade. + #[serde(skip_serializing_if = "Option::is_none")] + pub credit: Option, } // ============================================================================ @@ -1294,6 +1324,8 @@ pub struct ApiSubscriptionPayment { /// (and `tax`) are already net of it. #[serde(skip_serializing_if = "Option::is_none")] pub discount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credit: Option, } #[derive(Serialize, Deserialize)] @@ -1332,6 +1364,9 @@ impl From for ApiSubscriptionPayment { // Surface the payment-method data (e.g. the Lightning invoice) so callers // can actually pay. Non-lightning external data is best-effort parsed. let data = match &payment.payment_method { + _ if payment.external_data.as_str() == crate::subscription::CREDIT_FUNDED_MARKER => { + ApiPaymentData::Credit + } PaymentMethod::Lightning => { ApiPaymentData::Lightning(payment.external_data.clone().into()) } @@ -1377,6 +1412,7 @@ impl From for ApiSubscriptionPayment { data, // Filled by handlers that have the database to look it up. discount: None, + credit: None, } } } @@ -1724,6 +1760,42 @@ mod tests { assert!(!eur.rates.contains_key("EUR")); } + #[test] + fn a_credit_funded_payment_has_credit_data() { + let payment = lnvps_db::SubscriptionPayment { + id: vec![1u8; 32], + subscription_id: 1, + user_id: 1, + created: Utc::now(), + expires: Utc::now(), + amount: 1000, + currency: "EUR".to_string(), + payment_method: PaymentMethod::Revolut, + payment_type: lnvps_db::SubscriptionPaymentType::Renewal, + external_data: crate::subscription::CREDIT_FUNDED_MARKER.to_string().into(), + external_id: None, + is_paid: true, + rate: 1.0, + time_value: Some(2_592_000), + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + refunded_payment_id: None, + renewal_source: None, + }; + let vm = ApiVmPayment::from_subscription_payment(payment.clone(), 1).unwrap(); + assert!(matches!(vm.data, ApiPaymentData::Credit)); + assert_eq!(serde_json::to_value(&vm).unwrap()["data"], "credit"); + let sub = ApiSubscriptionPayment::from(payment); + assert!(matches!(sub.data, ApiPaymentData::Credit)); + } + /// On-chain payment data exposes the deposit outpoint (`{txid}:{vout}`) once /// a deposit is seen (external_id set), before confirmation, and omits it /// while no deposit has arrived. diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 4e50b9f8..1aeb6b0f 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1524,8 +1524,10 @@ async fn v1_renew_vm( .await?; let discount = crate::api::discount_line(&this.db, &payment.id).await; + let credit = crate::api::credit_line(&this.db, &payment).await; let mut out = ApiVmPayment::from_subscription_payment(payment, id)?; out.discount = discount; + out.credit = credit; ApiData::ok(out) } @@ -2486,31 +2488,42 @@ async fn v1_vm_upgrade_quote( let cfg: UpgradeConfig = req.into(); - // Calculate the upgrade cost and new renewal cost - match this + let method = q + .method + .and_then(|m| PaymentMethod::from_str(&m).ok()) + .unwrap_or(PaymentMethod::Lightning); + let quote = match this .sub_handler .pricing_engine() - .calculate_vm_upgrade_cost( - id, - &cfg, - q.method - .and_then(|m| PaymentMethod::from_str(&m).ok()) - .unwrap_or(PaymentMethod::Lightning), - ) + .calculate_vm_upgrade_cost(id, &cfg, method) .await { - Ok(quote) => { - let currency = quote.upgrade.amount.currency(); - ApiData::ok(ApiVmUpgradeQuote { - cost_difference: quote.upgrade.amount.into(), - new_renewal_cost: quote.renewal.amount.into(), - discount: quote.discount.amount.into(), - tax: CurrencyAmount::from_u64(currency, quote.tax.amount).into(), - processing_fee: CurrencyAmount::from_u64(currency, quote.processing_fee).into(), - }) - } - Err(e) => ApiData::err(e.to_string().as_str()), - } + Ok(quote) => quote, + Err(e) => return ApiData::err(e.to_string().as_str()), + }; + let currency = quote.upgrade.amount.currency(); + let (processing_fee, credit) = this + .sub_handler + .quote_vm_upgrade_credit( + id, + method, + quote.upgrade.amount.value(), + quote.tax.amount, + quote.processing_fee, + currency, + ) + .await?; + ApiData::ok(ApiVmUpgradeQuote { + cost_difference: quote.upgrade.amount.into(), + new_renewal_cost: quote.renewal.amount.into(), + discount: quote.discount.amount.into(), + tax: CurrencyAmount::from_u64(currency, quote.tax.amount).into(), + processing_fee: CurrencyAmount::from_u64(currency, processing_fee).into(), + credit: credit.map(|c| crate::api::model::ApiCredit { + amount: c.amount, + currency: c.currency.to_string(), + }), + }) } /// Upgrade a VM (requires payment first) @@ -2539,7 +2552,10 @@ async fn v1_vm_upgrade( .await?; // Note: The actual upgrade happens after payment is confirmed - ApiData::ok(ApiVmPayment::from_subscription_payment(payment, id)?) + let credit = crate::api::credit_line(&this.db, &payment).await; + let mut out = ApiVmPayment::from_subscription_payment(payment, id)?; + out.credit = credit; + ApiData::ok(out) } /// Resolve the monthly outbound transfer quota for a VM from its (custom) diff --git a/lnvps_api/src/api/subscriptions.rs b/lnvps_api/src/api/subscriptions.rs index f516ee59..d2491511 100644 --- a/lnvps_api/src/api/subscriptions.rs +++ b/lnvps_api/src/api/subscriptions.rs @@ -368,8 +368,10 @@ async fn v1_renew_subscription( .map_err(|e| anyhow::anyhow!("Failed to generate payment: {}", e))?; let discount = crate::api::discount_line(&this.db, &payment.id).await; + let credit = crate::api::credit_line(&this.db, &payment).await; let mut out = ApiSubscriptionPayment::from(payment); out.discount = discount; + out.credit = credit; ApiData::ok(out) } diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 94ab5bee..1636c5bd 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -480,6 +480,7 @@ async fn main() -> Result<(), Error> { .merge(subscriptions_router()) .merge(ip_space_router()) .merge(referral_router()) + .merge(credit_router()) .merge(marketplace_router()) .merge(apps_router()) .merge(app_backups_router()) diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index d19aa238..b81e5b29 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -141,6 +141,9 @@ impl DVMHandler for LnvpsDvm { let invoice = sub_handler .renew_subscription(line_item.subscription_id, PaymentMethod::Lightning, 1) .await?; + if invoice.is_paid { + return Ok(()); + } let mut payment = build_status_for_job( &request, diff --git a/lnvps_api/src/payments/invoice.rs b/lnvps_api/src/payments/invoice.rs index 573e9563..4381b43e 100644 --- a/lnvps_api/src/payments/invoice.rs +++ b/lnvps_api/src/payments/invoice.rs @@ -27,6 +27,22 @@ impl NodeInvoiceHandler { } async fn mark_paid(&self, id: &Vec) -> Result<()> { + if let Some(topup) = self.db.get_account_credit_topup_by_hash(id).await? { + if !topup.is_paid { + info!( + "Settling credit top-up {} for user {}", + topup.id, topup.user_id + ); + match self.db.settle_account_credit_topup(topup.id).await? { + Some(t) => info!( + "Credit top-up {} settled: +{} {} to user {}", + t.id, t.amount, t.currency, t.user_id + ), + None => info!("Credit top-up {} already settled", topup.id), + } + } + return Ok(()); + } let payment = self.db.get_subscription_payment(id).await?; self.complete(&payment).await } @@ -231,6 +247,31 @@ mod tests { Ok((db, node, sub, payment, vm_id)) } + #[tokio::test] + async fn mark_paid_settles_a_credit_topup_once() -> Result<()> { + let (db, node, sub, payment, _vm_id) = + setup_renewal(86400, SubscriptionPaymentType::Renewal).await?; + let hash = vec![9u8; 32]; + db.insert_account_credit_topup(&lnvps_db::AccountCreditTopup { + user_id: payment.user_id, + amount: 21_000, + currency: "BTC".to_string(), + invoice_hash: hash.clone(), + ..Default::default() + }) + .await?; + let handler = NodeInvoiceHandler::new(node, db.clone(), sub); + + handler.mark_paid(&hash).await?; + handler.mark_paid(&hash).await?; + assert_eq!( + db.get_account_credit(payment.user_id).await?.balance, + 21_000 + ); + assert!(!db.get_subscription_payment(&payment.id).await?.is_paid); + Ok(()) + } + /// complete for a Renewal payment marks it paid and enqueues CheckVm. #[tokio::test] async fn test_complete_renewal_marks_paid_and_enqueues_check_vm() -> Result<()> { diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index dc5ee6c4..e91f2261 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -83,6 +83,20 @@ impl OnChainPaymentHandler { /// Find the payment a deposit belongs to: the most recent on-chain payment /// whose stored receive address matches. + /// Spend the credit held on `payment` and return what it covered, in msat. + async fn claim_credit_msat(&self, payment: &SubscriptionPayment) -> Result { + if let Some(d) = self.db.settle_account_credit_debit(&payment.id).await? { + return Ok(d.payment_amount); + } + Ok(self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await? + .filter(|d| d.settled) + .map(|d| d.payment_amount) + .unwrap_or(0)) + } + async fn payment_for_address(&self, address: &str) -> Result> { Ok(self .db @@ -187,7 +201,14 @@ impl OnChainPaymentHandler { { return Ok(()); } - self.regenerate(&mut payment, amount_msat).await?; + let credit_msat = self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await? + .map(|d| d.payment_amount) + .unwrap_or(0); + self.regenerate(&mut payment, amount_msat + credit_msat) + .await?; info!( "Deposit {} detected: priced payment {} at rate {} for {} msat ({}s)", key, @@ -300,9 +321,10 @@ impl OnChainPaymentHandler { let mut payment = payment; // Price now unless Detected already did (and for the same gross // amount — a replaced tx could change the output value). + let gross = amount_msat + self.claim_credit_msat(&payment).await?; let priced_gross = payment.amount + payment.tax + payment.processing_fee; - if !already_priced || priced_gross != amount_msat { - self.regenerate(&mut payment, amount_msat).await?; + if !already_priced || priced_gross != gross { + self.regenerate(&mut payment, gross).await?; } info!( "Settling on-chain payment {}: {} msat at rate {} ({}s)", @@ -664,6 +686,91 @@ mod tests { Ok(()) } + async fn hold_credit(db: &MockDb, payment: &SubscriptionPayment, msat: u64) { + db.adjust_account_credit(payment.user_id, msat as i64, "topup", None) + .await + .unwrap(); + let id = db + .reserve_account_credit(&lnvps_db::AccountCreditDebit { + user_id: payment.user_id, + amount: msat, + currency: "BTC".to_string(), + payment_amount: msat, + expires: payment.expires, + ..Default::default() + }) + .await + .unwrap() + .unwrap(); + db.attach_account_credit_debit(id, &payment.id, payment.expires) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_credit_covered_deposit_prices_the_whole_bill() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + hold_credit(&db, &payment, 400_000).await; + let expect = engine_price(&handler, payment.subscription_id, EXPECTED).await?; + + handler + .handle_detected(ADDRESS, "tx1", 0, EXPECTED - 400_000) + .await?; + handler + .handle_deposit(ADDRESS, "tx1", 0, EXPECTED - 400_000) + .await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED); + assert_close(p.time_value.unwrap(), expect.time_value); + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await? + .unwrap(); + assert!(debit.settled); + assert_eq!(db.get_account_credit(payment.user_id).await?.balance, 0); + Ok(()) + } + + #[tokio::test] + async fn test_late_deposit_gets_only_the_credit_left() -> Result<()> { + let (db, _provider, handler, payment) = setup().await?; + hold_credit(&db, &payment, 400_000).await; + db.attach_account_credit_debit( + db.get_account_credit_debit_by_payment(&payment.id) + .await? + .unwrap() + .id, + &payment.id, + Utc::now() - chrono::Duration::seconds(1), + ) + .await?; + db.reserve_account_credit(&lnvps_db::AccountCreditDebit { + user_id: payment.user_id, + amount: 300_000, + currency: "BTC".to_string(), + payment_amount: 300_000, + expires: Utc::now() + chrono::Duration::hours(1), + ..Default::default() + }) + .await? + .unwrap(); + + handler + .handle_deposit(ADDRESS, "tx1", 0, EXPECTED - 400_000) + .await?; + + let p = get_payment(&db, &payment.id).await; + assert!(p.is_paid); + assert_eq!(p.amount + p.tax + p.processing_fee, EXPECTED - 300_000); + assert_eq!( + db.get_account_credit(payment.user_id).await?.balance, + 300_000 + ); + Ok(()) + } + /// Half the deposit buys half the time (engine pricing is linear). #[tokio::test] async fn test_partial_deposit_buys_less_time() -> Result<()> { diff --git a/lnvps_api/src/refund.rs b/lnvps_api/src/refund.rs index 90a64659..b523763e 100644 --- a/lnvps_api/src/refund.rs +++ b/lnvps_api/src/refund.rs @@ -16,8 +16,7 @@ use anyhow::{Result, anyhow, bail, ensure}; use chrono::{DateTime, Utc}; use lightning_invoice::Bolt11Invoice; use lnvps_api_common::{ - PricingEngine, RefundEvidence, VmHistoryLogger, allocate_refund, build_refund_row, - refundable_remaining, + PricingEngine, RefundEvidence, VmHistoryLogger, build_refund_row, refundable_remaining, }; use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment}; use log::{error, info}; @@ -44,6 +43,85 @@ pub struct RefundOutcome { pub currency: String, /// How many refund rows were written. pub refund_rows: u64, + /// Account credit returned for the share of the reversed payments that + /// credit paid for, in the balance currency's minor units. + pub credit_returned: u64, +} + +/// One payment a refund reverses: `amount` is booked against it, `cash` of +/// that goes out over Lightning and `credit` (balance currency) goes back to +/// the customer's account credit. +#[derive(Debug, Clone)] +pub struct RefundShare { + pub payment: SubscriptionPayment, + pub amount: u64, + pub cash: u64, + pub credit: u64, +} + +/// A payment that can absorb a refund: what is still refundable on it and +/// the credit that paid for part of it. +pub struct RefundCandidate { + pub payment: SubscriptionPayment, + pub remaining: u64, + pub debit: Option, +} + +/// Split a cash refund of `cash_total` across `candidates`, newest first. A +/// payment that credit partly paid for is reversed in proportion: the cash +/// returned never exceeds the cash it collected, and the credit share is +/// returned as credit. +pub fn allocate_cash_refund( + candidates: &[RefundCandidate], + cash_total: u64, +) -> Result> { + let mut left = cash_total; + let mut out = Vec::new(); + for c in candidates { + if left == 0 { + break; + } + let billable = (c.payment.amount + c.payment.tax) as u128; + let credit_paid = c + .debit + .as_ref() + .filter(|d| d.settled) + .map(|d| (d.payment_amount as u128).min(billable)) + .unwrap_or(0); + let cash_paid = billable - credit_paid; + if billable == 0 || cash_paid == 0 { + continue; + } + let cash_capacity = (c.remaining as u128 * cash_paid / billable) as u64; + let cash = cash_capacity.min(left); + if cash == 0 { + continue; + } + let amount = if cash == cash_capacity { + c.remaining + } else { + ((cash as u128 * billable).div_ceil(cash_paid) as u64).min(c.remaining) + }; + let credit = match &c.debit { + Some(d) if d.payment_amount > 0 => { + ((amount - cash) as u128 * d.amount as u128 / d.payment_amount as u128) as u64 + } + _ => 0, + }; + left -= cash; + out.push(RefundShare { + payment: c.payment.clone(), + amount, + cash, + credit, + }); + } + ensure!( + left == 0, + "refund of {cash_total} exceeds the {} still refundable in cash on this VM's payments", + cash_total - left + ); + Ok(out) } #[derive(Clone)] @@ -138,6 +216,18 @@ impl VmRefundHandler { .allocation(vm_id, ¤cy, booked.value()) .await .map_err(|e| anyhow!("VM {} refund cannot be booked, nothing paid: {}", vm_id, e))?; + let planned: u64 = plan.iter().map(|s| s.amount).sum(); + let owed = quote.rate.convert(CurrencyAmount::millisats(owed_msat))?; + ensure!( + planned <= owed.value(), + "invoice of {} msat reverses {} {} including credit, but VM {} is only owed {} {}", + pay_msat, + planned, + currency, + vm_id, + owed.value(), + currency + ); let paid = self .node @@ -159,8 +249,15 @@ impl VmRefundHandler { let refunded_at = Utc::now(); let mut refund_rows = 0; let mut booked_amount = 0; + let mut credit_returned = 0; let mut failures = Vec::new(); - for (original, amount) in plan { + for RefundShare { + payment: original, + amount, + credit, + .. + } in plan + { let row = build_refund_row( &original, amount, @@ -189,6 +286,35 @@ impl VmRefundHandler { booked_amount += amount; refund_rows += 1; + if credit > 0 { + match self + .db + .adjust_account_credit( + original.user_id, + credit as i64, + "refund", + Some(&format!( + "VM {} refund of payment {}", + vm_id, + hex::encode(&original.id) + )), + ) + .await + { + Ok(_) => credit_returned += credit, + Err(e) => { + error!( + "Refund of payment {} for VM {} owes {} account credit that could not be returned: {}", + hex::encode(&original.id), + vm_id, + credit, + e + ); + failures.push(format!("credit {}: {}", hex::encode(&original.id), e)); + } + } + } + if let Err(e) = self .history .log_vm_refunded( @@ -216,7 +342,7 @@ impl VmRefundHandler { vm_id, pay_msat, preimage, - booked.value() - booked_amount, + planned - booked_amount, failures.join(", ") ); } @@ -228,6 +354,7 @@ impl VmRefundHandler { booked_amount, currency, refund_rows, + credit_returned, }) } @@ -241,7 +368,7 @@ impl VmRefundHandler { vm_id: u64, currency: &str, amount: u64, - ) -> Result> { + ) -> Result> { let mut payments: Vec = self .db .list_vm_subscription_payments(vm_id) @@ -259,10 +386,18 @@ impl VmRefundHandler { let existing = self.db.list_refunds_for_payment(&payment.id).await?; let remaining = refundable_remaining(&payment, &existing); if remaining > 0 { - candidates.push((payment, remaining)); + let debit = self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await?; + candidates.push(RefundCandidate { + payment, + remaining, + debit, + }); } } - allocate_refund(&candidates, amount) + allocate_cash_refund(&candidates, amount) } } @@ -654,6 +789,145 @@ mod tests { assert_eq!(older_refunds[0].amount, 30); } + fn debit( + payment: &SubscriptionPayment, + payment_amount: u64, + amount: u64, + ) -> lnvps_db::AccountCreditDebit { + lnvps_db::AccountCreditDebit { + user_id: payment.user_id, + subscription_payment_id: Some(payment.id.clone()), + amount, + currency: "BTC".to_string(), + payment_amount, + settled: true, + ..Default::default() + } + } + + fn sale(amount: u64) -> SubscriptionPayment { + SubscriptionPayment { + id: vec![1u8; 32], + subscription_id: 1, + user_id: 1, + created: Utc::now(), + expires: Utc::now(), + amount, + currency: "EUR".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: SubscriptionPaymentType::Renewal, + external_data: "".into(), + external_id: None, + is_paid: true, + rate: 1.0, + time_value: None, + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + refunded_payment_id: None, + renewal_source: None, + } + } + + fn candidate(amount: u64, debit: Option) -> RefundCandidate { + RefundCandidate { + payment: sale(amount), + remaining: amount, + debit, + } + } + + #[test] + fn cash_refund_returns_the_credit_share_as_credit() { + let p = sale(1000); + let plan = + allocate_cash_refund(&[candidate(1000, Some(debit(&p, 500, 5_000_000)))], 200).unwrap(); + assert_eq!(plan.len(), 1); + assert_eq!( + (plan[0].amount, plan[0].cash, plan[0].credit), + (400, 200, 2_000_000) + ); + + let plan = allocate_cash_refund(&[candidate(1000, None)], 200).unwrap(); + assert_eq!( + (plan[0].amount, plan[0].cash, plan[0].credit), + (200, 200, 0) + ); + } + + #[test] + fn cash_refund_cannot_take_more_cash_than_was_paid() { + let p = sale(1000); + let half = [candidate(1000, Some(debit(&p, 500, 5_000_000)))]; + let plan = allocate_cash_refund(&half, 500).unwrap(); + assert_eq!( + (plan[0].amount, plan[0].cash, plan[0].credit), + (1000, 500, 5_000_000) + ); + assert!(allocate_cash_refund(&half, 501).is_err()); + + let all_credit = [candidate(1000, Some(debit(&p, 1000, 10_000_000)))]; + assert!(allocate_cash_refund(&all_credit, 1).is_err()); + assert!(allocate_cash_refund(&all_credit, 0).unwrap().is_empty()); + + let mut unsettled = debit(&p, 1000, 10_000_000); + unsettled.settled = false; + let plan = allocate_cash_refund(&[candidate(1000, Some(unsettled))], 300).unwrap(); + assert_eq!((plan[0].amount, plan[0].credit), (300, 0)); + } + + #[tokio::test] + async fn a_refund_of_a_credit_funded_payment_returns_credit() { + let mock = MockDb::default(); + let (vm, payment) = vm_with_payment(&mock, 1230, 230).await; + mock.account_credit_debits + .lock() + .await + .push(debit(&payment, 730, 7_300_000)); + let db: Arc = Arc::new(mock); + let node = Arc::new(PayNode::default()); + let handler = handler(db.clone(), node.clone()).await; + + let err = handler + .process(1, 9, None, None, "lightning", Some(&invoice_for(500_000))) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("including credit"), "{err}"); + assert!(node.paid.lock().unwrap().is_empty()); + + let outcome = handler + .process(1, 9, None, None, "lightning", Some(&invoice_for(300_000))) + .await + .expect("refund"); + assert_eq!( + outcome.amount_msat, 300_000, + "only the cash share is paid out" + ); + assert_eq!(outcome.booked_amount, 60); + assert_eq!(outcome.credit_returned, 300_000); + assert_eq!( + db.get_account_credit(vm.user_id).await.unwrap().balance, + 300_000 + ); + let refunds = db.list_refunds_for_payment(&payment.id).await.unwrap(); + assert_eq!(refunds[0].amount, 60); + let (events, _) = db + .list_account_credit_events(vm.user_id, 10, 0) + .await + .unwrap(); + assert_eq!( + (events[0].kind.as_str(), events[0].amount), + ("refund", 300_000) + ); + } + /// A failed payment writes no refund row: the ledger only ever records /// money that actually moved. #[tokio::test] diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index d0379206..e527cac9 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -124,6 +124,60 @@ pub struct RenewalQuote { pub tax_lines: Vec, /// The discount applied, when a code was supplied. pub discount: Option, + /// Account credit applied to this order. [`Self::amount`] and + /// [`Self::tax`] are the full bill, [`Self::processing_fee`] is charged on + /// what credit leaves to collect. + pub credit: Option, +} + +/// Account credit applied to an order: `amount` of the bill's net + tax, in +/// the payment currency, backed by `base_amount` of the balance. +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedCredit { + /// The user whose balance covers this. + pub user_id: u64, + /// Minor units of `currency` covered by the balance, as shown on the quote. + pub amount: u64, + /// Payment currency of [`Self::amount`]. + pub currency: Currency, + /// The same value in the balance's base currency: what the ledger deducts. + /// May differ from `amount` when the payment currency is not the base + /// currency, because the conversion floors in both directions. + pub base_amount: u64, + /// The balance's currency. + pub base_currency: Currency, +} + +/// The `external_data` of a payment fully funded by account credit. +pub(crate) const CREDIT_FUNDED_MARKER: &str = "credit"; + +impl AppliedCredit { + fn reduce_to(&mut self, amount: u64) { + if self.amount > 0 { + self.base_amount = + (self.base_amount as u128 * amount as u128 / self.amount as u128) as u64; + } + self.amount = amount; + } +} + +fn credit_amount(credit: Option<&AppliedCredit>) -> u64 { + credit.map(|c| c.amount).unwrap_or(0) +} + +fn credit_covers(credit: Option<&AppliedCredit>, net: u64, tax: u64) -> bool { + credit.is_some_and(|c| c.amount >= net + tax) +} + +fn scale_fee(fee: u64, collected: u64, billable: u64) -> u64 { + if billable == 0 { + return 0; + } + (fee as u128 * collected as u128 / billable as u128) as u64 +} + +fn credit_hold_expiry() -> chrono::DateTime { + Utc::now() + chrono::Duration::hours(1) } /// Outcome of pricing a renewal order. @@ -210,6 +264,10 @@ impl SubscriptionHandler { self.db.clone() } + pub fn node(&self) -> Arc { + self.node.clone() + } + /// Derive a fresh on-chain receive address for a new payment. /// /// Returns the address string to store in `external_data`. @@ -343,6 +401,35 @@ impl SubscriptionHandler { ), } + let held = self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await + .ok() + .flatten() + .map(|d| d.amount); + match self.db.settle_account_credit_debit(&payment.id).await { + Ok(Some(d)) if held.is_some_and(|h| h > d.amount) => warn!( + "Account credit on payment {} fell short: {} of {} {} spent", + hex::encode(&payment.id), + d.amount, + held.unwrap_or_default(), + d.currency + ), + Ok(Some(d)) => info!( + "Account credit {} {} spent on payment {}", + d.amount, + d.currency, + hex::encode(&payment.id) + ), + Ok(None) => {} + Err(e) => warn!( + "Failed to settle account credit for payment {}: {}", + hex::encode(&payment.id), + e + ), + } + let line_items = self .db .list_subscription_line_items(payment.subscription_id) @@ -622,12 +709,35 @@ impl SubscriptionHandler { payment_type: payment.payment_type, tax_lines: vec![], discount, + credit: self.credit_for_payment(&payment).await?, }) } PricedRenewal::Priced { quote, .. } => Ok(quote), } } + async fn credit_for_payment( + &self, + payment: &SubscriptionPayment, + ) -> Result> { + let Some(debit) = self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await? + else { + return Ok(None); + }; + Ok(Some(AppliedCredit { + user_id: debit.user_id, + amount: debit.payment_amount, + currency: Currency::from_str(&payment.currency) + .map_err(|_| anyhow::anyhow!("Invalid payment currency"))?, + base_amount: debit.amount, + base_currency: Currency::from_str(&debit.currency) + .map_err(|_| anyhow::anyhow!("Invalid credit currency"))?, + })) + } + /// Create a renewal/purchase payment for a subscription. /// /// For [`RenewMode::Saved`] the saved payment method is collected on the spot @@ -664,6 +774,7 @@ impl SubscriptionHandler { payment_type, tax_lines, discount, + credit, } = quote; let tax_summary = lnvps_api_common::summarize_tax_lines(&tax_lines); @@ -676,31 +787,41 @@ impl SubscriptionHandler { .evidence_json(), ); - // Generate payment based on method - let subscription_payment = self - .build_payment( - subscription_id, - &subscription, - &user, - method, - &mode, - payment_type, - converted_amount, - converted_currency, - tax, - processing_fee, - rate, - time_value, - &tax_summary, - tax_evidence, - tax_breakdown, - source, - ) - .await?; - - // Save payment to database - self.db - .insert_subscription_payment(&subscription_payment) + let reservation = self.reserve_credit(credit.as_ref()).await?; + let created = async { + let payment = self + .build_payment( + subscription_id, + &subscription, + &user, + method, + &mode, + payment_type, + converted_amount, + converted_currency, + tax, + processing_fee, + rate, + time_value, + &tax_summary, + tax_evidence, + tax_breakdown, + source, + credit.as_ref(), + ) + .await?; + self.db.insert_subscription_payment(&payment).await?; + Ok::<_, anyhow::Error>(payment) + } + .await; + let mut subscription_payment = match created { + Ok(p) => p, + Err(e) => { + self.release_credit(reservation).await; + return Err(e); + } + }; + self.attach_credit(reservation, &subscription_payment) .await?; // Record the discount against the payment, unsettled. It consumes no @@ -719,6 +840,9 @@ impl SubscriptionHandler { .await?; } + self.settle_if_credit_funded(&mut subscription_payment) + .await?; + // For saved-method payments, charge on the spot and wait for settlement. self.collect_saved_payment(subscription_payment, &mode) .await @@ -988,21 +1112,33 @@ impl SubscriptionHandler { let converted_amount = total_amount; let converted_currency = payment_currency; + let (processing_fee, credit) = self + .price_credit( + subscription.user_id, + subscription.company_id, + method, + converted_amount, + tax, + processing_fee, + converted_currency, + ) + .await?; + // Reject payments below the method's configured minimum (gross = net + tax // + processing fee). Prevents uneconomic small charges (e.g. Revolut's // flat 20c base fee dominating a tiny payment). Enforced here rather than // at invoice creation so a quote refuses an uneconomic order at the same // point the renewal would. - self.pe - .enforce_min_amount( - subscription.company_id, - method, - CurrencyAmount::from_u64( - converted_currency, - converted_amount + tax + processing_fee, - ), - ) - .await?; + let due = converted_amount + tax + processing_fee - credit_amount(credit.as_ref()); + if due > 0 { + self.pe + .enforce_min_amount( + subscription.company_id, + method, + CurrencyAmount::from_u64(converted_currency, due), + ) + .await?; + } Ok(PricedRenewal::Priced { quote: RenewalQuote { @@ -1015,6 +1151,7 @@ impl SubscriptionHandler { payment_type, tax_lines, discount, + credit, }, subscription, user, @@ -1042,8 +1179,44 @@ impl SubscriptionHandler { tax_evidence: Option, tax_breakdown: Option, source: RenewalSource, + credit: Option<&AppliedCredit>, ) -> Result { - let subscription = &*subscription; + if credit_covers(credit, converted_amount, tax) { + info!("Credit fully funds subscription {subscription_id} payment: no invoice"); + let new_id: [u8; 32] = rand::random(); + return Ok(SubscriptionPayment { + renewal_source: Some(source), + id: new_id.to_vec(), + subscription_id, + user_id: subscription.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(600)), + amount: converted_amount, + currency: converted_currency.to_string(), + payment_method: method, + payment_type, + external_data: CREDIT_FUNDED_MARKER.into(), + external_id: None, + is_paid: false, + rate, + time_value: if time_value > 0 { + Some(time_value) + } else { + None + }, + metadata: None, + tax, + processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence, + tax_breakdown, + refunded_payment_id: None, + }); + } + let credit_covered = credit_amount(credit); Ok(match method { PaymentMethod::Lightning => { ensure!( @@ -1051,8 +1224,7 @@ impl SubscriptionHandler { "Lightning payment must be in BTC" ); const INVOICE_EXPIRE: u64 = 600; - // Round to nearest satoshi for wallet compatibility - let invoice_amount = round_msat_to_sat(converted_amount + tax); + let invoice_amount = round_msat_to_sat(converted_amount + tax - credit_covered); let desc = match payment_type { SubscriptionPaymentType::Purchase => { format!("Subscription purchase: {}", subscription.name) @@ -1073,9 +1245,10 @@ impl SubscriptionHandler { }; info!( - "Creating invoice for subscription {} for {} sats", + "Creating invoice for subscription {} for {} sats ({} sats from credit)", subscription_id, - invoice_amount / 1000 + invoice_amount / 1000, + credit_covered / 1000 ); let invoice = self @@ -1149,10 +1322,8 @@ impl SubscriptionHandler { } }; - let order_amount = CurrencyAmount::from_u64( - converted_currency, - converted_amount + tax + processing_fee, - ); + let external_amount = converted_amount + tax + processing_fee - credit_covered; + let order_amount = CurrencyAmount::from_u64(converted_currency, external_amount); let (external_id, raw_data) = match &mode { RenewMode::Saved { method_id } => { // Merchant-initiated charge against a saved Revolut @@ -1230,7 +1401,7 @@ impl SubscriptionHandler { // On-chain payments need time for the tx to be broadcast and // confirmed; hold the quoted rate for longer than Lightning. const ONCHAIN_EXPIRE: u64 = 3600; - let invoice_amount = round_msat_to_sat(converted_amount + tax); + let invoice_amount = round_msat_to_sat(converted_amount + tax - credit_covered); let desc = match payment_type { SubscriptionPaymentType::Purchase => { format!("Subscription purchase: {}", subscription.name) @@ -1294,6 +1465,184 @@ impl SubscriptionHandler { }) } + /// The credit to apply to a bill of `gross` in `currency`: the user's + /// unreserved balance, converted and clamped. `None` unless opted in. + async fn quote_credit( + &self, + user_id: u64, + gross: u64, + currency: Currency, + ) -> Result> { + let credit = self.db.get_account_credit(user_id).await?; + if !credit.auto_apply || credit.balance == 0 || gross == 0 { + return Ok(None); + } + let available = credit + .balance + .saturating_sub(self.db.get_account_credit_reserved(user_id).await?); + if available == 0 { + return Ok(None); + } + let base_currency = Currency::from_str(&credit.currency) + .map_err(|_| anyhow::anyhow!("Invalid credit currency"))?; + let converted = self + .pe + .convert_currency(CurrencyAmount::from_u64(base_currency, available), currency) + .await? + .value(); + let applied = converted.min(gross); + if applied == 0 { + return Ok(None); + } + let base_amount = if base_currency == currency { + applied + } else { + ((applied as u128 * available as u128) / converted as u128) as u64 + } + .min(available); + Ok(Some(AppliedCredit { + user_id, + amount: applied, + currency, + base_amount, + base_currency, + })) + } + + /// Apply the user's credit to a bill of `net + tax`, returning the + /// processing fee on what is still collected and the credit. When the + /// remainder would fall below the method's minimum, less credit is used so + /// the remainder meets it. + #[allow(clippy::too_many_arguments)] + async fn price_credit( + &self, + user_id: u64, + company_id: u64, + method: PaymentMethod, + net: u64, + tax: u64, + fee: u64, + currency: Currency, + ) -> Result<(u64, Option)> { + let billable = net + tax; + let Some(mut credit) = self.quote_credit(user_id, billable, currency).await? else { + return Ok((fee, None)); + }; + let due = |covered: u64| { + let collected = billable - covered; + collected + scale_fee(fee, collected, billable) + }; + let min = self.pe.min_amount_in(company_id, method, currency).await?; + let remainder = due(credit.amount); + if remainder > 0 && remainder < min { + let shortfall = min - remainder; + if shortfall >= credit.amount { + return Ok((fee, None)); + } + credit.reduce_to(credit.amount - shortfall); + } + Ok(( + scale_fee(fee, billable - credit.amount, billable), + Some(credit), + )) + } + + /// Price the credit for an upgrade quote of `vm_id`, as + /// [`Self::create_vm_upgrade_payment`] will. + pub async fn quote_vm_upgrade_credit( + &self, + vm_id: u64, + method: PaymentMethod, + net: u64, + tax: u64, + fee: u64, + currency: Currency, + ) -> Result<(u64, Option)> { + let vm = self.db.get_vm(vm_id).await?; + let line_item = self + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let subscription = self.db.get_subscription(line_item.subscription_id).await?; + self.price_credit( + vm.user_id, + subscription.company_id, + method, + net, + tax, + fee, + currency, + ) + .await + } + + async fn reserve_credit(&self, credit: Option<&AppliedCredit>) -> Result> { + let Some(c) = credit else { + return Ok(None); + }; + self.db + .reserve_account_credit(&lnvps_db::AccountCreditDebit { + user_id: c.user_id, + amount: c.base_amount, + currency: c.base_currency.to_string(), + payment_amount: c.amount, + expires: credit_hold_expiry(), + ..Default::default() + }) + .await? + .map(Some) + .ok_or_else(|| anyhow::anyhow!("Account credit balance changed, please try again")) + } + + async fn release_credit(&self, reservation: Option) { + if let Some(id) = reservation + && let Err(e) = self.db.release_account_credit_debit(id).await + { + warn!("Failed to release account credit reservation {id}: {e}"); + } + } + + async fn attach_credit( + &self, + reservation: Option, + payment: &SubscriptionPayment, + ) -> Result<()> { + if let Some(id) = reservation { + self.db + .attach_account_credit_debit(id, &payment.id, payment.expires) + .await?; + } + Ok(()) + } + + /// Spend the credit on a payment with no external invoice and run the + /// settlement funnel. + async fn settle_if_credit_funded(&self, payment: &mut SubscriptionPayment) -> Result<()> { + if payment.external_data.as_str() != CREDIT_FUNDED_MARKER { + return Ok(()); + } + let held = self + .db + .get_account_credit_debit_by_payment(&payment.id) + .await? + .ok_or_else(|| anyhow::anyhow!("Credit-funded payment has no credit held"))?; + let spent = self + .db + .settle_account_credit_debit(&payment.id) + .await? + .ok_or_else(|| anyhow::anyhow!("Credit-funded payment was already settled"))?; + ensure!( + spent.amount == held.amount, + "Account credit fell short on payment {}: {} of {} spent", + hex::encode(&payment.id), + spent.amount, + held.amount + ); + self.complete_payment(payment).await?; + payment.is_paid = true; + Ok(()) + } + /// Resolve a discount code for an order, in the payment currency. /// /// Returns `Ok(None)` when no code was supplied. An unusable code is an @@ -1351,6 +1700,7 @@ impl SubscriptionHandler { SubscriptionPaymentType::Renewal, None, RenewMode::Interactive { save_card: false }, + false, ) .await } @@ -1363,6 +1713,7 @@ impl SubscriptionHandler { payment_type: SubscriptionPaymentType, metadata: Option, mode: RenewMode, + apply_credit: bool, ) -> Result { let (subscription_id, user_id, desc) = match &price { CostResult::Existing(_) => (0, 0, String::new()), @@ -1396,6 +1747,7 @@ impl SubscriptionHandler { payment_type, metadata, mode, + apply_credit, ) .await } @@ -1417,253 +1769,377 @@ impl SubscriptionHandler { payment_type: SubscriptionPaymentType, metadata: Option, mode: RenewMode, + apply_credit: bool, ) -> Result { - match price { - CostResult::Existing(p) => Ok(p), - CostResult::New(p) => { - // Record the tax values on the payment. Single line item, so the - // breakdown has exactly one entry. - let tax_lines = vec![p.tax_details.to_line(p.amount)]; - let tax_summary = lnvps_api_common::summarize_tax_lines(&tax_lines); - let tax_breakdown = serde_json::to_value(&tax_lines).ok(); - let tax_evidence = Some(p.tax_details.evidence_json()); - let payment = match method { - PaymentMethod::Lightning => { - ensure!( - p.currency == Currency::BTC, - "Cannot create invoices for non-BTC currency" - ); - const INVOICE_EXPIRE: u64 = 600; - let total_amount = round_msat_to_sat(p.amount + p.tax); - info!( - "Creating invoice for subscription {subscription_id} for {} sats", - total_amount / 1000 - ); - let invoice = self - .node - .add_invoice(AddInvoiceRequest { - memo: Some(desc), - amount: total_amount, - expire: Some(INVOICE_EXPIRE as u32), - }) - .await?; - SubscriptionPayment { - // Every caller here is a customer action (renew, upgrade, purchase); - // worker-initiated renewals go through renew_subscription_inner. - renewal_source: Some(RenewalSource::Manual), - id: hex::decode(invoice.payment_hash())?, - subscription_id, - user_id: user_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), - amount: p.amount, - currency: p.currency.to_string(), - payment_method: method, - payment_type, - external_data: invoice.pr().into(), - external_id: invoice.external_id, - is_paid: false, - rate: p.rate.rate, - time_value: Some(p.time_value), - metadata, - tax: p.tax, - processing_fee: p.processing_fee, - paid_at: None, - tax_rate: tax_summary.rate, - tax_country_code: tax_summary.country_code.clone(), - tax_treatment: tax_summary.treatment.clone(), - tax_evidence: tax_evidence.clone(), - tax_breakdown: tax_breakdown.clone(), - refunded_payment_id: None, - } - } - PaymentMethod::Revolut => { - let rev = if let Some(r) = &self.revolut { - r - } else { - bail!("Revolut not configured") - }; - ensure!( - p.currency != Currency::BTC, - "Cannot create revolut orders for BTC currency" - ); - let subscription = self.db.get_subscription(subscription_id).await?; - let user = self.db.get_user(user_id).await?; - let order_amount = CurrencyAmount::from_u64( - p.currency, - p.amount + p.tax + p.processing_fee, - ); - let (external_id, raw_data) = match &mode { - RenewMode::Saved { method_id } => { - // Merchant-initiated off-session charge against a - // saved Revolut card (e.g. for an upgrade paid with - // a saved method) — no customer interaction. - let method = - self.revolut_payment_method(user.id, *method_id).await?; - let customer_id: String = method - .external_customer_id - .clone() - .ok_or_else(|| { - anyhow::anyhow!("Revolut method missing customer id") - })? - .into(); - let payment_method_id: String = method.external_id.clone().into(); - let info = rev - .charge_subscription( - &customer_id, - &payment_method_id, - order_amount, - &desc, - ) - .await?; - (info.external_id, info.raw_data) - } - RenewMode::Interactive { save_card } => { - self.create_revolut_order( - rev, - &subscription, - &user, - payment_type, - &desc, - order_amount, - *save_card, - ) - .await? - } - }; - let new_id: [u8; 32] = rand::random(); - SubscriptionPayment { - // Every caller here is a customer action (renew, upgrade, purchase); - // worker-initiated renewals go through renew_subscription_inner. - renewal_source: Some(RenewalSource::Manual), - id: new_id.to_vec(), - subscription_id, - user_id: user_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(3600)), - amount: p.amount, - currency: p.currency.to_string(), - payment_method: method, - payment_type, - external_data: raw_data.into(), - external_id: Some(external_id), - is_paid: false, - rate: p.rate.rate, - time_value: Some(p.time_value), - metadata, - tax: p.tax, - processing_fee: p.processing_fee, - paid_at: None, - tax_rate: tax_summary.rate, - tax_country_code: tax_summary.country_code.clone(), - tax_treatment: tax_summary.treatment.clone(), - tax_evidence: tax_evidence.clone(), - tax_breakdown: tax_breakdown.clone(), - refunded_payment_id: None, - } - } - PaymentMethod::OnChain => { - ensure!( - p.currency == Currency::BTC, - "Cannot create on-chain payments for non-BTC currency" - ); - const ONCHAIN_EXPIRE: u64 = 3600; - let total_amount = round_msat_to_sat(p.amount + p.tax); - info!( - "Creating on-chain address for subscription {subscription_id} for {} sats", - total_amount / 1000 - ); - let new_id: [u8; 32] = rand::random(); - let address = self - .new_onchain_address(total_amount, desc, hex::encode(new_id)) - .await?; - SubscriptionPayment { - // Every caller here is a customer action (renew, upgrade, purchase); - // worker-initiated renewals go through renew_subscription_inner. - renewal_source: Some(RenewalSource::Manual), - id: new_id.to_vec(), - subscription_id, - user_id: user_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(ONCHAIN_EXPIRE)), - amount: p.amount, - currency: p.currency.to_string(), - payment_method: method, - payment_type, - external_data: address.into(), - // txid is filled in by the chain watcher when the - // deposit is seen. - external_id: None, - is_paid: false, - rate: p.rate.rate, - time_value: Some(p.time_value), - metadata, - tax: p.tax, - processing_fee: p.processing_fee, - paid_at: None, - tax_rate: tax_summary.rate, - tax_country_code: tax_summary.country_code.clone(), - tax_treatment: tax_summary.treatment.clone(), - tax_evidence: tax_evidence.clone(), - tax_breakdown: tax_breakdown.clone(), - refunded_payment_id: None, - } - } - PaymentMethod::Paypal => bail!("PayPal not implemented"), - PaymentMethod::Stripe => { - bail!("Stripe payment creation not yet implemented") - } - }; - - self.db.insert_subscription_payment(&payment).await?; - - // For saved-method payments, charge on the spot and wait for - // settlement before returning. - self.collect_saved_payment(payment, &mode).await - } + let mut p = match price { + CostResult::Existing(p) => return Ok(p), + CostResult::New(p) => p, + }; + let credit = if apply_credit { + let subscription = self.db.get_subscription(subscription_id).await?; + let (fee, credit) = self + .price_credit( + user_id, + subscription.company_id, + method, + p.amount, + p.tax, + p.processing_fee, + p.currency, + ) + .await?; + p.processing_fee = fee; + credit + } else { + None + }; + let reservation = self.reserve_credit(credit.as_ref()).await?; + let created = async { + let payment = self + .build_priced_payment( + subscription_id, + user_id, + desc, + method, + &p, + payment_type, + metadata, + &mode, + credit.as_ref(), + ) + .await?; + self.db.insert_subscription_payment(&payment).await?; + Ok::<_, anyhow::Error>(payment) } - } - - /// The user's default usable (enabled, non-expired) saved payment method, - /// across all providers. Methods are ordered default-first. - pub async fn default_payment_method(&self, user_id: u64) -> Result { - let now = Utc::now(); - let (year, month) = (now.year() as u16, now.month() as u16); - self.db - .list_user_payment_methods(user_id, None) - .await? - .into_iter() - .find(|pm| pm.enabled && !pm.is_expired(year, month)) - .ok_or_else(|| anyhow::anyhow!("No usable saved payment method")) - } + .await; + let mut payment = match created { + Ok(p) => p, + Err(e) => { + self.release_credit(reservation).await; + return Err(e); + } + }; + self.attach_credit(reservation, &payment).await?; + self.settle_if_credit_funded(&mut payment).await?; - /// The user's first enabled NWC payment method. - async fn nwc_payment_method(&self, user_id: u64) -> Result { - self.db - .list_user_payment_methods(user_id, Some("nwc")) - .await? - .into_iter() - .find(|pm| pm.enabled) - .ok_or_else(|| anyhow::anyhow!("No NWC payment method configured")) + // For saved-method payments, charge on the spot and wait for + // settlement before returning. + self.collect_saved_payment(payment, &mode).await } - /// Attempt automatic renewal using the user's default saved payment method, - /// dispatching by provider (NWC Lightning wallet or Revolut card). - pub async fn auto_renew(&self, sub_id: u64) -> Result { - let sub = self.db.get_subscription(sub_id).await?; - let method = self.default_payment_method(sub.user_id).await?; - match method.provider.as_str() { - "nwc" => { - #[cfg(feature = "nostr-nwc")] - { - self.auto_renew_via_nwc(sub_id).await - } - #[cfg(not(feature = "nostr-nwc"))] - { - bail!("NWC auto-renewal is not supported by this build") - } - } - "revolut" => self.auto_renew_via_revolut(sub_id).await, + #[allow(clippy::too_many_arguments)] + async fn build_priced_payment( + &self, + subscription_id: u64, + user_id: u64, + desc: String, + method: PaymentMethod, + p: &NewPaymentInfo, + payment_type: SubscriptionPaymentType, + metadata: Option, + mode: &RenewMode, + credit: Option<&AppliedCredit>, + ) -> Result { + // Record the tax values on the payment. Single line item, so the + // breakdown has exactly one entry. + let tax_lines = vec![p.tax_details.to_line(p.amount)]; + let tax_summary = lnvps_api_common::summarize_tax_lines(&tax_lines); + let tax_breakdown = serde_json::to_value(&tax_lines).ok(); + let tax_evidence = Some(p.tax_details.evidence_json()); + if credit_covers(credit, p.amount, p.tax) { + info!("Credit fully funds subscription {subscription_id} payment: no invoice"); + let new_id: [u8; 32] = rand::random(); + return Ok(SubscriptionPayment { + // Every caller here is a customer action (renew, upgrade, purchase); + // worker-initiated renewals go through renew_subscription_inner. + renewal_source: Some(RenewalSource::Manual), + id: new_id.to_vec(), + subscription_id, + user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(600)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: CREDIT_FUNDED_MARKER.into(), + external_id: None, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence, + tax_breakdown, + refunded_payment_id: None, + }); + } + let credit_covered = credit_amount(credit); + Ok(match method { + PaymentMethod::Lightning => { + ensure!( + p.currency == Currency::BTC, + "Cannot create invoices for non-BTC currency" + ); + const INVOICE_EXPIRE: u64 = 600; + let total_amount = round_msat_to_sat(p.amount + p.tax - credit_covered); + info!( + "Creating invoice for subscription {subscription_id} for {} sats ({} sats from credit)", + total_amount / 1000, + credit_covered / 1000 + ); + let invoice = self + .node + .add_invoice(AddInvoiceRequest { + memo: Some(desc), + amount: total_amount, + expire: Some(INVOICE_EXPIRE as u32), + }) + .await?; + SubscriptionPayment { + // Every caller here is a customer action (renew, upgrade, purchase); + // worker-initiated renewals go through renew_subscription_inner. + renewal_source: Some(RenewalSource::Manual), + id: hex::decode(invoice.payment_hash())?, + subscription_id, + user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: invoice.pr().into(), + external_id: invoice.external_id, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + refunded_payment_id: None, + } + } + PaymentMethod::Revolut => { + let rev = if let Some(r) = &self.revolut { + r + } else { + bail!("Revolut not configured") + }; + ensure!( + p.currency != Currency::BTC, + "Cannot create revolut orders for BTC currency" + ); + let subscription = self.db.get_subscription(subscription_id).await?; + let user = self.db.get_user(user_id).await?; + let external_amount = p.amount + p.tax + p.processing_fee - credit_covered; + let order_amount = CurrencyAmount::from_u64(p.currency, external_amount); + let (external_id, raw_data) = match &mode { + RenewMode::Saved { method_id } => { + // Merchant-initiated off-session charge against a + // saved Revolut card (e.g. for an upgrade paid with + // a saved method) — no customer interaction. + let method = self.revolut_payment_method(user.id, *method_id).await?; + let customer_id: String = method + .external_customer_id + .clone() + .ok_or_else(|| anyhow::anyhow!("Revolut method missing customer id"))? + .into(); + let payment_method_id: String = method.external_id.clone().into(); + let info = rev + .charge_subscription( + &customer_id, + &payment_method_id, + order_amount, + &desc, + ) + .await?; + (info.external_id, info.raw_data) + } + RenewMode::Interactive { save_card } => { + self.create_revolut_order( + rev, + &subscription, + &user, + payment_type, + &desc, + order_amount, + *save_card, + ) + .await? + } + }; + let new_id: [u8; 32] = rand::random(); + SubscriptionPayment { + // Every caller here is a customer action (renew, upgrade, purchase); + // worker-initiated renewals go through renew_subscription_inner. + renewal_source: Some(RenewalSource::Manual), + id: new_id.to_vec(), + subscription_id, + user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(3600)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: raw_data.into(), + external_id: Some(external_id), + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + refunded_payment_id: None, + } + } + PaymentMethod::OnChain => { + ensure!( + p.currency == Currency::BTC, + "Cannot create on-chain payments for non-BTC currency" + ); + const ONCHAIN_EXPIRE: u64 = 3600; + let total_amount = round_msat_to_sat(p.amount + p.tax - credit_covered); + info!( + "Creating on-chain address for subscription {subscription_id} for {} sats ({} sats from credit)", + total_amount / 1000, + credit_covered / 1000 + ); + let new_id: [u8; 32] = rand::random(); + let address = self + .new_onchain_address(total_amount, desc, hex::encode(new_id)) + .await?; + SubscriptionPayment { + // Every caller here is a customer action (renew, upgrade, purchase); + // worker-initiated renewals go through renew_subscription_inner. + renewal_source: Some(RenewalSource::Manual), + id: new_id.to_vec(), + subscription_id, + user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(ONCHAIN_EXPIRE)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: address.into(), + // txid is filled in by the chain watcher when the + // deposit is seen. + external_id: None, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + tax_rate: tax_summary.rate, + tax_country_code: tax_summary.country_code.clone(), + tax_treatment: tax_summary.treatment.clone(), + tax_evidence: tax_evidence.clone(), + tax_breakdown: tax_breakdown.clone(), + refunded_payment_id: None, + } + } + PaymentMethod::Paypal => bail!("PayPal not implemented"), + PaymentMethod::Stripe => { + bail!("Stripe payment creation not yet implemented") + } + }) + } + + /// The user's default usable (enabled, non-expired) saved payment method, + /// across all providers. Methods are ordered default-first. + pub async fn default_payment_method(&self, user_id: u64) -> Result { + let now = Utc::now(); + let (year, month) = (now.year() as u16, now.month() as u16); + self.db + .list_user_payment_methods(user_id, None) + .await? + .into_iter() + .find(|pm| pm.enabled && !pm.is_expired(year, month)) + .ok_or_else(|| anyhow::anyhow!("No usable saved payment method")) + } + + /// The user's first enabled NWC payment method. + async fn nwc_payment_method(&self, user_id: u64) -> Result { + self.db + .list_user_payment_methods(user_id, Some("nwc")) + .await? + .into_iter() + .find(|pm| pm.enabled) + .ok_or_else(|| anyhow::anyhow!("No NWC payment method configured")) + } + + /// Attempt automatic renewal using the user's default saved payment method, + /// dispatching by provider (NWC Lightning wallet or Revolut card). + pub async fn auto_renew(&self, sub_id: u64) -> Result { + let sub = self.db.get_subscription(sub_id).await?; + + let credit = self.db.get_account_credit(sub.user_id).await?; + if credit.auto_apply && credit.balance > 0 { + let quote = self + .quote_renewal(sub_id, PaymentMethod::Lightning, 1, None) + .await?; + let covered = quote + .credit + .as_ref() + .is_some_and(|c| c.amount >= quote.amount + quote.tax); + if covered { + let payment = self + .renew_subscription_inner( + sub_id, + PaymentMethod::Lightning, + 1, + RenewMode::default(), + None, + RenewalSource::Auto, + ) + .await?; + info!("Subscription {sub_id} renewed on account credit"); + return Ok(payment); + } + } + + let method = match self.default_payment_method(sub.user_id).await { + Ok(m) => m, + Err(_) if credit.auto_apply && credit.balance > 0 => { + bail!( + "Account credit does not cover the renewal and no saved payment method is set" + ) + } + Err(e) => return Err(e), + }; + match method.provider.as_str() { + "nwc" => { + #[cfg(feature = "nostr-nwc")] + { + self.auto_renew_via_nwc(sub_id).await + } + #[cfg(not(feature = "nostr-nwc"))] + { + bail!("NWC auto-renewal is not supported by this build") + } + } + "revolut" => self.auto_renew_via_revolut(sub_id).await, other => bail!("No auto-renewal handler for provider {other}"), } } @@ -1708,7 +2184,7 @@ impl SubscriptionHandler { payment: SubscriptionPayment, mode: &RenewMode, ) -> Result { - if !matches!(mode, RenewMode::Saved { .. }) { + if !matches!(mode, RenewMode::Saved { .. }) || payment.is_paid { return Ok(payment); } @@ -1833,6 +2309,7 @@ impl SubscriptionHandler { SubscriptionPaymentType::Upgrade, Some(metadata), mode, + true, ) .await } @@ -1884,6 +2361,7 @@ impl SubscriptionHandler { SubscriptionPaymentType::Upgrade, Some(metadata), mode, + true, ) .await } @@ -3322,3 +3800,714 @@ mod quote_tests { assert_eq!(quoted.processing_fee, payment.processing_fee); } } + +#[cfg(test)] +mod credit_tests { + use super::*; + use crate::mocks::{MockNode, MockOnChainProvider}; + use crate::settings::mock_settings; + use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; + use lnvps_db::{IntervalType, LNVpsDbBase, LineItemType, Subscription, SubscriptionLineItem}; + + /// An IpRange subscription (10.00 EUR/month) so the credit maths are cents + /// against a known conversion rate: 1 BTC = 100,000 EUR, so 1,000,000 sats + /// = 10.00 EUR. + async fn setup() -> (Arc, SubscriptionHandler, u64, u64) { + let db = Arc::new(MockDb::default()); + let user_id = db.upsert_user(&[7u8; 32]).await.unwrap(); + let (sub_id, _items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "s".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: true, + is_setup: true, + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: LineItemType::IpRange, + name: "hosting".to_string(), + description: None, + amount: 1_000, + setup_amount: 0, + configuration: None, + }], + ) + .await + .unwrap(); + + let rates = Arc::new(MockExchangeRate::default()); + rates + .set_rate( + lnvps_api_common::Ticker::btc_rate("EUR").unwrap(), + 100_000.0, + ) + .await; + let mut sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + Arc::new(MockNode::default()), + Arc::new(MockOnChainProvider::default()), + None, + rates, + VatClient::new(), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + ) + .unwrap(); + sub.set_revolut_for_test(Arc::new(super::revolut_offline_tests::MockFiat::default())); + sub.set_settle_timeout_for_test(Duration::ZERO); + (db, sub, user_id, sub_id) + } + + async fn give_credit(db: &MockDb, user_id: u64, balance: u64, auto_apply: bool) { + if balance > 0 { + db.adjust_account_credit(user_id, balance as i64, "topup", None) + .await + .unwrap(); + } + db.set_account_credit_auto_apply(user_id, auto_apply) + .await + .unwrap(); + } + + async fn revolut_config(db: &MockDb, fee_base: u64, min_amount: u64) { + use lnvps_db::{PaymentMethodConfig, ProviderConfig, RevolutProviderConfig}; + let mut config = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::Revolut, + "Revolut".to_string(), + true, + ProviderConfig::Revolut(RevolutProviderConfig { + url: "https://api.revolut.com".to_string(), + token: "test-token".to_string(), + api_version: "2024-09-01".to_string(), + public_key: "pk_test".to_string(), + webhook_secret: None, + }), + ); + config.id = 1; + config.processing_fee_rate = Some(0.0); + config.processing_fee_base = Some(fee_base); + config.processing_fee_currency = Some("EUR".to_string()); + config.min_amount = Some(min_amount); + config.min_amount_currency = Some("EUR".to_string()); + db.payment_method_configs.lock().await.insert(1, config); + } + + async fn quote(sub: &SubscriptionHandler, sub_id: u64) -> Result { + sub.quote_renewal(sub_id, PaymentMethod::Revolut, 1, None) + .await + } + + async fn renew(sub: &SubscriptionHandler, sub_id: u64) -> Result { + sub.renew_subscription_with_mode( + sub_id, + PaymentMethod::Revolut, + 1, + RenewMode::Interactive { save_card: false }, + ) + .await + } + + async fn ledger_sum(db: &MockDb, user_id: u64) -> i64 { + let (events, _) = db + .list_account_credit_events(user_id, 100, 0) + .await + .unwrap(); + events.iter().map(|e| e.amount).sum() + } + + #[tokio::test] + async fn credit_is_not_applied_without_opt_in_or_balance() { + let (db, sub, user_id, _sub_id) = setup().await; + assert!( + sub.quote_credit(user_id, 10_000, Currency::EUR) + .await + .unwrap() + .is_none() + ); + + give_credit(&db, user_id, 0, true).await; + assert!( + sub.quote_credit(user_id, 10_000, Currency::EUR) + .await + .unwrap() + .is_none() + ); + + give_credit(&db, user_id, 10_000_000, false).await; + assert!( + sub.quote_credit(user_id, 10_000, Currency::EUR) + .await + .unwrap() + .is_none() + ); + + give_credit(&db, user_id, 0, true).await; + assert!( + sub.quote_credit(user_id, 0, Currency::EUR) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn credit_converts_and_clamps_to_the_bill() { + let (db, sub, user_id, _sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + + let applied = sub + .quote_credit(user_id, 10_000, Currency::EUR) + .await + .unwrap() + .unwrap(); + assert_eq!(applied.amount, 500); + assert_eq!(applied.base_amount, 5_000_000); + assert_eq!(applied.currency, Currency::EUR); + + let applied = sub + .quote_credit(user_id, 300, Currency::EUR) + .await + .unwrap() + .unwrap(); + assert_eq!(applied.amount, 300); + assert_eq!(applied.base_amount, 3_000_000); + } + + #[tokio::test] + async fn a_quote_reports_credit_and_leaves_the_bill_intact() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + + let quoted = quote(&sub, sub_id).await.unwrap(); + assert_eq!(quoted.amount, 1_000); + assert_eq!(quoted.credit.expect("credit on the quote").amount, 500); + assert!(db.subscription_payments.lock().await.is_empty()); + assert!(db.account_credit_debits.lock().await.is_empty()); + } + + #[tokio::test] + async fn a_partly_credit_funded_renewal_holds_the_credit() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + + let payment = renew(&sub, sub_id).await.unwrap(); + assert_eq!(payment.amount, 1_000); + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .expect("credit held"); + assert_eq!(debit.amount, 5_000_000); + assert_eq!(debit.payment_amount, 500); + assert_eq!(debit.expires, payment.expires); + assert!(!debit.settled); + assert_eq!( + db.get_account_credit(user_id).await.unwrap().balance, + 5_000_000 + ); + assert_eq!( + db.get_account_credit_reserved(user_id).await.unwrap(), + 5_000_000 + ); + + let credit = sub.credit_for_payment(&payment).await.unwrap().unwrap(); + assert_eq!((credit.amount, credit.currency), (500, Currency::EUR)); + assert_eq!(credit.base_amount, 5_000_000); + + sub.apply_payment(&payment).await.unwrap(); + assert_eq!(db.get_account_credit(user_id).await.unwrap().balance, 0); + } + + #[tokio::test] + async fn a_payment_settled_after_its_hold_lapsed_spends_only_what_is_left() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + let payment = renew(&sub, sub_id).await.unwrap(); + let held = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .unwrap(); + db.attach_account_credit_debit( + held.id, + &payment.id, + Utc::now() - chrono::Duration::seconds(1), + ) + .await + .unwrap(); + db.reserve_account_credit(&lnvps_db::AccountCreditDebit { + user_id, + amount: 3_000_000, + currency: "BTC".to_string(), + payment_amount: 300, + expires: Utc::now() + chrono::Duration::hours(1), + ..Default::default() + }) + .await + .unwrap() + .unwrap(); + + sub.apply_payment(&payment).await.unwrap(); + let spent = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .unwrap(); + assert_eq!((spent.amount, spent.payment_amount), (2_000_000, 200)); + assert_eq!( + db.get_account_credit(user_id).await.unwrap().balance, + 3_000_000 + ); + } + + #[tokio::test] + async fn held_credit_is_not_offered_to_a_second_order() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + + renew(&sub, sub_id).await.unwrap(); + assert!(quote(&sub, sub_id).await.unwrap().credit.is_none()); + let second = renew(&sub, sub_id).await.unwrap(); + assert!( + db.get_account_credit_debit_by_payment(&second.id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn a_failed_order_releases_its_credit() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 5_000_000, true).await; + + let res = sub + .renew_subscription_with_mode( + sub_id, + PaymentMethod::Revolut, + 1, + RenewMode::Saved { method_id: None }, + ) + .await; + assert!(res.is_err(), "no saved card to charge"); + assert_eq!(db.get_account_credit_reserved(user_id).await.unwrap(), 0); + assert!(db.account_credit_debits.lock().await.is_empty()); + } + + #[tokio::test] + async fn a_fully_credit_funded_renewal_settles_immediately() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 20_000_000, true).await; + let before = Utc::now(); + + let payment = renew(&sub, sub_id).await.unwrap(); + assert!(payment.is_paid); + assert_eq!(payment.external_data.as_str(), CREDIT_FUNDED_MARKER); + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .unwrap(); + assert!(debit.settled); + assert_eq!(debit.amount, 10_000_000); + assert_eq!( + db.get_account_credit(user_id).await.unwrap().balance, + 10_000_000 + ); + + let added = db.get_subscription(sub_id).await.unwrap().expires.unwrap() - before; + assert!((29..=31).contains(&added.num_days())); + } + + #[tokio::test] + async fn the_fee_is_charged_only_on_what_is_collected() { + let (db, sub, user_id, sub_id) = setup().await; + revolut_config(&db, 20, 0).await; + give_credit(&db, user_id, 5_000_000, true).await; + let quoted = quote(&sub, sub_id).await.unwrap(); + assert_eq!(quoted.processing_fee, 10, "half the bill is collected"); + + let (db, sub, user_id, sub_id) = setup().await; + revolut_config(&db, 20, 0).await; + give_credit(&db, user_id, 20_000_000, true).await; + assert_eq!(quote(&sub, sub_id).await.unwrap().processing_fee, 0); + let payment = renew(&sub, sub_id).await.unwrap(); + assert!(payment.is_paid, "nothing left to collect"); + assert_eq!(payment.processing_fee, 0); + } + + #[tokio::test] + async fn a_remainder_below_the_minimum_uses_less_credit() { + let (db, sub, user_id, sub_id) = setup().await; + revolut_config(&db, 0, 300).await; + give_credit(&db, user_id, 9_000_000, true).await; + + let quoted = quote(&sub, sub_id).await.unwrap(); + let credit = quoted.credit.unwrap(); + assert_eq!(credit.amount, 700); + assert_eq!(credit.base_amount, 7_000_000); + + let (db, sub, user_id, sub_id) = setup().await; + revolut_config(&db, 0, 300).await; + give_credit(&db, user_id, 2_000_000, true).await; + assert_eq!( + quote(&sub, sub_id).await.unwrap().credit.unwrap().amount, + 200 + ); + } + + async fn upgradable_vm(db: &MockDb, user_id: u64) -> u64 { + use lnvps_db::{DiskInterface, DiskType, VmCustomPricing, VmCustomPricingDisk}; + db.custom_pricing.lock().await.insert( + 1, + VmCustomPricing { + id: 1, + name: "custom".to_string(), + enabled: true, + created: Utc::now(), + expires: None, + region_id: 1, + currency: "EUR".to_string(), + cpu_cost: 200, + memory_cost: 100, + min_cpu: 1, + max_cpu: 16, + min_memory: lnvps_api_common::GB, + max_memory: 64 * lnvps_api_common::GB, + min_ip4: 1, + max_ip4: 4, + min_ip6: 1, + max_ip6: 4, + ..Default::default() + }, + ); + db.custom_pricing_disk.lock().await.insert( + 1, + VmCustomPricingDisk { + id: 1, + pricing_id: 1, + kind: DiskType::SSD, + interface: DiskInterface::PCIe, + cost: 50, + min_disk_size: 5 * lnvps_api_common::GB, + max_disk_size: lnvps_api_common::TB, + }, + ); + let (_sub_id, items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "vm".to_string(), + description: None, + created: Utc::now(), + expires: Some(Utc::now() + chrono::Duration::days(15)), + is_active: true, + is_setup: true, + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: LineItemType::Vps, + name: "vm".to_string(), + description: None, + amount: 1000, + setup_amount: 0, + configuration: None, + }], + ) + .await + .unwrap(); + db.insert_vm(&lnvps_db::Vm { + host_id: 1, + user_id, + image_id: 1, + template_id: Some(1), + subscription_line_item_id: items[0], + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + ..Default::default() + }) + .await + .unwrap() + } + + #[tokio::test] + async fn an_upgrade_is_quoted_and_paid_with_credit() { + let (db, sub, user_id, _sub_id) = setup().await; + let vm_id = upgradable_vm(&db, user_id).await; + give_credit(&db, user_id, 100_000_000_000, true).await; + let cfg = lnvps_api_common::UpgradeConfig { + new_cpu: Some(4), + ..Default::default() + }; + let cost = sub + .pricing_engine() + .calculate_vm_upgrade_cost(vm_id, &cfg, PaymentMethod::Lightning) + .await + .unwrap(); + let net = cost.upgrade.amount.value(); + let (fee, credit) = sub + .quote_vm_upgrade_credit( + vm_id, + PaymentMethod::Lightning, + net, + cost.tax.amount, + cost.processing_fee, + Currency::BTC, + ) + .await + .unwrap(); + assert_eq!(fee, 0); + assert_eq!(credit.unwrap().amount, net + cost.tax.amount); + + let payment = sub + .create_vm_upgrade_payment(vm_id, &cfg, PaymentMethod::Lightning, RenewMode::default()) + .await + .unwrap(); + assert!(payment.is_paid); + assert_eq!(payment.external_data.as_str(), CREDIT_FUNDED_MARKER); + assert_eq!(payment.payment_type, SubscriptionPaymentType::Upgrade); + assert_eq!( + db.get_account_credit(user_id).await.unwrap().balance, + 100_000_000_000 - (payment.amount + payment.tax) + ); + } + + #[tokio::test] + async fn an_upgrade_partly_paid_with_credit_invoices_the_rest() { + let (db, sub, user_id, _sub_id) = setup().await; + let vm_id = upgradable_vm(&db, user_id).await; + give_credit(&db, user_id, 1_000, true).await; + let cfg = lnvps_api_common::UpgradeConfig { + new_cpu: Some(4), + ..Default::default() + }; + let payment = sub + .create_vm_upgrade_payment(vm_id, &cfg, PaymentMethod::Lightning, RenewMode::default()) + .await + .unwrap(); + assert!(!payment.is_paid); + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .unwrap(); + assert_eq!(debit.amount, 1_000); + assert_eq!( + db.get_account_credit_reserved(user_id).await.unwrap(), + 1_000 + ); + } + + #[tokio::test] + async fn ledger_and_balance_stay_consistent() { + let (db, sub, user_id, sub_id) = setup().await; + let topup_id = db + .insert_account_credit_topup(&lnvps_db::AccountCreditTopup { + user_id, + amount: 10_000_000, + currency: "BTC".to_string(), + invoice_hash: vec![1u8; 32], + ..Default::default() + }) + .await + .unwrap(); + db.settle_account_credit_topup(topup_id).await.unwrap(); + give_credit(&db, user_id, 0, true).await; + + let first = renew(&sub, sub_id).await.unwrap(); + assert!(first.is_paid); + assert_eq!(db.get_account_credit(user_id).await.unwrap().balance, 0); + + let second = renew(&sub, sub_id).await.unwrap(); + assert!(!second.is_paid); + + let (events, total) = db.list_account_credit_events(user_id, 10, 0).await.unwrap(); + assert_eq!(total, 2); + assert_eq!(events[0].kind, "spend"); + assert_eq!(events[0].amount, -10_000_000); + assert_eq!(ledger_sum(&db, user_id).await, 0); + } + + /// Auto-renewal uses the credit balance when opted in: fully covered means + /// the payment settles immediately with no saved payment method at all. + #[tokio::test] + async fn auto_renew_fully_covered_by_credit() { + let (db, sub, user_id, sub_id) = setup().await; + // 15.00 EUR of credit against a 10.00 EUR bill. + give_credit(&db, user_id, 15_000_000, true).await; + // Enable auto-renewal, and make the subscription "expiring soon" by + // dating its expiry inside the lead window. + let mut s = db.get_subscription(sub_id).await.unwrap(); + s.auto_renewal_enabled = true; + s.expires = Some(Utc::now() + chrono::Duration::hours(1)); + db.update_subscription(&s).await.unwrap(); + + let payment = sub.auto_renew(sub_id).await.unwrap(); + assert!(payment.is_paid, "fully credit-funded settles immediately"); + assert_eq!(payment.external_data.as_str(), CREDIT_FUNDED_MARKER); + assert_eq!( + db.get_account_credit(user_id).await.unwrap().balance, + 15_000_000 - 10_000_000, + "the bill is spent from the balance" + ); + } + + /// A user with credit opted in but no saved payment method can still + /// auto-renew: the worker counts credit as a method. + #[tokio::test] + async fn auto_renew_credit_counts_when_no_saved_method() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 15_000_000, true).await; + let mut s = db.get_subscription(sub_id).await.unwrap(); + s.auto_renewal_enabled = true; + s.expires = Some(Utc::now() + chrono::Duration::hours(1)); + db.update_subscription(&s).await.unwrap(); + + // No saved payment method exists at all. + assert!( + db.list_user_payment_methods(user_id, None) + .await + .unwrap() + .is_empty() + ); + + // The worker's gate would pass with credit alone; the handler itself + // must also succeed. + assert!(sub.auto_renew(sub_id).await.is_ok()); + } + + fn revolut_card(user_id: u64) -> lnvps_db::UserPaymentMethod { + lnvps_db::UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: "revolut".to_string(), + name: None, + external_customer_id: Some("cust1".to_string().into()), + external_id: "pm1".to_string().into(), + card_brand: Some("VISA".to_string()), + card_last_four: Some("5709".to_string()), + exp_month: Some(12), + exp_year: Some(2999), + is_default: true, + enabled: true, + } + } + + async fn auto_renewing_sub(db: &MockDb, sub_id: u64) { + let mut s = db.get_subscription(sub_id).await.unwrap(); + s.auto_renewal_enabled = true; + s.expires = Some(Utc::now() + chrono::Duration::hours(1)); + db.update_subscription(&s).await.unwrap(); + } + + /// Opted in but with nothing to spend, a card-only user still renews on the card. + #[tokio::test] + async fn auto_renew_opted_in_with_zero_balance_charges_the_card() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 0, true).await; + db.insert_user_payment_method(&revolut_card(user_id)) + .await + .unwrap(); + auto_renewing_sub(&db, sub_id).await; + + let payment = sub.auto_renew(sub_id).await.unwrap(); + assert_eq!(payment.payment_method, PaymentMethod::Revolut); + assert!( + db.get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .is_none() + ); + } + + /// Partial credit with a saved card: the card is charged the remainder. + #[tokio::test] + async fn auto_renew_partial_credit_charges_the_remainder_to_the_card() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 4_000_000, true).await; + db.insert_user_payment_method(&revolut_card(user_id)) + .await + .unwrap(); + auto_renewing_sub(&db, sub_id).await; + + let payment = sub.auto_renew(sub_id).await.unwrap(); + assert_eq!(payment.payment_method, PaymentMethod::Revolut); + let debit = db + .get_account_credit_debit_by_payment(&payment.id) + .await + .unwrap() + .expect("credit applied"); + assert_eq!(debit.amount, 4_000_000); + } + + /// Partial credit and no saved method cannot complete: it errors without + /// leaving an unpaid invoice behind. + #[tokio::test] + async fn auto_renew_partial_credit_without_a_method_errors() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 4_000_000, true).await; + auto_renewing_sub(&db, sub_id).await; + let err = sub.auto_renew(sub_id).await.unwrap_err(); + assert!(err.to_string().contains("does not cover"), "{err}"); + assert!( + db.list_subscription_payments(sub_id) + .await + .unwrap() + .is_empty() + ); + } + + /// Credit covering the whole bill settles on credit even with a card saved: + /// the card is not charged the processing fee. + #[tokio::test] + async fn auto_renew_full_credit_does_not_touch_the_card() { + let (db, sub, user_id, sub_id) = setup().await; + give_credit(&db, user_id, 15_000_000, true).await; + db.insert_user_payment_method(&revolut_card(user_id)) + .await + .unwrap(); + auto_renewing_sub(&db, sub_id).await; + + let payment = sub.auto_renew(sub_id).await.unwrap(); + assert!(payment.is_paid); + assert_eq!(payment.payment_method, PaymentMethod::Lightning); + assert_eq!(payment.external_data.as_str(), CREDIT_FUNDED_MARKER); + } + + /// Auto-renewal is unchanged for a user without credit: no saved method + /// means the usual error, not a credit-funnel surprise. + #[tokio::test] + async fn auto_renew_without_credit_falls_back_to_saved_method() { + let (_db, sub, _user_id, sub_id) = setup().await; + let err = sub.auto_renew(sub_id).await.unwrap_err(); + assert!( + err.to_string().contains("No usable saved payment method"), + "unexpected error: {err}" + ); + } +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 9f32e4cb..a2a97806 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -471,8 +471,9 @@ impl Worker { // earlier ones are notice only. let is_final_reminder = due <= lead; - // Attempt auto-renewal using the user's default saved payment method - // (NWC Lightning wallet or Revolut card), dispatched by provider. + // Attempt auto-renewal using the user's account credit or default + // saved payment method (NWC Lightning wallet or Revolut card), + // dispatched by provider. let mut auto_renewed = false; if sub.auto_renewal_enabled { @@ -482,19 +483,34 @@ impl Worker { .await .map(|m| m.iter().any(|pm| pm.enabled)) .unwrap_or(false); - if has_method && !is_final_reminder { + // A credit balance the user opted into is also a renewable + // method, so it counts even when no saved method exists. + let has_credit = self + .db + .get_account_credit(sub.user_id) + .await + .map(|c| c.auto_apply && c.balance > 0) + .unwrap_or(false); + if (has_method || has_credit) && !is_final_reminder { // It will renew itself before it expires; an "expiring soon" // warning a week out would just be wrong. return Ok(()); } - if has_method { + if has_method || has_credit { info!("Attempting auto-renewal for subscription {}", sub.id); match self.subscription_handler.auto_renew(sub.id).await { - Ok(_) => { + Ok(payment) => { info!("Successfully auto-renewed subscription {}", sub.id); + let paid_with = if payment.external_data.as_str() + == crate::subscription::CREDIT_FUNDED_MARKER + { + "your account credit" + } else { + "your saved payment method" + }; self.queue_notification( sub.user_id, - format!("Your subscription is being automatically renewed using your saved payment method.\n{}", sub_notification_descr), + format!("Your subscription is being automatically renewed using {}.\n{}", paid_with, sub_notification_descr), Some(format!("[{}] Auto-Renewed", sub_notification_subject)), ).await; auto_renewed = true; @@ -3943,12 +3959,21 @@ impl Worker { ); } + let credit_note = if outcome.credit_returned > 0 { + format!( + " {} sats paid with account credit were returned to your balance.", + outcome.credit_returned / 1000 + ) + } else { + String::new() + }; self.queue_notification( vm.user_id, format!( - "Your VM #{} has been refunded {} sats and will be deleted.", + "Your VM #{} has been refunded {} sats and will be deleted.{}", vm_id, - outcome.amount_msat / 1000 + outcome.amount_msat / 1000, + credit_note ), Some(format!("[VM{}] Refunded", vm_id)), ) diff --git a/lnvps_api_admin/src/admin/credit.rs b/lnvps_api_admin/src/admin/credit.rs new file mode 100644 index 00000000..31fa9c62 --- /dev/null +++ b/lnvps_api_admin/src/admin/credit.rs @@ -0,0 +1,208 @@ +//! Admin account credit: read a user's balance and ledger, and adjust it +//! (manual top-up or clawback). See `work/account-credit.md` and issue #145. + +use crate::admin::RouterState; +use crate::admin::auth::AdminAuth; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; +use lnvps_db::{AccountCredit, AccountCreditEvent, AdminAction, AdminResource}; +use serde::{Deserialize, Serialize}; + +pub fn router() -> Router { + Router::new() + .route( + "/api/admin/v1/users/{id}/credit", + get(admin_get_user_credit).patch(admin_adjust_user_credit), + ) + .route( + "/api/admin/v1/users/{id}/credit/events", + get(admin_list_user_credit_events), + ) +} + +/// A user's account credit balance, as shown to an admin. +#[derive(Serialize)] +pub struct AdminAccountCredit { + pub user_id: u64, + pub balance: u64, + pub currency: String, + pub auto_apply: bool, +} + +impl From for AdminAccountCredit { + fn from(c: AccountCredit) -> Self { + Self { + user_id: c.user_id, + balance: c.balance, + currency: c.currency, + auto_apply: c.auto_apply, + } + } +} + +/// One row of the account credit ledger. +#[derive(Serialize)] +pub struct AdminAccountCreditEvent { + pub id: u64, + pub kind: String, + pub amount: i64, + pub currency: String, + pub subscription_payment_id: Option, + pub note: Option, + pub created: chrono::DateTime, +} + +impl From for AdminAccountCreditEvent { + fn from(e: AccountCreditEvent) -> Self { + Self { + id: e.id, + kind: e.kind, + amount: e.amount, + currency: e.currency, + subscription_payment_id: e.subscription_payment_id.as_ref().map(hex::encode), + note: e.note, + created: e.created, + } + } +} + +#[derive(Deserialize)] +struct AdjustCreditRequest { + /// Signed msat delta. Positive credits, negative claws back. + delta: i64, + /// Ledger `kind`: `topup`, `refund` (both positive only) or `admin`. + #[serde(default = "default_kind")] + kind: String, + /// Free-form context stored on the ledger row. + note: Option, +} + +fn default_kind() -> String { + "admin".to_string() +} + +const KINDS: [&str; 3] = ["topup", "refund", "admin"]; + +/// A user's account credit balance. +async fn admin_get_user_credit( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::AccountCredit, AdminAction::View)?; + let credit = this.db.get_account_credit(id).await?; + ApiData::ok(AdminAccountCredit::from(credit)) +} + +/// Shift a user's balance (manual top-up or clawback) and record it in the +/// ledger. +async fn admin_adjust_user_credit( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::AccountCredit, AdminAction::Update)?; + if req.delta == 0 { + return ApiData::err("delta must be non-zero"); + } + if !KINDS.contains(&req.kind.as_str()) { + return ApiData::err("kind must be one of: topup, refund, admin"); + } + if req.delta < 0 && req.kind != "admin" { + return ApiData::err("a negative delta must use kind admin"); + } + let credit = this + .db + .adjust_account_credit(id, req.delta, &req.kind, req.note.as_deref()) + .await + .map_err(|e| ApiError::new(e.to_string()))?; + ApiData::ok(AdminAccountCredit::from(credit)) +} + +/// A user's account credit ledger, newest first, paginated. +async fn admin_list_user_credit_events( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Query(q): Query, +) -> ApiPaginatedResult { + auth.require_permission(AdminResource::AccountCredit, AdminAction::View)?; + let limit = q.limit.unwrap_or(50).min(100); + let offset = q.offset.unwrap_or(0); + let (events, total) = this + .db + .list_account_credit_events(id, limit, offset) + .await + .map_err(|e| ApiError::new(e.to_string()))?; + let out: Vec = events + .into_iter() + .map(AdminAccountCreditEvent::from) + .collect(); + ApiPaginatedData::ok(out, total, limit, offset) +} + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_api_common::MockDb; + use lnvps_db::LNVpsDbBase; + use std::sync::Arc; + + /// The API view of a ledger row hex-encodes its payment hash. + #[test] + fn admin_event_encodes_payment_hash() { + let e = AccountCreditEvent { + kind: "spend".to_string(), + amount: 100, + subscription_payment_id: Some(vec![3u8; 32]), + ..Default::default() + }; + let api = AdminAccountCreditEvent::from(e); + assert_eq!( + api.subscription_payment_id.as_deref(), + Some(hex::encode(vec![3u8; 32]).as_str()) + ); + } + + /// An admin adjustment via the DB layer is reflected in the API view. + #[tokio::test] + async fn admin_adjust_via_db_shows_in_api_view() { + let db = Arc::new(MockDb::default()); + let row = db + .adjust_account_credit(5, 2500, "topup", Some("manual")) + .await + .unwrap(); + let (events, _) = db.list_account_credit_events(5, 10, 0).await.unwrap(); + assert_eq!( + AdminAccountCreditEvent::from(events[0].clone()).amount, + 2500 + ); + db.adjust_account_credit(5, -500, "admin", None) + .await + .unwrap(); + let (events, _) = db.list_account_credit_events(5, 10, 0).await.unwrap(); + assert_eq!( + AdminAccountCreditEvent::from(events[0].clone()).amount, + -500 + ); + let api = AdminAccountCredit::from(row); + assert_eq!(api.user_id, 5); + assert_eq!(api.balance, 2500); + assert_eq!(db.get_account_credit(5).await.unwrap().balance, 2000); + assert_eq!(api.currency, "BTC"); + } + + /// The default ledger kind is `admin`. + #[test] + fn default_kind_is_admin() { + assert_eq!(default_kind(), "admin"); + assert!(KINDS.contains(&"topup")); + assert!(KINDS.contains(&"refund")); + assert!(KINDS.contains(&"admin")); + } +} diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index ad67cd40..60623691 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -15,6 +15,7 @@ mod bulk_message; mod companies; mod cost_plans; mod costs; +mod credit; mod custom_pricing; mod custom_templates; mod discounts; @@ -84,6 +85,7 @@ pub fn admin_router( .merge(vm_os_images::router()) .merge(vm_templates::router()) .merge(companies::router()) + .merge(credit::router()) .merge(cost_plans::router()) .merge(costs::router()) .merge(custom_pricing::router()) diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 8bcf6cfc..39c9f42a 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -79,6 +79,10 @@ pub struct MockDb { pub referrals: Arc>>, pub discounts: Arc>>, pub discount_redemptions: Arc>>, + pub account_credits: Arc>>, + pub account_credit_events: Arc>>, + pub account_credit_debits: Arc>>, + pub account_credit_topups: Arc>>, pub marketplace_operators: Arc>>, pub marketplace_nodes: Arc>>, pub marketplace_node_health: Arc>>, @@ -534,6 +538,10 @@ impl Default for MockDb { referrals: Arc::new(Default::default()), discounts: Arc::new(Default::default()), discount_redemptions: Arc::new(Default::default()), + account_credits: Arc::new(Default::default()), + account_credit_events: Arc::new(Default::default()), + account_credit_debits: Arc::new(Default::default()), + account_credit_topups: Arc::new(Default::default()), marketplace_operators: Arc::new(Default::default()), marketplace_nodes: Arc::new(Default::default()), marketplace_node_health: Arc::new(Default::default()), @@ -565,6 +573,29 @@ impl Default for MockDb { } } +fn empty_account_credit(user_id: u64) -> lnvps_db::AccountCredit { + lnvps_db::AccountCredit { + user_id, + balance: 0, + currency: "BTC".to_string(), + auto_apply: false, + updated: Utc::now(), + } +} + +fn reserved_credit( + debits: &[lnvps_db::AccountCreditDebit], + user_id: u64, + exclude: Option, +) -> u64 { + let now = Utc::now(); + debits + .iter() + .filter(|d| d.user_id == user_id && !d.settled && d.expires > now && Some(d.id) != exclude) + .map(|d| d.amount) + .sum() +} + #[async_trait] impl LNVpsDbBase for MockDb { async fn migrate(&self) -> DbResult<()> { @@ -4324,6 +4355,283 @@ impl LNVpsDbBase for MockDb { Ok(totals.into_iter().collect()) } + // ======================================================================== + // Account credit + // ======================================================================== + + async fn get_account_credit(&self, user_id: u64) -> DbResult { + let credits = self.account_credits.lock().await; + Ok(credits + .get(&user_id) + .cloned() + .unwrap_or_else(|| empty_account_credit(user_id))) + } + + async fn set_account_credit_auto_apply( + &self, + user_id: u64, + auto_apply: bool, + ) -> DbResult { + let mut credits = self.account_credits.lock().await; + let row = credits + .entry(user_id) + .or_insert_with(|| empty_account_credit(user_id)); + row.auto_apply = auto_apply; + row.updated = Utc::now(); + Ok(row.clone()) + } + + async fn get_account_credit_reserved(&self, user_id: u64) -> DbResult { + let debits = self.account_credit_debits.lock().await; + Ok(reserved_credit(&debits, user_id, None)) + } + + async fn adjust_account_credit( + &self, + user_id: u64, + delta: i64, + kind: &str, + note: Option<&str>, + ) -> DbResult { + let mut credits = self.account_credits.lock().await; + let debits = self.account_credit_debits.lock().await; + let mut events = self.account_credit_events.lock().await; + let row = credits + .entry(user_id) + .or_insert_with(|| empty_account_credit(user_id)); + if delta == 0 { + return Ok(row.clone()); + } + if delta > 0 { + row.balance += delta.unsigned_abs(); + } else { + let available = row + .balance + .saturating_sub(reserved_credit(&debits, user_id, None)); + if delta.unsigned_abs() > available { + return Err(anyhow!( + "account credit for user {user_id} has {available} unreserved, cannot remove {}", + delta.unsigned_abs() + ) + .into()); + } + row.balance -= delta.unsigned_abs(); + } + row.updated = Utc::now(); + let event_id = events.len() as u64 + 1; + events.push(lnvps_db::AccountCreditEvent { + id: event_id, + user_id, + kind: kind.to_string(), + amount: delta, + currency: row.currency.clone(), + subscription_payment_id: None, + note: note.map(str::to_string), + created: Utc::now(), + }); + Ok(row.clone()) + } + + async fn list_account_credit_events( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let events = self.account_credit_events.lock().await; + let mut all: Vec = events + .iter() + .filter(|e| e.user_id == user_id) + .cloned() + .collect(); + all.sort_by_key(|e| std::cmp::Reverse(e.id)); + let total = all.len() as u64; + Ok(( + all.into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(), + total, + )) + } + + async fn reserve_account_credit( + &self, + debit: &lnvps_db::AccountCreditDebit, + ) -> DbResult> { + let credits = self.account_credits.lock().await; + let mut debits = self.account_credit_debits.lock().await; + let balance = credits.get(&debit.user_id).map(|c| c.balance).unwrap_or(0); + if balance.saturating_sub(reserved_credit(&debits, debit.user_id, None)) < debit.amount { + return Ok(None); + } + let id = debits.len() as u64 + 1; + debits.push(lnvps_db::AccountCreditDebit { + id, + subscription_payment_id: None, + settled: false, + settled_at: None, + created: Utc::now(), + ..debit.clone() + }); + Ok(Some(id)) + } + + async fn attach_account_credit_debit( + &self, + id: u64, + subscription_payment_id: &Vec, + expires: DateTime, + ) -> DbResult<()> { + let mut debits = self.account_credit_debits.lock().await; + if let Some(d) = debits.iter_mut().find(|d| d.id == id && !d.settled) { + d.subscription_payment_id = Some(subscription_payment_id.clone()); + d.expires = expires; + } + Ok(()) + } + + async fn release_account_credit_debit(&self, id: u64) -> DbResult<()> { + let mut debits = self.account_credit_debits.lock().await; + debits.retain(|d| d.id != id || d.settled); + Ok(()) + } + + async fn get_account_credit_debit_by_payment( + &self, + subscription_payment_id: &Vec, + ) -> DbResult> { + let debits = self.account_credit_debits.lock().await; + Ok(debits + .iter() + .find(|d| d.subscription_payment_id.as_ref() == Some(subscription_payment_id)) + .cloned()) + } + + async fn settle_account_credit_debit( + &self, + subscription_payment_id: &Vec, + ) -> DbResult> { + let mut credits = self.account_credits.lock().await; + let mut debits = self.account_credit_debits.lock().await; + let mut events = self.account_credit_events.lock().await; + let Some(idx) = debits.iter().position(|d| { + d.subscription_payment_id.as_ref() == Some(subscription_payment_id) && !d.settled + }) else { + return Ok(None); + }; + let user_id = debits[idx].user_id; + let row = credits + .entry(user_id) + .or_insert_with(|| empty_account_credit(user_id)); + let available = if debits[idx].expires > Utc::now() { + row.balance + } else { + row.balance + .saturating_sub(reserved_credit(&debits, user_id, Some(debits[idx].id))) + }; + let debit = &mut debits[idx]; + debit.clamp_to(available); + debit.settled = true; + debit.settled_at = Some(Utc::now()); + row.balance -= debit.amount; + row.updated = Utc::now(); + let event_id = events.len() as u64 + 1; + events.push(lnvps_db::AccountCreditEvent { + id: event_id, + user_id, + kind: "spend".to_string(), + amount: -(debit.amount as i64), + currency: debit.currency.clone(), + subscription_payment_id: debit.subscription_payment_id.clone(), + note: None, + created: Utc::now(), + }); + Ok(Some(debit.clone())) + } + + async fn insert_account_credit_topup( + &self, + topup: &lnvps_db::AccountCreditTopup, + ) -> DbResult { + let mut topups = self.account_credit_topups.lock().await; + let new_id = topups.len() as u64 + 1; + topups.push(lnvps_db::AccountCreditTopup { + id: new_id, + is_paid: false, + paid_at: None, + created: Utc::now(), + ..topup.clone() + }); + Ok(new_id) + } + + async fn get_account_credit_topup_by_hash( + &self, + invoice_hash: &Vec, + ) -> DbResult> { + let topups = self.account_credit_topups.lock().await; + Ok(topups + .iter() + .find(|t| &t.invoice_hash == invoice_hash) + .cloned()) + } + + async fn settle_account_credit_topup( + &self, + id: u64, + ) -> DbResult> { + let mut topups = self.account_credit_topups.lock().await; + let mut credits = self.account_credits.lock().await; + let mut events = self.account_credit_events.lock().await; + let Some(row) = topups.iter_mut().find(|t| t.id == id && !t.is_paid) else { + return Ok(None); + }; + row.is_paid = true; + row.paid_at = Some(Utc::now()); + let settled = row.clone(); + let entry = credits + .entry(settled.user_id) + .or_insert_with(|| empty_account_credit(settled.user_id)); + entry.balance += settled.amount; + entry.updated = Utc::now(); + let event_id = events.len() as u64 + 1; + events.push(lnvps_db::AccountCreditEvent { + id: event_id, + user_id: settled.user_id, + kind: "topup".to_string(), + amount: settled.amount as i64, + currency: settled.currency.clone(), + subscription_payment_id: None, + note: None, + created: Utc::now(), + }); + Ok(Some(settled)) + } + + async fn list_account_credit_topups( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let topups = self.account_credit_topups.lock().await; + let mut all: Vec = topups + .iter() + .filter(|t| t.user_id == user_id) + .cloned() + .collect(); + all.sort_by_key(|t| std::cmp::Reverse(t.id)); + let total = all.len() as u64; + Ok(( + all.into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(), + total, + )) + } + // ----- Marketplace (operator-run compute nodes) ----- async fn get_marketplace_operator(&self, id: u64) -> DbResult { @@ -13031,3 +13339,349 @@ mod vpn_tests { assert!(db.replace_tunnel_routes(9999, &[]).await.is_err()); } } + +#[cfg(test)] +mod account_credit_tests { + use super::*; + use lnvps_db::{AccountCreditDebit, AccountCreditTopup, LNVpsDbBase}; + + fn hold(user_id: u64, amount: u64, expires_in: i64) -> AccountCreditDebit { + AccountCreditDebit { + user_id, + amount, + currency: "BTC".to_string(), + payment_amount: amount / 10, + expires: Utc::now() + TimeDelta::seconds(expires_in), + ..Default::default() + } + } + + async fn ledger_sum(db: &MockDb, user_id: u64) -> i64 { + let (events, _) = db + .list_account_credit_events(user_id, 1000, 0) + .await + .unwrap(); + events.iter().map(|e| e.amount).sum() + } + + #[tokio::test] + async fn get_credit_returns_zero_row_for_unknown_user() { + let db = MockDb::default(); + let row = db.get_account_credit(42).await.unwrap(); + assert_eq!(row.user_id, 42); + assert_eq!(row.balance, 0); + assert_eq!(row.currency, "BTC"); + assert!(!row.auto_apply); + } + + #[tokio::test] + async fn set_auto_apply_keeps_the_balance() { + let db = MockDb::default(); + db.adjust_account_credit(7, 500, "topup", None) + .await + .unwrap(); + let row = db.set_account_credit_auto_apply(7, true).await.unwrap(); + assert!(row.auto_apply); + assert_eq!(row.balance, 500); + let row = db.set_account_credit_auto_apply(7, false).await.unwrap(); + assert!(!row.auto_apply); + assert_eq!(row.balance, 500); + } + + #[tokio::test] + async fn ledger_is_signed_and_sums_to_the_balance() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + db.adjust_account_credit(1, -300, "admin", Some("clawback")) + .await + .unwrap(); + let id = db + .reserve_account_credit(&hold(1, 200, 600)) + .await + .unwrap() + .unwrap(); + db.attach_account_credit_debit(id, &vec![1u8; 32], Utc::now() + TimeDelta::minutes(10)) + .await + .unwrap(); + db.settle_account_credit_debit(&vec![1u8; 32]) + .await + .unwrap(); + + let (events, total) = db.list_account_credit_events(1, 10, 0).await.unwrap(); + assert_eq!(total, 3); + assert_eq!( + events + .iter() + .map(|e| (e.kind.as_str(), e.amount)) + .collect::>(), + vec![("spend", -200), ("admin", -300), ("topup", 1000)] + ); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 500); + assert_eq!(ledger_sum(&db, 1).await, 500); + } + + #[tokio::test] + async fn ledger_is_paginated_newest_first() { + let db = MockDb::default(); + for i in 1..=5 { + db.adjust_account_credit(1, i * 100, "topup", None) + .await + .unwrap(); + } + db.adjust_account_credit(2, 1, "topup", None).await.unwrap(); + + let (page, total) = db.list_account_credit_events(1, 3, 0).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(page.len(), 3); + assert!(page[0].id > page[1].id && page[1].id > page[2].id); + let (page2, _) = db.list_account_credit_events(1, 3, 3).await.unwrap(); + assert_eq!(page2.len(), 2); + } + + #[tokio::test] + async fn a_reservation_holds_credit_until_it_expires() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + + assert!( + db.reserve_account_credit(&hold(1, 700, 600)) + .await + .unwrap() + .is_some() + ); + assert_eq!(db.get_account_credit_reserved(1).await.unwrap(), 700); + assert!( + db.reserve_account_credit(&hold(1, 700, 600)) + .await + .unwrap() + .is_none(), + "the same credit cannot be promised twice" + ); + assert!( + db.reserve_account_credit(&hold(1, 300, 600)) + .await + .unwrap() + .is_some() + ); + + let other = MockDb::default(); + other + .adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + other + .reserve_account_credit(&hold(1, 700, -1)) + .await + .unwrap() + .unwrap(); + assert_eq!(other.get_account_credit_reserved(1).await.unwrap(), 0); + assert!( + other + .reserve_account_credit(&hold(1, 1000, 600)) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn a_released_reservation_frees_the_credit() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + let id = db + .reserve_account_credit(&hold(1, 1000, 600)) + .await + .unwrap() + .unwrap(); + db.release_account_credit_debit(id).await.unwrap(); + assert_eq!(db.get_account_credit_reserved(1).await.unwrap(), 0); + assert!( + db.reserve_account_credit(&hold(1, 1000, 600)) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn settle_moves_the_balance_once() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + let pid = vec![1u8; 32]; + let id = db + .reserve_account_credit(&hold(1, 400, 600)) + .await + .unwrap() + .unwrap(); + db.attach_account_credit_debit(id, &pid, Utc::now() + TimeDelta::minutes(10)) + .await + .unwrap(); + let row = db + .get_account_credit_debit_by_payment(&pid) + .await + .unwrap() + .unwrap(); + assert!(!row.settled); + + let settled = db.settle_account_credit_debit(&pid).await.unwrap().unwrap(); + assert_eq!(settled.amount, 400); + assert!(settled.settled); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 600); + assert_eq!(db.get_account_credit_reserved(1).await.unwrap(), 0); + + assert!( + db.settle_account_credit_debit(&pid) + .await + .unwrap() + .is_none() + ); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 600); + assert!( + db.settle_account_credit_debit(&vec![2u8; 32]) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn a_late_settle_cannot_take_credit_promised_to_a_live_order() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + let late = vec![1u8; 32]; + let id = db + .reserve_account_credit(&hold(1, 800, 600)) + .await + .unwrap() + .unwrap(); + db.attach_account_credit_debit(id, &late, Utc::now() - TimeDelta::seconds(1)) + .await + .unwrap(); + db.reserve_account_credit(&hold(1, 700, 600)) + .await + .unwrap() + .unwrap(); + + let settled = db + .settle_account_credit_debit(&late) + .await + .unwrap() + .unwrap(); + assert_eq!(settled.amount, 300, "only what the live order left over"); + assert_eq!(settled.payment_amount, 30, "payment figure scaled with it"); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 700); + assert_eq!(ledger_sum(&db, 1).await, 700); + } + + #[tokio::test] + async fn a_clawback_cannot_take_reserved_credit() { + let db = MockDb::default(); + db.adjust_account_credit(1, 1000, "topup", None) + .await + .unwrap(); + db.reserve_account_credit(&hold(1, 600, 600)) + .await + .unwrap() + .unwrap(); + assert!( + db.adjust_account_credit(1, -401, "admin", None) + .await + .is_err() + ); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 1000); + let row = db + .adjust_account_credit(1, -400, "admin", None) + .await + .unwrap(); + assert_eq!(row.balance, 600); + assert_eq!( + db.adjust_account_credit(1, 0, "admin", None) + .await + .unwrap() + .balance, + 600 + ); + } + + #[tokio::test] + async fn settle_topup_increases_balance_and_is_idempotent() { + let db = MockDb::default(); + let hash = vec![5u8; 32]; + let id = db + .insert_account_credit_topup(&AccountCreditTopup { + user_id: 1, + amount: 2500, + currency: "BTC".to_string(), + invoice_hash: hash.clone(), + ..Default::default() + }) + .await + .unwrap(); + let found = db + .get_account_credit_topup_by_hash(&hash) + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, id); + assert!(!found.is_paid); + assert!( + db.get_account_credit_topup_by_hash(&vec![6u8; 32]) + .await + .unwrap() + .is_none() + ); + + let settled = db.settle_account_credit_topup(id).await.unwrap().unwrap(); + assert!(settled.is_paid); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 2500); + assert_eq!(ledger_sum(&db, 1).await, 2500); + + assert!(db.settle_account_credit_topup(id).await.unwrap().is_none()); + assert_eq!(db.get_account_credit(1).await.unwrap().balance, 2500); + assert!( + db.settle_account_credit_topup(9999) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn topup_history_is_paginated_per_user() { + let db = MockDb::default(); + for i in 0..3 { + db.insert_account_credit_topup(&AccountCreditTopup { + user_id: 1, + amount: i + 1, + invoice_hash: vec![i as u8; 32], + ..Default::default() + }) + .await + .unwrap(); + } + db.insert_account_credit_topup(&AccountCreditTopup { + user_id: 2, + amount: 99, + invoice_hash: vec![9u8; 32], + ..Default::default() + }) + .await + .unwrap(); + let (page, total) = db.list_account_credit_topups(1, 2, 0).await.unwrap(); + assert_eq!(total, 3); + assert_eq!(page.len(), 2); + assert!(page[0].id > page[1].id); + let (rest, _) = db.list_account_credit_topups(1, 2, 2).await.unwrap(); + assert_eq!(rest.len(), 1); + } +} diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 38f3b7f6..3248c776 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -569,43 +569,49 @@ impl PricingEngine { method: PaymentMethod, gross: CurrencyAmount, ) -> Result<()> { + let min = self + .min_amount_in(company_id, method, gross.currency()) + .await?; + ensure!( + gross.value() >= min, + "Amount is below the minimum for this payment method ({} {})", + CurrencyAmount::from_u64(gross.currency(), min).value_f32(), + gross.currency() + ); + Ok(()) + } + + /// The method's configured minimum processable amount in `currency`, or 0. + pub async fn min_amount_in( + &self, + company_id: u64, + method: PaymentMethod, + currency: Currency, + ) -> Result { if method == PaymentMethod::Lightning { - return Ok(()); + return Ok(0); } - let config = match self + let Ok(config) = self .db .get_payment_method_config_for_company(company_id, method) .await - { - Ok(c) => c, - // No config -> nothing to enforce (fee calc logs this case already) - Err(_) => return Ok(()), + else { + return Ok(0); }; let (Some(min_raw), Some(min_currency_str)) = (config.min_amount, config.min_amount_currency.as_deref()) else { - return Ok(()); + return Ok(0); }; if min_raw == 0 { - return Ok(()); + return Ok(0); } let min_currency = Currency::from_str(min_currency_str) .map_err(|_| anyhow!("Invalid min_amount_currency '{}'", min_currency_str))?; - // Convert the configured minimum into the gross currency (resolving a - // cross-rate path if there is no direct pair). - let min_in_gross = self - .convert_currency( - CurrencyAmount::from_u64(min_currency, min_raw), - gross.currency(), - ) - .await?; - ensure!( - gross.value() >= min_in_gross.value(), - "Amount is below the minimum for this payment method ({} {})", - min_in_gross.value_f32(), - gross.currency() - ); - Ok(()) + Ok(self + .convert_currency(CurrencyAmount::from_u64(min_currency, min_raw), currency) + .await? + .value()) } /// Turn an amount **actually paid** into VM extension time. diff --git a/lnvps_db/migrations/20261015092358_account_credit.sql b/lnvps_db/migrations/20261015092358_account_credit.sql new file mode 100644 index 00000000..252010e9 --- /dev/null +++ b/lnvps_db/migrations/20261015092358_account_credit.sql @@ -0,0 +1,101 @@ +-- Account credit: a balance a customer tops up and spends against purchases, +-- renewals and upgrades, in the same flow as a NWC payment. See +-- `work/account-credit.md` and issue #145. +-- +-- The balance is denominated in BTC (msat) +-- regardless of the payment currency of any given invoice. Conversion to the +-- payment currency happens at quote time via the pricing engine, matching how +-- a discount's fixed amount is converted per-order. + +-- One row per user. `balance` always equals the sum of `account_credit_event.amount` +-- for the user; the row exists for O(1) reads rather than a ledger scan. +CREATE TABLE account_credit +( + user_id INTEGER UNSIGNED NOT NULL, + -- Minor units (msat for BTC) of `currency`. Never negative. + balance BIGINT UNSIGNED NOT NULL DEFAULT 0, + -- ISO currency code of the balance. Stored rather than hard-coded so a + -- future company on a different base currency does not require a schema + -- change. + currency VARCHAR(4) NOT NULL DEFAULT 'BTC', + -- Opt-in to auto-applying the balance against quotes. When true, every + -- quote for a purchase, renewal or upgrade subtracts min(balance, total) + -- before tax and processing fee are finalised. When false, the balance + -- sits there untouched until the customer tops up again. + auto_apply BOOLEAN NOT NULL DEFAULT FALSE, + -- Last time the balance or `auto_apply` changed. Informational only: the + -- ledger is the audit trail, not this column. + updated DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id), + CONSTRAINT fk_account_credit_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- Append-only ledger. `balance` is the sum of `amount` over a user's rows. +-- * `topup` -- customer bought credit, or an admin topped them up (positive). +-- * `spend` -- credit covered part or all of a `subscription_payment` (negative). +-- * `refund` -- credit returned to the customer (positive). +-- * `admin` -- manual adjustment, either sign, with a `note`. +CREATE TABLE account_credit_event +( + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INTEGER UNSIGNED NOT NULL, + kind VARCHAR(16) NOT NULL, + amount BIGINT NOT NULL, + currency VARCHAR(4) NOT NULL, + subscription_payment_id BINARY(32) NULL DEFAULT NULL, + note VARCHAR(255) NULL DEFAULT NULL, + created DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id), + KEY ix_account_credit_event_user (user_id, created), + CONSTRAINT fk_account_credit_event_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- Credit held against an order. A row is a reservation from the moment the +-- order is priced until `expires`: unsettled rows that have not expired are +-- subtracted from the balance when quoting, so the same credit cannot be +-- promised to two orders. `subscription_payment_id` is attached once the +-- payment row exists. Settling moves the balance and writes a `spend` event. +CREATE TABLE account_credit_debit +( + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INTEGER UNSIGNED NOT NULL, + subscription_payment_id BINARY(32) NULL DEFAULT NULL, + -- Minor units of `currency` (the balance currency) taken off the balance. + amount BIGINT UNSIGNED NOT NULL, + currency VARCHAR(4) NOT NULL, + -- The same value in the payment's currency, as shown to the customer. + payment_amount BIGINT UNSIGNED NOT NULL, + settled BOOLEAN NOT NULL DEFAULT FALSE, + expires DATETIME NOT NULL, + created DATETIME NOT NULL DEFAULT NOW(), + settled_at DATETIME NULL DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_account_credit_debit_payment (subscription_payment_id), + KEY ix_account_credit_debit_user (user_id, settled, expires), + CONSTRAINT fk_account_credit_debit_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- Top-up invoices. A top-up is not a `subscription_payment`: it does not +-- belong to a subscription, it does not extend anything, and it must never +-- appear in referral commission or the VM payment history. It lives in its +-- own table so the Lightning listener can tell the two apart. +CREATE TABLE account_credit_topup +( + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INTEGER UNSIGNED NOT NULL, + -- Minor units of `currency` the customer paid. + amount BIGINT UNSIGNED NOT NULL, + currency VARCHAR(4) NOT NULL, + -- BOLT11 payment hash of the top-up invoice. The Lightning listener's + -- `mark_paid` looks up here before `subscription_payment`, so a top-up + -- invoice can never be mistaken for a VM renewal. + invoice_hash BINARY(32) NOT NULL, + external_data TEXT NOT NULL, + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at DATETIME NULL DEFAULT NULL, + created DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id), + UNIQUE KEY uk_account_credit_topup_hash (invoice_hash), + KEY ix_account_credit_topup_user (user_id, is_paid), + CONSTRAINT fk_account_credit_topup_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); diff --git a/lnvps_db/migrations/20261015092359_account_credit_rbac_permissions.sql b/lnvps_db/migrations/20261015092359_account_credit_rbac_permissions.sql new file mode 100644 index 00000000..d5afa4fe --- /dev/null +++ b/lnvps_db/migrations/20261015092359_account_credit_rbac_permissions.sql @@ -0,0 +1,20 @@ +-- RBAC permissions for account credit administration. +-- +-- Adds AdminResource::AccountCredit = 34 to the default super_admin role, +-- following the per-feature grant convention. Without this the endpoints are +-- unreachable by anybody: there is no super-admin bypass in the permission +-- check, so a resource with no grants means the feature is simply unusable. +-- +-- Its own resource rather than part of `Payments` because adjusting a balance +-- is authority to create or destroy money a customer can spend against +-- invoices. +-- +-- AdminAction: Create = 0, View = 1, Update = 2, Delete = 3 +INSERT IGNORE INTO admin_role_permissions (role_id, resource, action, created_at) +SELECT id, 34, 0, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 34, 1, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 34, 2, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 34, 3, NOW() FROM admin_roles WHERE name = 'super_admin'; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 1962c01c..d80c1397 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -1413,6 +1413,85 @@ pub trait LNVpsDbBase: Send + Sync { /// cents would be meaningless. async fn sum_discount_redemptions(&self, discount_id: u64) -> DbResult>; + // ======================================================================== + // Account credit + // ======================================================================== + + /// The user's credit row, or a zero BTC row when they have none. + async fn get_account_credit(&self, user_id: u64) -> DbResult; + + async fn set_account_credit_auto_apply( + &self, + user_id: u64, + auto_apply: bool, + ) -> DbResult; + + /// Credit held by unsettled, unexpired debits. + async fn get_account_credit_reserved(&self, user_id: u64) -> DbResult; + + /// Shift the balance by a signed `delta` and write a ledger event in one + /// transaction. A negative delta may not take the balance below what is + /// reserved. + async fn adjust_account_credit( + &self, + user_id: u64, + delta: i64, + kind: &str, + note: Option<&str>, + ) -> DbResult; + + async fn list_account_credit_events( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + + /// Hold `debit.amount` of the user's balance until `debit.expires`. + /// Returns the debit id, or `None` when the unreserved balance is short. + async fn reserve_account_credit(&self, debit: &AccountCreditDebit) -> DbResult>; + + async fn attach_account_credit_debit( + &self, + id: u64, + subscription_payment_id: &Vec, + expires: DateTime, + ) -> DbResult<()>; + + /// Drop an unsettled reservation. + async fn release_account_credit_debit(&self, id: u64) -> DbResult<()>; + + async fn get_account_credit_debit_by_payment( + &self, + subscription_payment_id: &Vec, + ) -> DbResult>; + + /// Spend the debit on a payment. Idempotent: returns `None` when there is + /// no unsettled debit. An expired reservation may find its credit promised + /// elsewhere, so it is clamped to what is left unreserved, and the returned + /// row carries the amounts actually spent. + async fn settle_account_credit_debit( + &self, + subscription_payment_id: &Vec, + ) -> DbResult>; + + async fn insert_account_credit_topup(&self, topup: &AccountCreditTopup) -> DbResult; + + async fn get_account_credit_topup_by_hash( + &self, + invoice_hash: &Vec, + ) -> DbResult>; + + /// Mark a top-up paid and credit the balance. Idempotent. + async fn settle_account_credit_topup(&self, id: u64) -> DbResult>; + + async fn list_account_credit_topups( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + // ----- Marketplace (operator-run compute nodes) ----- /// Get a marketplace operator enrolment by id diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 5e08ad96..64dc5169 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1889,6 +1889,84 @@ pub struct DiscountRedemptionWithCode { pub discount_code: Option, } +/// A user's account credit balance. `balance` equals the sum of the user's +/// [`AccountCreditEvent`] amounts. +#[derive(FromRow, Clone, Debug, Default)] +pub struct AccountCredit { + pub user_id: u64, + /// Minor units of `currency` (msat for BTC). + pub balance: u64, + pub currency: String, + pub auto_apply: bool, + pub updated: DateTime, +} + +/// One row of the append-only account credit ledger. `amount` is signed: +/// `topup` and `refund` are positive, `spend` is negative, `admin` is either. +#[derive(FromRow, Clone, Debug, Default)] +pub struct AccountCreditEvent { + pub id: u64, + pub user_id: u64, + pub kind: String, + pub amount: i64, + pub currency: String, + pub subscription_payment_id: Option>, + pub note: Option, + pub created: DateTime, +} + +/// Credit held against an order. Unsettled rows that have not expired are a +/// reservation against the balance; settling moves the balance. +#[derive(FromRow, Clone, Debug, Default)] +pub struct AccountCreditDebit { + pub id: u64, + pub user_id: u64, + pub subscription_payment_id: Option>, + /// Minor units of `currency`, the balance currency. + pub amount: u64, + pub currency: String, + /// The same value in the payment's currency. + pub payment_amount: u64, + pub settled: bool, + pub expires: DateTime, + pub created: DateTime, + pub settled_at: Option>, +} + +impl AccountCreditDebit { + pub fn clamp_to(&mut self, available: u64) { + if self.amount <= available { + return; + } + self.payment_amount = if self.amount == 0 { + 0 + } else { + (self.payment_amount as u128 * available as u128 / self.amount as u128) as u64 + }; + self.amount = available; + } +} + +/// A top-up invoice. A top-up is not a `subscription_payment`: it does not +/// belong to a subscription, it does not extend anything, and it must never +/// appear in referral commission or the VM payment history. It lives in its +/// own table so the Lightning listener can tell the two apart. +#[derive(FromRow, Clone, Debug, Default)] +pub struct AccountCreditTopup { + pub id: u64, + pub user_id: u64, + /// Minor units of `currency` (msat for BTC) the customer paid. + pub amount: u64, + pub currency: String, + /// BOLT11 payment hash of the top-up invoice. + pub invoice_hash: Vec, + /// The BOLT11 invoice string, for display and re-issuance. + pub external_data: String, + pub is_paid: bool, + pub paid_at: Option>, + pub created: DateTime, +} + /// How an outbound payout reaches its recipient. Stored as a small integer; new /// methods are added as new variants (append-only to preserve values). /// @@ -2824,6 +2902,13 @@ pub enum AdminResource { /// key that has to stop working now -- so support needs it without also /// being able to reprice the service. VpnSubscription = 33, + /// A customer's account credit balance: reading the balance and ledger, and + /// adjusting it (a manual top-up or clawback). + /// + /// Its own resource rather than part of [`AdminResource::Payments`] because + /// adjusting a balance is authority to create or destroy money the customer + /// can spend against invoices. + AccountCredit = 34, } /// Actions that can be performed on administrative resources @@ -2877,6 +2962,7 @@ impl Display for AdminResource { AdminResource::MarketplaceNode => write!(f, "marketplace_node"), AdminResource::MarketplaceOperator => write!(f, "marketplace_operator"), AdminResource::SupportAgent => write!(f, "support_agent"), + AdminResource::AccountCredit => write!(f, "account_credit"), } } } @@ -2922,6 +3008,7 @@ impl FromStr for AdminResource { "discount" | "discounts" => Ok(AdminResource::Discount), "vpn_service" | "vpn_services" => Ok(AdminResource::VpnService), "vpn_subscription" | "vpn_subscriptions" => Ok(AdminResource::VpnSubscription), + "account_credit" | "account_credits" => Ok(AdminResource::AccountCredit), _ => Err(anyhow!("unknown admin resource: {}", s)), } } @@ -2966,6 +3053,7 @@ impl TryFrom for AdminResource { 31 => Ok(AdminResource::Discount), 32 => Ok(AdminResource::VpnService), 33 => Ok(AdminResource::VpnSubscription), + 34 => Ok(AdminResource::AccountCredit), _ => Err(anyhow!("unknown admin resource value: {}", value)), } } @@ -3009,6 +3097,7 @@ impl AdminResource { AdminResource::Discount, AdminResource::VpnService, AdminResource::VpnSubscription, + AdminResource::AccountCredit, ] } } diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 6e9558a7..637513f5 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,9 +1,10 @@ use crate::{ - AccessPolicy, AgentConversation, AgentConversationFilter, AgentConversationOverview, - AgentMessage, App, AppBackupState, AppCluster, AppDeployment, AppDeploymentBackup, - AppDeploymentFilter, AppDeploymentServiceUsage, AppDeploymentVolumeUsage, AppTag, - AsnSubscription, AsnSubscriptionStatus, AvailableIpSpace, BulkMessageTarget, Company, DbError, - DbResult, Discount, DiscountRedemption, DiscountRedemptionWithCode, DnsServer, EncryptedString, + AccessPolicy, AccountCredit, AccountCreditDebit, AccountCreditEvent, AccountCreditTopup, + AgentConversation, AgentConversationFilter, AgentConversationOverview, AgentMessage, App, + AppBackupState, AppCluster, AppDeployment, AppDeploymentBackup, AppDeploymentFilter, + AppDeploymentServiceUsage, AppDeploymentVolumeUsage, AppTag, AsnSubscription, + AsnSubscriptionStatus, AvailableIpSpace, BulkMessageTarget, Company, DbError, DbResult, + Discount, DiscountRedemption, DiscountRedemptionWithCode, DnsServer, EncryptedString, IntervalType, IpRange, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, MarketplaceNode, MarketplaceNodeHealth, MarketplaceNodeStatus, MarketplaceOperator, NewAgentMessage, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, ReferralCostUsage, ReferralPayout, @@ -79,6 +80,40 @@ pub struct LNVpsDbMysql { db: MySqlPool, } +async fn lock_account_credit( + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, + user_id: u64, +) -> Result<(u64, String)> { + sqlx::query( + "INSERT INTO account_credit (user_id, balance, currency) VALUES (?, 0, 'BTC') \ + ON DUPLICATE KEY UPDATE user_id = user_id", + ) + .bind(user_id) + .execute(&mut **tx) + .await?; + Ok( + sqlx::query_as("SELECT balance, currency FROM account_credit WHERE user_id = ? FOR UPDATE") + .bind(user_id) + .fetch_one(&mut **tx) + .await?, + ) +} + +async fn account_credit_reserved<'e, E: sqlx::MySqlExecutor<'e>>( + ex: E, + user_id: u64, + exclude: Option, +) -> Result { + Ok(sqlx::query_scalar( + "SELECT CAST(COALESCE(SUM(amount), 0) AS UNSIGNED) FROM account_credit_debit \ + WHERE user_id = ? AND settled = 0 AND expires > NOW() AND id <> ?", + ) + .bind(user_id) + .bind(exclude.unwrap_or(0)) + .fetch_one(ex) + .await?) +} + impl LNVpsDbMysql { pub async fn new(conn: &str) -> DbResult { let db = MySqlPool::connect(conn).await?; @@ -4751,6 +4786,345 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?) } + // ======================================================================== + // Account credit + // ======================================================================== + + async fn get_account_credit(&self, user_id: u64) -> DbResult { + let row: Option = sqlx::query_as( + "SELECT user_id, balance, currency, auto_apply, updated \ + FROM account_credit WHERE user_id = ?", + ) + .bind(user_id) + .fetch_optional(&self.db) + .await?; + Ok(row.unwrap_or_else(|| AccountCredit { + user_id, + balance: 0, + currency: "BTC".to_string(), + auto_apply: false, + updated: Utc::now(), + })) + } + + async fn set_account_credit_auto_apply( + &self, + user_id: u64, + auto_apply: bool, + ) -> DbResult { + sqlx::query( + "INSERT INTO account_credit (user_id, balance, currency, auto_apply) \ + VALUES (?, 0, 'BTC', ?) \ + ON DUPLICATE KEY UPDATE auto_apply = VALUES(auto_apply), updated = NOW()", + ) + .bind(user_id) + .bind(auto_apply) + .execute(&self.db) + .await?; + self.get_account_credit(user_id).await + } + + async fn get_account_credit_reserved(&self, user_id: u64) -> DbResult { + Ok(account_credit_reserved(&self.db, user_id, None).await?) + } + + async fn adjust_account_credit( + &self, + user_id: u64, + delta: i64, + kind: &str, + note: Option<&str>, + ) -> DbResult { + if delta == 0 { + return self.get_account_credit(user_id).await; + } + let mut tx = self.db.begin().await?; + let (balance, currency) = lock_account_credit(&mut tx, user_id).await?; + let new_balance = if delta > 0 { + balance + .checked_add(delta.unsigned_abs()) + .ok_or_else(|| anyhow!("account credit balance overflow for user {user_id}"))? + } else { + let reserved = account_credit_reserved(&mut *tx, user_id, None).await?; + let available = balance.saturating_sub(reserved); + if delta.unsigned_abs() > available { + tx.rollback().await?; + return Err(anyhow!( + "account credit for user {user_id} has {available} unreserved, cannot remove {}", + delta.unsigned_abs() + ) + .into()); + } + balance - delta.unsigned_abs() + }; + sqlx::query("UPDATE account_credit SET balance = ?, updated = NOW() WHERE user_id = ?") + .bind(new_balance) + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO account_credit_event (user_id, kind, amount, currency, note) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(user_id) + .bind(kind) + .bind(delta) + .bind(¤cy) + .bind(note) + .execute(&mut *tx) + .await?; + tx.commit().await?; + self.get_account_credit(user_id).await + } + + async fn list_account_credit_events( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let rows = sqlx::query_as( + "SELECT * FROM account_credit_event WHERE user_id = ? \ + ORDER BY created DESC, id DESC LIMIT ? OFFSET ?", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM account_credit_event WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.db) + .await?; + Ok((rows, total as u64)) + } + + async fn reserve_account_credit(&self, debit: &AccountCreditDebit) -> DbResult> { + let mut tx = self.db.begin().await?; + let (balance, _) = lock_account_credit(&mut tx, debit.user_id).await?; + let reserved = account_credit_reserved(&mut *tx, debit.user_id, None).await?; + if balance.saturating_sub(reserved) < debit.amount { + tx.rollback().await?; + return Ok(None); + } + let res = sqlx::query( + "INSERT INTO account_credit_debit \ + (user_id, amount, currency, payment_amount, settled, expires) \ + VALUES (?, ?, ?, ?, 0, ?) RETURNING id", + ) + .bind(debit.user_id) + .bind(debit.amount) + .bind(&debit.currency) + .bind(debit.payment_amount) + .bind(debit.expires) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(res.try_get(0)?)) + } + + async fn attach_account_credit_debit( + &self, + id: u64, + subscription_payment_id: &Vec, + expires: DateTime, + ) -> DbResult<()> { + sqlx::query( + "UPDATE account_credit_debit SET subscription_payment_id = ?, expires = ? \ + WHERE id = ? AND settled = 0", + ) + .bind(subscription_payment_id) + .bind(expires) + .bind(id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn release_account_credit_debit(&self, id: u64) -> DbResult<()> { + sqlx::query("DELETE FROM account_credit_debit WHERE id = ? AND settled = 0") + .bind(id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn get_account_credit_debit_by_payment( + &self, + subscription_payment_id: &Vec, + ) -> DbResult> { + Ok( + sqlx::query_as("SELECT * FROM account_credit_debit WHERE subscription_payment_id = ?") + .bind(subscription_payment_id) + .fetch_optional(&self.db) + .await?, + ) + } + + async fn settle_account_credit_debit( + &self, + subscription_payment_id: &Vec, + ) -> DbResult> { + let user_id: Option = sqlx::query_scalar( + "SELECT user_id FROM account_credit_debit \ + WHERE subscription_payment_id = ? AND settled = 0", + ) + .bind(subscription_payment_id) + .fetch_optional(&self.db) + .await?; + let Some(user_id) = user_id else { + return Ok(None); + }; + let mut tx = self.db.begin().await?; + let (balance, _) = lock_account_credit(&mut tx, user_id).await?; + let debit: Option = sqlx::query_as( + "SELECT * FROM account_credit_debit \ + WHERE subscription_payment_id = ? AND settled = 0 FOR UPDATE", + ) + .bind(subscription_payment_id) + .fetch_optional(&mut *tx) + .await?; + let Some(mut debit) = debit else { + tx.rollback().await?; + return Ok(None); + }; + let available = if debit.expires > Utc::now() { + balance + } else { + let others = account_credit_reserved(&mut *tx, debit.user_id, Some(debit.id)).await?; + balance.saturating_sub(others) + }; + debit.clamp_to(available); + + sqlx::query( + "UPDATE account_credit_debit \ + SET settled = 1, settled_at = NOW(), amount = ?, payment_amount = ? WHERE id = ?", + ) + .bind(debit.amount) + .bind(debit.payment_amount) + .bind(debit.id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE account_credit SET balance = ?, updated = NOW() WHERE user_id = ?") + .bind(balance - debit.amount) + .bind(debit.user_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO account_credit_event \ + (user_id, kind, amount, currency, subscription_payment_id) \ + VALUES (?, 'spend', ?, ?, ?)", + ) + .bind(debit.user_id) + .bind(-i64::try_from(debit.amount).map_err(|e| anyhow!(e))?) + .bind(&debit.currency) + .bind(&debit.subscription_payment_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + debit.settled = true; + debit.settled_at = Some(Utc::now()); + Ok(Some(debit)) + } + + async fn insert_account_credit_topup(&self, topup: &AccountCreditTopup) -> DbResult { + let res = sqlx::query( + "INSERT INTO account_credit_topup \ + (user_id, amount, currency, invoice_hash, external_data, is_paid) \ + VALUES (?, ?, ?, ?, ?, 0) RETURNING id", + ) + .bind(topup.user_id) + .bind(topup.amount) + .bind(&topup.currency) + .bind(&topup.invoice_hash) + .bind(&topup.external_data) + .fetch_one(&self.db) + .await?; + Ok(res.try_get(0)?) + } + + async fn get_account_credit_topup_by_hash( + &self, + invoice_hash: &Vec, + ) -> DbResult> { + Ok( + sqlx::query_as("SELECT * FROM account_credit_topup WHERE invoice_hash = ?") + .bind(invoice_hash) + .fetch_optional(&self.db) + .await?, + ) + } + + async fn settle_account_credit_topup(&self, id: u64) -> DbResult> { + let user_id: Option = sqlx::query_scalar( + "SELECT user_id FROM account_credit_topup WHERE id = ? AND is_paid = 0", + ) + .bind(id) + .fetch_optional(&self.db) + .await?; + let Some(user_id) = user_id else { + return Ok(None); + }; + let mut tx = self.db.begin().await?; + let (balance, _) = lock_account_credit(&mut tx, user_id).await?; + let paid = sqlx::query( + "UPDATE account_credit_topup SET is_paid = 1, paid_at = NOW() \ + WHERE id = ? AND is_paid = 0", + ) + .bind(id) + .execute(&mut *tx) + .await?; + if paid.rows_affected() == 0 { + tx.rollback().await?; + return Ok(None); + } + let topup: AccountCreditTopup = + sqlx::query_as("SELECT * FROM account_credit_topup WHERE id = ?") + .bind(id) + .fetch_one(&mut *tx) + .await?; + sqlx::query("UPDATE account_credit SET balance = ?, updated = NOW() WHERE user_id = ?") + .bind(balance + topup.amount) + .bind(topup.user_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO account_credit_event (user_id, kind, amount, currency) \ + VALUES (?, 'topup', ?, ?)", + ) + .bind(topup.user_id) + .bind(i64::try_from(topup.amount).map_err(|e| anyhow!(e))?) + .bind(&topup.currency) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(topup)) + } + + async fn list_account_credit_topups( + &self, + user_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let rows = sqlx::query_as( + "SELECT * FROM account_credit_topup WHERE user_id = ? \ + ORDER BY created DESC, id DESC LIMIT ? OFFSET ?", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM account_credit_topup WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.db) + .await?; + Ok((rows, total as u64)) + } + // ----- Marketplace (operator-run compute nodes) ----- async fn get_marketplace_operator(&self, id: u64) -> DbResult { diff --git a/lnvps_e2e/src/credit.rs b/lnvps_e2e/src/credit.rs new file mode 100644 index 00000000..f75e1875 --- /dev/null +++ b/lnvps_e2e/src/credit.rs @@ -0,0 +1,410 @@ +//! E2E tests for the account credit system (issue #145). +//! +//! Covers the customer surface (balance, opt-in toggle, top-up invoice shape, +//! ledger) and the admin surface (read, adjust, clawback guard). Credit-funded +//! order flows are exercised by the unit tests in +//! `lnvps_api::subscription::credit_tests`, which run against the mock node +//! without standing up the API. + +#[cfg(test)] +mod tests { + use crate::client::*; + use crate::db; + use chrono::{Duration, Utc}; + use lnvps_db::{AccountCreditDebit, AccountCreditTopup, LNVpsDbBase, LNVpsDbMysql}; + use nostr::Keys; + use reqwest::StatusCode; + use serde_json::{Value, json}; + + async fn setup() -> TestClient { + bootstrap_admin().await.unwrap(); + admin_client() + } + + async fn json_ok(resp: reqwest::Response) -> Value { + let status = resp.status(); + let body = resp.text().await.unwrap(); + assert_eq!(status, StatusCode::OK, "Expected 200, body: {body}"); + serde_json::from_str(&body).unwrap() + } + + /// A fresh customer identity with its user row ensured, and a client + /// authenticated as it. + async fn customer() -> (u64, TestClient) { + let keys = Keys::generate(); + let pool = db::connect().await.unwrap(); + let uid = db::ensure_user(&pool, &keys).await.unwrap(); + pool.close().await; + (uid, user_client_with_keys(keys)) + } + + /// A customer's credit row reads back zero with no opt-in before anything + /// has happened. + #[tokio::test] + async fn credit_reads_zero_before_any_topup() { + let _admin = setup().await; + let (_uid, customer) = customer().await; + + let body = json_ok(customer.get_auth("/api/v1/account/credit").await.unwrap()).await; + assert_eq!(body["data"]["balance"], 0); + assert_eq!(body["data"]["currency"], "BTC"); + assert_eq!(body["data"]["auto_apply"], false); + } + + /// The opt-in toggle round-trips. + #[tokio::test] + async fn auto_apply_toggles() { + let _admin = setup().await; + let (_uid, customer) = customer().await; + + let body = json_ok( + customer + .patch_auth("/api/v1/account/credit", &json!({"auto_apply": true})) + .await + .unwrap(), + ) + .await; + assert_eq!(body["data"]["auto_apply"], true); + + let body = json_ok( + customer + .patch_auth("/api/v1/account/credit", &json!({"auto_apply": false})) + .await + .unwrap(), + ) + .await; + assert_eq!(body["data"]["auto_apply"], false); + } + + /// Toggling the opt-in leaves an existing balance and its currency alone. + #[tokio::test] + async fn auto_apply_toggle_keeps_the_balance() { + let admin = setup().await; + let (uid, customer) = customer().await; + + json_ok( + admin + .patch_auth( + &format!("/api/admin/v1/users/{uid}/credit"), + &json!({"delta": 3_000_000, "kind": "topup"}), + ) + .await + .unwrap(), + ) + .await; + + let body = json_ok( + customer + .patch_auth("/api/v1/account/credit", &json!({"auto_apply": true})) + .await + .unwrap(), + ) + .await; + assert_eq!(body["data"]["balance"], 3_000_000); + assert_eq!(body["data"]["currency"], "BTC"); + assert_eq!(body["data"]["auto_apply"], true); + } + + /// A sub-minimum top-up is rejected with a 400. + #[tokio::test] + async fn sub_minimum_topup_is_rejected() { + let _admin = setup().await; + let (_uid, customer) = customer().await; + + let resp = customer + .post_auth("/api/v1/account/credit/topup", &json!({"amount": 100})) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + /// A top-up issues a Lightning invoice the customer can pay: a BTC BOLT11 + /// with the right amount and a 32-byte hex payment hash. + #[tokio::test] + async fn topup_issues_a_lightning_invoice() { + let _admin = setup().await; + let (_uid, customer) = customer().await; + + let body = json_ok( + customer + .post_auth( + "/api/v1/account/credit/topup", + &json!({"amount": 10_000_000}), + ) + .await + .unwrap(), + ) + .await; + let pr = body["data"]["pr"].as_str().expect("bolt11 string"); + assert!(pr.starts_with("lnbc"), "expected a BTC invoice, got {pr}"); + assert_eq!(body["data"]["amount"], 10_000_000); + assert_eq!(body["data"]["currency"], "BTC"); + assert!( + body["data"]["payment_hash"] + .as_str() + .is_some_and(|h| h.len() == 64), + "payment hash is hex of 32 bytes" + ); + } + + /// The admin can read a user's credit and ledger, and adjusting the balance + /// moves it with a ledger row to show for it. A clawback past the balance is + /// refused and leaves it untouched. + #[tokio::test] + async fn admin_can_read_and_adjust_credit() { + let admin = setup().await; + let (uid, customer) = customer().await; + + let body = json_ok( + admin + .get_auth(&format!("/api/admin/v1/users/{uid}/credit")) + .await + .unwrap(), + ) + .await; + assert_eq!(body["data"]["balance"], 0); + + // Grant the user 5.00 EUR equivalent in sats. + let body = json_ok( + admin + .patch_auth( + &format!("/api/admin/v1/users/{uid}/credit"), + &json!({"delta": 5_000_000, "kind": "topup", "note": "e2e grant"}), + ) + .await + .unwrap(), + ) + .await; + assert_eq!(body["data"]["balance"], 5_000_000); + + // The ledger shows the grant. + let body = json_ok( + admin + .get_auth(&format!("/api/admin/v1/users/{uid}/credit/events")) + .await + .unwrap(), + ) + .await; + let events = body["data"].as_array().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "topup"); + assert_eq!(events[0]["amount"], 5_000_000); + assert_eq!(events[0]["note"], "e2e grant"); + + let resp = admin + .patch_auth( + &format!("/api/admin/v1/users/{uid}/credit"), + &json!({"delta": -1_000_000, "kind": "topup"}), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // A clawback larger than the balance is refused. + let resp = admin + .patch_auth( + &format!("/api/admin/v1/users/{uid}/credit"), + &json!({"delta": -5_000_001, "kind": "admin"}), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + json_ok( + admin + .patch_auth( + &format!("/api/admin/v1/users/{uid}/credit"), + &json!({"delta": -1_000_000, "kind": "admin", "note": "clawback"}), + ) + .await + .unwrap(), + ) + .await; + + let body = json_ok(customer.get_auth("/api/v1/account/credit").await.unwrap()).await; + assert_eq!(body["data"]["balance"], 4_000_000); + let body = json_ok( + customer + .get_auth("/api/v1/account/credit/events") + .await + .unwrap(), + ) + .await; + let amounts: Vec = body["data"] + .as_array() + .unwrap() + .iter() + .map(|e| e["amount"].as_i64().unwrap()) + .collect(); + assert_eq!(amounts, vec![-1_000_000, 5_000_000]); + } + + fn hold(user_id: u64, amount: u64, expires_in: i64) -> AccountCreditDebit { + AccountCreditDebit { + user_id, + amount, + currency: "BTC".to_string(), + payment_amount: amount / 10_000, + expires: Utc::now() + Duration::seconds(expires_in), + ..Default::default() + } + } + + async fn ledger_sum(pool: &sqlx::MySqlPool, user_id: u64) -> i64 { + sqlx::query_scalar( + "SELECT CAST(COALESCE(SUM(amount), 0) AS SIGNED) FROM account_credit_event WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(pool) + .await + .unwrap() + } + + async fn hold_on_payment( + db: &LNVpsDbMysql, + user_id: u64, + amount: u64, + expires_in: i64, + ) -> Vec { + let id = db + .reserve_account_credit(&hold(user_id, amount, 3600)) + .await + .unwrap() + .expect("credit reserved"); + let payment: Vec = Keys::generate().public_key().to_bytes().to_vec(); + db.attach_account_credit_debit(id, &payment, Utc::now() + Duration::seconds(expires_in)) + .await + .unwrap(); + payment + } + + #[tokio::test] + async fn mysql_credit_is_held_settled_once_and_balances_with_the_ledger() { + let (uid, _customer) = customer().await; + let pool = db::connect().await.unwrap(); + let db = db::lnvps_db().await.unwrap(); + db.adjust_account_credit(uid, 10_000_000, "topup", None) + .await + .unwrap(); + + let payment = hold_on_payment(&db, uid, 6_000_000, 600).await; + assert_eq!( + db.get_account_credit_reserved(uid).await.unwrap(), + 6_000_000 + ); + assert!( + db.reserve_account_credit(&hold(uid, 5_000_000, 600)) + .await + .unwrap() + .is_none(), + "held credit is not promised twice" + ); + assert!( + db.adjust_account_credit(uid, -5_000_000, "admin", None) + .await + .is_err(), + "a clawback cannot take held credit" + ); + + let spent = db + .settle_account_credit_debit(&payment) + .await + .unwrap() + .expect("settled"); + assert_eq!(spent.amount, 6_000_000); + assert!( + db.settle_account_credit_debit(&payment) + .await + .unwrap() + .is_none() + ); + assert_eq!(db.get_account_credit(uid).await.unwrap().balance, 4_000_000); + assert_eq!(db.get_account_credit_reserved(uid).await.unwrap(), 0); + assert_eq!(ledger_sum(&pool, uid).await, 4_000_000); + + let released = db + .reserve_account_credit(&hold(uid, 4_000_000, 600)) + .await + .unwrap() + .unwrap(); + db.release_account_credit_debit(released).await.unwrap(); + assert_eq!(db.get_account_credit_reserved(uid).await.unwrap(), 0); + pool.close().await; + } + + #[tokio::test] + async fn mysql_a_late_settle_spends_only_unpromised_credit() { + let (uid, _customer) = customer().await; + let pool = db::connect().await.unwrap(); + let db = db::lnvps_db().await.unwrap(); + db.adjust_account_credit(uid, 10_000_000, "topup", None) + .await + .unwrap(); + + let late = hold_on_payment(&db, uid, 8_000_000, -1).await; + hold_on_payment(&db, uid, 7_000_000, 600).await; + let spent = db + .settle_account_credit_debit(&late) + .await + .unwrap() + .unwrap(); + assert_eq!(spent.amount, 3_000_000); + assert_eq!(spent.payment_amount, 300); + assert_eq!(db.get_account_credit(uid).await.unwrap().balance, 7_000_000); + assert_eq!(ledger_sum(&pool, uid).await, 7_000_000); + pool.close().await; + } + + #[tokio::test] + async fn mysql_concurrent_settles_lose_no_update() { + let (uid, _customer) = customer().await; + let pool = db::connect().await.unwrap(); + let db = db::lnvps_db().await.unwrap(); + db.adjust_account_credit(uid, 50_000_000, "topup", None) + .await + .unwrap(); + + let mut payments = Vec::new(); + for _ in 0..10 { + payments.push(hold_on_payment(&db, uid, 1_000_000, 600).await); + } + let mut topups = Vec::new(); + for _ in 0..10 { + let hash: Vec = Keys::generate().public_key().to_bytes().to_vec(); + topups.push( + db.insert_account_credit_topup(&AccountCreditTopup { + user_id: uid, + amount: 500_000, + currency: "BTC".to_string(), + invoice_hash: hash, + external_data: "lnbc".to_string(), + ..Default::default() + }) + .await + .unwrap(), + ); + } + + let mut tasks = Vec::new(); + for p in payments { + let db = db.clone(); + tasks.push(tokio::spawn(async move { + db.settle_account_credit_debit(&p).await.unwrap(); + })); + } + for t in topups { + let db = db.clone(); + tasks.push(tokio::spawn(async move { + db.settle_account_credit_topup(t).await.unwrap(); + })); + } + for t in tasks { + t.await.unwrap(); + } + + let balance = db.get_account_credit(uid).await.unwrap().balance; + assert_eq!(balance, 50_000_000 - 10_000_000 + 5_000_000); + assert_eq!(ledger_sum(&pool, uid).await, balance as i64); + pool.close().await; + } +} diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index ccf68539..d1408614 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -102,6 +102,12 @@ pub async fn connect() -> anyhow::Result { Ok(pool) } +/// The production database layer on the per-run test database. +pub async fn lnvps_db() -> anyhow::Result { + ensure_test_database().await?; + Ok(lnvps_db::LNVpsDbMysql::new(&db_url()).await?) +} + /// Ensure a user exists for the given Nostr keys and return the user_id. /// Uses the same INSERT IGNORE + SELECT pattern as the production `upsert_user`. pub async fn ensure_user(pool: &MySqlPool, keys: &Keys) -> anyhow::Result { diff --git a/lnvps_e2e/src/lib.rs b/lnvps_e2e/src/lib.rs index be865024..2e531e72 100644 --- a/lnvps_e2e/src/lib.rs +++ b/lnvps_e2e/src/lib.rs @@ -16,6 +16,7 @@ mod admin_api; mod agent_chat; mod app_backups; pub mod client; +mod credit; pub mod db; mod discounts; mod lifecycle; diff --git a/work/account-credit.md b/work/account-credit.md new file mode 100644 index 00000000..f07f8629 --- /dev/null +++ b/work/account-credit.md @@ -0,0 +1,45 @@ +# Account Credit + +**Status:** complete (code, tests, docs); PR not yet opened +**Started:** 2026-10-15 + +Tracks [#145](https://github.com/LNVPS/api/issues/145). + +## Goal + +A customer tops up a credit balance and spends it against purchases, renewals +and upgrades, in full or as part of a larger bill. Every movement is a signed +row in `account_credit_event`, and the balance always equals their sum. + +## Decisions (agreed with @v0l) + +| Question | Decision | +|---|---| +| Top-up | `POST /api/v1/account/credit/topup {amount}` (msat) issues a Lightning invoice; settling it credits the balance. | +| Spend | Opt-in per user (`auto_apply`). Every renewal and upgrade quote applies `min(unreserved balance, net + tax)`. | +| Holding | `account_credit_debit` rows hold credit from the moment an order is priced until it expires. Unsettled, unexpired rows are subtracted from the balance when quoting, reserving and clawing back, so credit is never promised twice. | +| Settlement | `apply_payment` settles the debit. A debit whose hold expired is clamped to what live holds leave over, so a late payment can never spend credit promised to another order. | +| Fully credit-funded | No external invoice: `external_data = "credit"`, the debit is settled strictly before `complete_payment`. The API shows `data: "credit"`. | +| Processing fee | Scaled to the share still collected, like a discount. A remainder below the method minimum uses less credit so the remainder meets it. | +| On-chain | The watcher claims the credit at deposit time and prices `received + credit`. | +| Auto-renewal | Credit alone when it covers the whole renewal, otherwise the saved method pays the remainder. | +| Refunds | Automated VM refunds pay cash only up to the cash share of each payment and return the credit share to the balance. The reversal including credit must fit what the VM is owed. | + +## Increments + +- [x] Schema, DB layer, MockDb +- [x] Top-up API and Lightning listener +- [x] Quote-time credit with holds +- [x] Settlement (Lightning, Revolut, on-chain, fully funded) +- [x] Auto-renewal +- [x] Refunds return credit +- [x] Admin API and RBAC +- [x] E2E: API surface plus MariaDB tests for holds, late settles and concurrent settles (`./scripts/run-e2e.sh --filter credit`, with `LNVPS_ADMIN_API_URL=http://localhost:18001` when 8001 is taken) +- [ ] Label issue #145 with `enhancement`, `api`, `database`, `payments` when the PR opens + +## Notes + +- MariaDB snapshot isolation: every transaction that moves the balance takes the + `account_credit` row lock (`lock_account_credit`) before any consistent read, + otherwise concurrent settles fail with error 1020. +- `PayoutMode::AccountCredit` (referral payouts) can land into this ledger later. From 20025ba6b0e161e47c0741a11772eb1737d430b4 Mon Sep 17 00:00:00 2001 From: v0l Date: Tue, 20 Oct 2026 14:12:20 +0100 Subject: [PATCH 2/2] fix: credit renewal reminders and DVM success --- lnvps_api/src/dvm/lnvps.rs | 121 +++++++++++++++++++++++++----- lnvps_api/src/subscription/mod.rs | 66 ++++++++++------ lnvps_api/src/worker.rs | 35 ++++++++- 3 files changed, 180 insertions(+), 42 deletions(-) diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index b81e5b29..e26b1993 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -4,10 +4,11 @@ use crate::subscription::SubscriptionHandler; use anyhow::Context; use lnvps_api_common::VmStateCache; use lnvps_db::{ - DiskInterface, DiskType, OsDistribution, PaymentMethod, UserSshKey, VmCustomTemplate, + DiskInterface, DiskType, OsDistribution, PaymentMethod, SubscriptionPayment, UserSshKey, + VmCustomTemplate, }; use nostr_sdk::prelude::DataVendingMachineStatus; -use nostr_sdk::{Client, Tag}; +use nostr_sdk::{Client, EventBuilder, Tag}; use ssh_key::PublicKey; use std::future::Future; use std::pin::Pin; @@ -141,28 +142,40 @@ impl DVMHandler for LnvpsDvm { let invoice = sub_handler .renew_subscription(line_item.subscription_id, PaymentMethod::Lightning, 1) .await?; - if invoice.is_paid { - return Ok(()); - } - - let mut payment = build_status_for_job( - &request, - DataVendingMachineStatus::PaymentRequired, - None, - None, - ); - payment = payment.tag(Tag::parse([ - "amount", - invoice.amount.to_string().as_str(), - invoice.external_data.as_str(), - ])?); - client.send_event_builder(payment).await?; + client + .send_event_builder(payment_feedback(&request, &invoice)?) + .await?; Ok(()) }) } } +fn payment_feedback( + request: &DVMJobRequest, + invoice: &SubscriptionPayment, +) -> anyhow::Result { + if invoice.is_paid { + return Ok(build_status_for_job( + request, + DataVendingMachineStatus::Success, + None, + None, + )); + } + Ok(build_status_for_job( + request, + DataVendingMachineStatus::PaymentRequired, + None, + None, + ) + .tag(Tag::parse([ + "amount", + invoice.amount.to_string().as_str(), + invoice.external_data.as_str(), + ])?)) +} + #[cfg(test)] mod tests { use super::*; @@ -176,6 +189,78 @@ mod tests { use nostr_sdk::{EventBuilder, Keys, Kind}; use std::sync::Arc; + async fn job_request(keys: &Keys) -> DVMJobRequest { + let ev = EventBuilder::new(Kind::from_u16(5999), "") + .sign(keys) + .await + .unwrap(); + parse_job_request(&ev).unwrap() + } + + fn renewal(is_paid: bool, external_data: &str) -> SubscriptionPayment { + SubscriptionPayment { + id: vec![1u8; 32], + subscription_id: 1, + user_id: 1, + created: chrono::Utc::now(), + expires: chrono::Utc::now(), + amount: 21_000, + currency: "BTC".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: lnvps_db::SubscriptionPaymentType::Purchase, + external_data: external_data.to_string().into(), + external_id: None, + is_paid, + rate: 1.0, + time_value: None, + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + tax_rate: None, + tax_country_code: None, + tax_treatment: None, + tax_evidence: None, + tax_breakdown: None, + refunded_payment_id: None, + renewal_source: None, + } + } + + fn tag_values(ev: &nostr_sdk::Event, name: &str) -> Vec { + ev.tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some(name)) + .map(|t| t.as_slice()[1..].to_vec()) + .unwrap_or_default() + } + + #[tokio::test] + async fn a_credit_funded_job_reports_success() { + let keys = Keys::generate(); + let req = job_request(&keys).await; + let ev = payment_feedback(&req, &renewal(true, "credit")) + .unwrap() + .sign(&keys) + .await + .unwrap(); + assert_eq!(tag_values(&ev, "status")[0], "success"); + assert!(tag_values(&ev, "amount").is_empty()); + } + + #[tokio::test] + async fn an_unpaid_job_asks_for_payment() { + let keys = Keys::generate(); + let req = job_request(&keys).await; + let ev = payment_feedback(&req, &renewal(false, "lnbc1")) + .unwrap() + .sign(&keys) + .await + .unwrap(); + assert_eq!(tag_values(&ev, "status")[0], "payment-required"); + assert_eq!(tag_values(&ev, "amount"), vec!["21000", "lnbc1"]); + } + #[tokio::test] #[ignore] async fn test_dvm() -> anyhow::Result<()> { diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index e527cac9..6759cf6a 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -2089,34 +2089,41 @@ impl SubscriptionHandler { .ok_or_else(|| anyhow::anyhow!("No NWC payment method configured")) } + pub async fn credit_covers_renewal(&self, sub_id: u64) -> Result { + let sub = self.db.get_subscription(sub_id).await?; + let credit = self.db.get_account_credit(sub.user_id).await?; + if !credit.auto_apply || credit.balance == 0 { + return Ok(false); + } + let quote = self + .quote_renewal(sub_id, PaymentMethod::Lightning, 1, None) + .await?; + Ok(credit_covers( + quote.credit.as_ref(), + quote.amount, + quote.tax, + )) + } + /// Attempt automatic renewal using the user's default saved payment method, /// dispatching by provider (NWC Lightning wallet or Revolut card). pub async fn auto_renew(&self, sub_id: u64) -> Result { let sub = self.db.get_subscription(sub_id).await?; let credit = self.db.get_account_credit(sub.user_id).await?; - if credit.auto_apply && credit.balance > 0 { - let quote = self - .quote_renewal(sub_id, PaymentMethod::Lightning, 1, None) + if self.credit_covers_renewal(sub_id).await? { + let payment = self + .renew_subscription_inner( + sub_id, + PaymentMethod::Lightning, + 1, + RenewMode::default(), + None, + RenewalSource::Auto, + ) .await?; - let covered = quote - .credit - .as_ref() - .is_some_and(|c| c.amount >= quote.amount + quote.tax); - if covered { - let payment = self - .renew_subscription_inner( - sub_id, - PaymentMethod::Lightning, - 1, - RenewMode::default(), - None, - RenewalSource::Auto, - ) - .await?; - info!("Subscription {sub_id} renewed on account credit"); - return Ok(payment); - } + info!("Subscription {sub_id} renewed on account credit"); + return Ok(payment); } let method = match self.default_payment_method(sub.user_id).await { @@ -4510,4 +4517,21 @@ mod credit_tests { "unexpected error: {err}" ); } + + #[tokio::test] + async fn credit_covers_renewal_only_when_it_pays_the_whole_bill() { + let (db, sub, user_id, sub_id) = setup().await; + assert!(!sub.credit_covers_renewal(sub_id).await.unwrap()); + + give_credit(&db, user_id, 4_000_000, true).await; + assert!(!sub.credit_covers_renewal(sub_id).await.unwrap()); + + give_credit(&db, user_id, 6_000_000, false).await; + assert!(!sub.credit_covers_renewal(sub_id).await.unwrap()); + + db.set_account_credit_auto_apply(user_id, true) + .await + .unwrap(); + assert!(sub.credit_covers_renewal(sub_id).await.unwrap()); + } } diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index a2a97806..23a66dd2 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -486,10 +486,9 @@ impl Worker { // A credit balance the user opted into is also a renewable // method, so it counts even when no saved method exists. let has_credit = self - .db - .get_account_credit(sub.user_id) + .subscription_handler + .credit_covers_renewal(sub.id) .await - .map(|c| c.auto_apply && c.balance > 0) .unwrap_or(false); if (has_method || has_credit) && !is_final_reminder { // It will renew itself before it expires; an "expiring soon" @@ -7082,6 +7081,36 @@ mod tests { Ok(()) } + /// A credit balance too small to renew is not a renewal method: the user + /// still gets the early "expires in" reminders. + #[tokio::test] + async fn test_leftover_credit_does_not_suppress_expiry_reminders() -> Result<()> { + let db = Arc::new(MockDb::default()); + let (_vm_id, subscription_id) = add_vm_with_subscription(&db, Utc::now(), true).await?; + let sub = { + let mut subs = db.subscriptions.lock().await; + let s = subs.get_mut(&subscription_id).unwrap(); + s.auto_renewal_enabled = true; + s.expires = Some( + Utc::now() + .add(TimeDelta::days(7)) + .sub(TimeDelta::minutes(1)), + ); + s.clone() + }; + db.adjust_account_credit(sub.user_id, 1_000, "topup", None) + .await?; + db.set_account_credit_auto_apply(sub.user_id, true).await?; + + let worker = setup_worker(db.clone()).await?; + worker + .handle_subscription_state(&sub, Utc::now().sub(TimeDelta::minutes(5))) + .await?; + + assert_eq!(count_notifications(&worker, "Expires in 7 days").await, 1); + Ok(()) + } + /// A 1-day-interval subscription genuinely near expiry (within the capped /// 12h lead window) still fires the expiring-soon handling. #[tokio::test]