From c0aff5f7cfacba13560309de9b7a58382078585f Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 12 May 2026 16:32:05 -0700 Subject: [PATCH] Refactor billing module --- backend/spec/billing.md | 80 ++-- backend/spec/bitcoin.md | 62 +++ backend/spec/stripe.md | 113 +++++ backend/src/api.rs | 3 +- backend/src/billing.rs | 769 ++++---------------------------- backend/src/bitcoin.rs | 220 +++++++++ backend/src/lib.rs | 2 + backend/src/main.rs | 2 + backend/src/stripe.rs | 482 ++++++++++++++++++++ backend/tests/btc_quote_stub.rs | 31 -- 10 files changed, 1012 insertions(+), 752 deletions(-) create mode 100644 backend/spec/bitcoin.md create mode 100644 backend/spec/stripe.md create mode 100644 backend/src/bitcoin.rs create mode 100644 backend/src/stripe.rs delete mode 100644 backend/tests/btc_quote_stub.rs diff --git a/backend/spec/billing.md b/backend/spec/billing.md index 6db97c4..e272a5e 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -2,29 +2,26 @@ Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle. +It owns the domain logic only: every raw Stripe REST call goes through the `Stripe` wrapper (see `spec/stripe.md`), and every Lightning (NWC) wallet operation and the fiat-minor-units → millisatoshi conversion go through the helpers in `spec/bitcoin.md`. + Members: -- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL` -- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY` -- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET` +- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` +- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`) - `query: Query` - `command: Command` - `robot: Robot` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` -- Reads environment and populates members -- Panics if `STRIPE_SECRET_KEY` is missing/empty -- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty +- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()` +- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional) ## `pub fn start(&self)` - Subscribes to `command.notify.subscribe()` -- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`. - -## `pub fn sync_relay_subscription(&self, activity: &Activity)` - -Resolves the relay associated with `activity` and reconciles that relay's tenant via `sync_tenant_subscription`. The startup/lagged reconcile loop calls `sync_tenant_subscription` for every tenant. +- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`: resolve the relay named by the activity (skip if it no longer exists) and reconcile its tenant via `sync_tenant_subscription`. +- The startup/lagged reconcile loop calls `sync_tenant_subscription` for every tenant. ## `fn sync_tenant_subscription(&self, tenant_pubkey: &str)` @@ -44,8 +41,8 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created, ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` -- Verify the webhook signature using `self.stripe_webhook_secret` -- Parse the event and dispatch by type: +- Verify and parse the event via `self.stripe.construct_event(payload, signature)` (checks the `Stripe-Signature` HMAC and timestamp tolerance — see `spec/stripe.md`) +- Dispatch by type: - `invoice.created` -> `self.handle_invoice_created` - `invoice.paid` -> `self.handle_invoice_paid` - `invoice.payment_failed` -> `self.handle_invoice_payment_failed` @@ -55,56 +52,69 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created, - `payment_method.attached` -> `self.handle_payment_method_attached` - Unknown event types are ignored (return Ok) +## `pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result` + +- Resolves a display name via `robot.fetch_nostr_name(tenant_pubkey)`, falling back to the first 8 chars of the pubkey +- Creates the Stripe customer via `stripe.create_customer(display_name, tenant_pubkey)` and returns its id + ## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result` -- Fetches invoices from Stripe API for the given customer -- Returns the `data` array from the Stripe response +- Delegates to `stripe.list_invoices` — returns the `data` array of the customer's invoices -## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result` +## `pub async fn stripe_create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result` -- Fetches a single invoice from Stripe API by ID -- Returns the full Stripe invoice object +- Delegates to `stripe.create_portal_session` — returns the Customer Portal session URL -## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result` +## `pub async fn get_invoice_with_tenant(&self, invoice_id: &str) -> Result<(Value, Tenant), InvoiceLookupError>` -- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`) +- Fetches the invoice via `stripe.get_invoice` (a Stripe 4xx surfaces as `InvoiceLookupError::StripeClient`) +- Looks up the tenant by the invoice's `customer` field; errors if the invoice has no customer or no tenant matches + +## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result` + +If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`bitcoin.system_wallet().invoice_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged. + +## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result` + +- Returns the existing bolt11 if one is already recorded for the invoice +- Otherwise creates one via `create_bolt11`, records it with `command.insert_manual_lightning_invoice_payment`, and returns it (re-reading the stored row if the insert lost a race) + +## `pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result` + +- Converts the fiat amount to msats via `bitcoin.fiat_minor_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`) +- Issues a bolt11 invoice for that amount on the system NWC wallet (`bitcoin.system_wallet().make_invoice(...)`) - Returns the bolt11 invoice string -## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result` - -- Creates a Stripe Customer Portal session for the given customer -- Returns the portal session URL - ## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>` Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid. - If `tenant.nwc_url` is empty, return early. -- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`. +- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`. - For each invoice with `status == "open"` and `amount_due > 0`: - Attempt NWC payment via `nwc_pay_invoice`. - - On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`. + - On success: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and call `command.clear_tenant_nwc_error`. - On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice. ## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>` Attempts Stripe-side collection for open invoices when the tenant has a card on file. -- If tenant has no card payment method, return early. -- List all Stripe invoices for `tenant.stripe_customer_id`. +- If tenant has no card payment method (`stripe.has_payment_method`), return early. +- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`. - For each invoice with `status == "open"` and `amount_due > 0`: - - Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file. + - Call `stripe.pay_invoice` to retry collection using the card on file. - Log and continue on failures. ## `fn handle_invoice_created(&self, invoice: &Invoice)` Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority: -1. **NWC auto-pay**: If the tenant has a `nwc_url`: - - Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet) - - Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet) - - If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`. - - If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option. +1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first): + - The system wallet (`bitcoin.system_wallet()`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::parse` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries. + - If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done. + - If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM). + - If it fails after a charge may have gone out (needs reconciliation): set `nwc_error` and return the error without falling through — a human must reconcile before any retry. 2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt. 3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment. diff --git a/backend/spec/bitcoin.md b/backend/spec/bitcoin.md new file mode 100644 index 0000000..70f9cb3 --- /dev/null +++ b/backend/spec/bitcoin.md @@ -0,0 +1,62 @@ +# `bitcoin` — Bitcoin / Lightning helpers + +Small wrappers around the Bitcoin-facing services the app talks to: Nostr Wallet Connect (NWC) wallets for Lightning invoices/payments, and a fiat↔BTC spot price feed, plus the fiat-minor-units → millisatoshi conversion that ties them together. The billing-specific orchestration (which wallet pays which invoice, double-charge guards, DMs, etc.) lives in `spec/billing.md`. + +## `pub struct Bitcoin` + +Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the wallet used to *receive* payments — issue and look up bolt11 invoices) and the HTTP client used for the fiat↔BTC spot price feed. + +Members: + +- `system_nwc_url: String` - from `NWC_URL` +- `http: reqwest::Client` + +### `pub fn from_env() -> Self` + +Reads `NWC_URL` (the system / receiving wallet) and constructs a fresh `reqwest::Client`. Unlike the Stripe keys, `NWC_URL` is **optional**: if it's unset, Lightning operations fail at use time with a clear error rather than panicking at startup. This is what `Billing::new` calls. + +### `pub fn system_wallet(&self) -> Result` + +Returns the system `Wallet` (parsed from `system_nwc_url` with label `"system"`). Errors if `NWC_URL` is unset or malformed. + +### `pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result` + +Fetches the live BTC spot price (via `btc_spot_price_from_base` against Coinbase) for `currency` and converts `amount_due_minor` to millisatoshis via `fiat_minor_to_msats_from_quote`. `currency` is upper-cased before use. + +## `pub struct Wallet` + +A handle to a single NWC wallet. Each operation opens a fresh NWC connection and tears it down (`shutdown`) afterwards. + +Member: + +- `uri: NostrWalletConnectURI` - the parsed `nostr+walletconnect://…` URI + +### `pub fn parse(uri: &str, label: &str) -> Result` + +Parses an `nostr+walletconnect://` URI. `label` (e.g. `"system"` / `"tenant"`) only flavours the error message — `invalid {label} NWC URL` — so callers can tell which wallet was misconfigured. + +### `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result` + +Issues a bolt11 invoice for `amount_msats` with the given description. Returns the bolt11 string. + +### `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>` + +Pays a bolt11 invoice. + +### `pub async fn invoice_settled(&self, bolt11: &str) -> Result` + +Looks up a bolt11 invoice (previously issued by this wallet) and returns whether it has settled — true if the transaction state is `Settled` or `settled_at` is present. + +## `pub async fn btc_spot_price_from_base(http: &reqwest::Client, api_base: &str, currency: &str) -> Result` + +Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a Coinbase-shaped API at `api_base` (`{api_base}/BTC-{currency}/spot`); production callers reach it via `Bitcoin::fiat_minor_to_msats` with the real Coinbase base, while tests can point it at a stub. Errors if the quote is missing, unparseable, or non-positive. + +## `pub fn fiat_minor_to_msats_from_quote(amount_due_minor: i64, currency: &str, btc_price_in_fiat: f64) -> Result` + +Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, given a BTC price quote in that currency. + +- Errors if `amount_due_minor <= 0` or `btc_price_in_fiat <= 0` +- Converts minor units to a major-unit amount using the currency's decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3 — following Stripe's currency conventions; an unrecognized or malformed code is an error) +- `msats = (amount_fiat / btc_price_in_fiat) * 100_000_000_000` +- Rounds **up** so we never under-charge, but snaps to the nearest integer when within `1e-6` of one to avoid floating-point artifacts at integer boundaries +- Errors if the result is non-finite, non-positive, or exceeds `u64::MAX` diff --git a/backend/spec/stripe.md b/backend/spec/stripe.md new file mode 100644 index 0000000..cf7b2e8 --- /dev/null +++ b/backend/spec/stripe.md @@ -0,0 +1,113 @@ +# `pub struct Stripe` + +A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns `serde_json::Value` (or small typed results). The domain logic that drives it lives in `spec/billing.md`. + +Members: + +- `secret_key: String` - Stripe API key, used as the bearer token and as the HMAC key for idempotency keys +- `webhook_secret: String` - secret for verifying Stripe webhook signatures +- `http: reqwest::Client` + +All requests authenticate with `secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with `secret_key`. Reconcile-to-desired-state writes (e.g. setting an item quantity) intentionally omit the idempotency key, since re-applying the same target is a no-op. + +On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line. + +## `pub fn from_env() -> Self` + +Reads `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` (both required) and constructs the client. Panics if either is missing or blank. This is what `Billing::new` calls. + +## `pub fn new(secret_key: String, webhook_secret: String) -> Self` + +Constructs the client with a fresh `reqwest::Client` from explicit keys (does not touch the environment). + +## `pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result` + +- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]` +- Idempotent on `tenant_pubkey` +- Returns the new customer id; errors if it isn't a `cus_…` id + +## `pub async fn get_subscription(&self, subscription_id: &str) -> Result>` + +- `GET /v1/subscriptions/:id` +- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the subscription object + +## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap) -> Result<(String, BTreeMap)>` + +- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry +- Idempotent on the customer and the `(price, quantity)` set +- Returns the subscription id and a map from price id to the created subscription item id + +## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result` + +- `POST /v1/subscription_items` +- Idempotent on `(subscription_id, price_id)` +- Returns the new subscription item id + +## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>` + +- `POST /v1/subscription_items/:id` with `quantity` +- No idempotency key (reconcile-to-target write) + +## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>` + +- `DELETE /v1/subscription_items/:id` + +## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>` + +- `DELETE /v1/subscriptions/:id` + +## `pub async fn list_invoices(&self, customer_id: &str) -> Result` + +- `GET /v1/invoices?customer=…` +- Returns the `data` array + +## `pub async fn get_invoice(&self, invoice_id: &str) -> Result` + +- `GET /v1/invoices/:id` +- On a 4xx response, returns `InvoiceLookupError::StripeClient { status }` (callers usually surface this as a client error, e.g. `404` "no such invoice"); other failures are `InvoiceLookupError::Internal` +- Returns the full invoice object + +## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>` + +- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method) +- Idempotent on `invoice_id` + +## `pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()>` + +- `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe +- Idempotent on `invoice_id` + +## `pub async fn preview_upcoming_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result` + +- `GET /v1/invoices/upcoming?customer=…[&subscription=…]` +- Used to validate proration when a subscription is downgraded + +## `pub async fn has_payment_method(&self, customer_id: &str) -> Result` + +- `GET /v1/payment_methods?customer=…&type=card` +- Returns whether the customer has at least one card on file + +## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result` + +- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url` +- Returns the portal session URL + +## `pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result` + +Verifies the `Stripe-Signature` header against `webhook_secret` and parses the body. + +- Parse `t=` (timestamp) and `v1=` (signature) from the header +- Compute `HMAC-SHA256(webhook_secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch +- Error if the timestamp is more than 300 seconds from now +- Returns the deserialized `Event` (`{ event_type, data: { object } }`) + +# `pub enum InvoiceLookupError` + +- `StripeClient { status: reqwest::StatusCode }` - Stripe returned a 4xx for an invoice lookup +- `Internal(anyhow::Error)` - any other failure + +Implements `Display`/`Error` and `From` / `From` (both mapping to `Internal`). + +# `pub struct Event` / `pub struct EventData` + +The verified, parsed webhook event: `Event { event_type: String, data: EventData }`, `EventData { object: serde_json::Value }`. diff --git a/backend/src/api.rs b/backend/src/api.rs index 36adf64..37f06f0 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -12,13 +12,14 @@ use base64::Engine; use nostr_sdk::{Event, JsonUtil, Kind}; use serde::{Deserialize, Serialize}; -use crate::billing::{Billing, InvoiceLookupError}; +use crate::billing::Billing; use crate::command::Command; use crate::infra::Infra; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, }; use crate::query::Query; +use crate::stripe::InvoiceLookupError; use axum::body::Bytes; #[derive(Clone)] diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 024434f..7c168ae 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -1,82 +1,17 @@ use anyhow::{Result, anyhow}; -use hmac::{Hmac, Mac}; -use nwc::prelude::{ - LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI, - PayInvoiceRequest as NwcPayInvoiceRequest, TransactionState, -}; -use sha2::Sha256; use std::collections::BTreeMap; +use crate::bitcoin::{Bitcoin, Wallet}; use crate::command::Command; use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay}; use crate::query::Query; use crate::robot::Robot; +use crate::stripe::{InvoiceLookupError, Stripe}; -type HmacSha256 = Hmac; - -const STRIPE_API: &str = "https://api.stripe.com/v1"; -const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; -const WEBHOOK_TOLERANCE_SECS: i64 = 300; const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment."; const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:"; const NWC_ERROR_DM_MAX_CHARS: usize = 240; - -#[derive(Debug)] -pub enum InvoiceLookupError { - StripeClient { status: reqwest::StatusCode }, - Internal(anyhow::Error), -} - -impl std::fmt::Display for InvoiceLookupError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::StripeClient { status } => { - write!( - f, - "stripe invoice lookup failed with status {}", - status.as_u16() - ) - } - Self::Internal(error) => write!(f, "{error}"), - } - } -} - -impl std::error::Error for InvoiceLookupError {} - -impl From for InvoiceLookupError { - fn from(value: anyhow::Error) -> Self { - Self::Internal(value) - } -} - -impl From for InvoiceLookupError { - fn from(value: reqwest::Error) -> Self { - Self::Internal(value.into()) - } -} - -#[derive(serde::Deserialize)] -struct StripeEvent { - #[serde(rename = "type")] - event_type: String, - data: StripeEventData, -} - -#[derive(serde::Deserialize)] -struct StripeEventData { - object: serde_json::Value, -} - -#[derive(serde::Deserialize)] -struct CoinbaseSpotPriceResponse { - data: CoinbaseSpotPriceData, -} - -#[derive(serde::Deserialize)] -struct CoinbaseSpotPriceData { - amount: String, -} +const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment"; enum NwcInvoicePaymentOutcome { Paid, @@ -86,10 +21,8 @@ enum NwcInvoicePaymentOutcome { #[derive(Clone)] pub struct Billing { - nwc_url: String, - stripe_secret_key: String, - stripe_webhook_secret: String, - http: reqwest::Client, + stripe: Stripe, + bitcoin: Bitcoin, query: Query, command: Command, robot: Robot, @@ -97,20 +30,9 @@ pub struct Billing { impl Billing { pub fn new(query: Query, command: Command, robot: Robot) -> Self { - let nwc_url = std::env::var("NWC_URL").unwrap_or_default(); - let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); - if stripe_secret_key.trim().is_empty() { - panic!("missing STRIPE_SECRET_KEY environment variable"); - } - let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); - if stripe_webhook_secret.trim().is_empty() { - panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); - } Self { - nwc_url, - stripe_secret_key, - stripe_webhook_secret, - http: reqwest::Client::new(), + stripe: Stripe::from_env(), + bitcoin: Bitcoin::from_env(), query, command, robot, @@ -181,21 +103,15 @@ impl Billing { | "complete_relay_sync" ); - if needs_billing_sync { - self.sync_relay_subscription(activity).await?; + if needs_billing_sync + && let Some(relay) = self.query.get_relay(&activity.resource_id).await? + { + self.sync_tenant_subscription(&relay.tenant).await?; } Ok(()) } - pub async fn sync_relay_subscription(&self, activity: &Activity) -> Result<()> { - let Some(relay) = self.query.get_relay(&activity.resource_id).await? else { - return Ok(()); - }; - - self.sync_tenant_subscription(&relay.tenant).await - } - /// Reconciles a tenant's single Stripe subscription with the set of relays that /// should be billed. /// @@ -237,7 +153,7 @@ impl Billing { // Resolve the live subscription, dropping a stale reference to one that no // longer exists or has been canceled. let subscription = match tenant.stripe_subscription_id.as_deref() { - Some(subscription_id) => match self.stripe_get_subscription(subscription_id).await? { + Some(subscription_id) => match self.stripe.get_subscription(subscription_id).await? { Some(sub) if !matches!( sub["status"].as_str().unwrap_or_default(), @@ -260,7 +176,7 @@ impl Billing { // No relays to bill: tear everything down. if desired.is_empty() { if let Some(ref subscription_id) = tenant.stripe_subscription_id { - self.stripe_cancel_subscription(subscription_id).await?; + self.stripe.cancel_subscription(subscription_id).await?; self.command .clear_tenant_subscription(tenant_pubkey) .await?; @@ -283,7 +199,8 @@ impl Billing { match subscription { None => { let (subscription_id, items) = self - .stripe_create_subscription(&tenant.stripe_customer_id, &desired) + .stripe + .create_subscription(&tenant.stripe_customer_id, &desired) .await?; self.command .set_tenant_subscription(tenant_pubkey, &subscription_id) @@ -315,13 +232,15 @@ impl Billing { if quantity < current_quantity { downgraded = true; } - self.stripe_set_subscription_item_quantity(&item_id, quantity) + self.stripe + .set_subscription_item_quantity(&item_id, quantity) .await?; } price_to_item.insert(price_id.clone(), item_id); } else { let item_id = self - .stripe_create_subscription_item(&subscription_id, price_id, quantity) + .stripe + .create_subscription_item(&subscription_id, price_id, quantity) .await?; price_to_item.insert(price_id.clone(), item_id); } @@ -330,7 +249,7 @@ impl Billing { // Items for plans no relay is on anymore. for (_, (item_id, _)) in current { downgraded = true; - self.stripe_delete_subscription_item(&item_id).await?; + self.stripe.delete_subscription_item(&item_id).await?; } } } @@ -384,9 +303,7 @@ impl Billing { } pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> { - self.verify_webhook_signature(payload, signature)?; - - let event: StripeEvent = serde_json::from_str(payload)?; + let event = self.stripe.construct_event(payload, signature)?; let obj = &event.data.object; match event.event_type.as_str() { @@ -429,40 +346,6 @@ impl Billing { Ok(()) } - fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> { - let mut timestamp = None; - let mut signature = None; - for part in sig_header.split(',') { - if let Some(t) = part.strip_prefix("t=") { - timestamp = Some(t); - } else if let Some(v) = part.strip_prefix("v1=") { - signature = Some(v); - } - } - let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?; - let signature = signature.ok_or_else(|| anyhow!("missing webhook signature"))?; - - let signed_payload = format!("{timestamp}.{payload}"); - let mut mac = HmacSha256::new_from_slice(self.stripe_webhook_secret.as_bytes()) - .map_err(|e| anyhow!("invalid webhook secret: {e}"))?; - mac.update(signed_payload.as_bytes()); - let expected = hex::encode(mac.finalize().into_bytes()); - - if expected != signature { - return Err(anyhow!("webhook signature mismatch")); - } - - let ts: i64 = timestamp - .parse() - .map_err(|_| anyhow!("bad webhook timestamp"))?; - let now = chrono::Utc::now().timestamp(); - if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS { - return Err(anyhow!("webhook timestamp outside tolerance")); - } - - Ok(()) - } - async fn handle_invoice_created( &self, stripe_customer_id: &str, @@ -536,7 +419,8 @@ impl Billing { // 2. Card on file: if the tenant has a payment method, Stripe charges automatically if self - .stripe_has_payment_method(&tenant.stripe_customer_id) + .stripe + .has_payment_method(&tenant.stripe_customer_id) .await? { return Ok(()); @@ -686,7 +570,8 @@ impl Billing { async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) { match self - .stripe_preview_upcoming_invoice( + .stripe + .preview_upcoming_invoice( &tenant.stripe_customer_id, tenant.stripe_subscription_id.as_deref(), ) @@ -743,7 +628,7 @@ impl Billing { &self, invoice_id: &str, ) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> { - let invoice = self.stripe_get_invoice(invoice_id).await?; + let invoice = self.stripe.get_invoice(invoice_id).await?; let customer_id = invoice["customer"] .as_str() .ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?; @@ -803,92 +688,39 @@ impl Billing { pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result { let short_pubkey: String = tenant_pubkey.chars().take(8).collect(); - let nostr_name = self.robot.fetch_nostr_name(tenant_pubkey).await; - let display_name = nostr_name.unwrap_or_else(|| short_pubkey.clone()); - let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]); - - let resp = self - .http - .post(format!("{STRIPE_API}/customers")) - .bearer_auth(&self.stripe_secret_key) - .header("Idempotency-Key", idempotency_key) - .form(&[ - ("name", display_name.as_str()), - ("metadata[tenant_pubkey]", tenant_pubkey), - ]) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - let customer_id = body["id"] - .as_str() - .ok_or_else(|| anyhow!("missing customer id"))?; - - if !customer_id.starts_with("cus_") { - return Err(anyhow!("unexpected customer id format")); - } - - Ok(customer_id.to_string()) + let display_name = self + .robot + .fetch_nostr_name(tenant_pubkey) + .await + .unwrap_or(short_pubkey); + self.stripe + .create_customer(&display_name, tenant_pubkey) + .await } pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result { - let resp = self - .http - .get(format!("{STRIPE_API}/invoices")) - .bearer_auth(&self.stripe_secret_key) - .query(&[("customer", customer_id)]) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - Ok(body["data"].clone()) + self.stripe.list_invoices(customer_id).await } - pub async fn stripe_get_invoice( + pub async fn stripe_create_portal_session( &self, - invoice_id: &str, - ) -> std::result::Result { - let resp = self - .http - .get(format!("{STRIPE_API}/invoices/{invoice_id}")) - .bearer_auth(&self.stripe_secret_key) - .send() - .await?; - - if resp.status().is_client_error() { - return Err(InvoiceLookupError::StripeClient { - status: resp.status(), - }); - } - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - Ok(body) + customer_id: &str, + return_url: Option<&str>, + ) -> Result { + self.stripe + .create_portal_session(customer_id, return_url) + .await } pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result { - let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?; - - let system_uri: NostrWalletConnectURI = self - .nwc_url - .parse() - .map_err(|_| anyhow!("invalid system NWC URL"))?; - let system_nwc = NWC::new(system_uri); - - let make_req = MakeInvoiceRequest { - amount: amount_msats, - description: Some("Relay subscription payment".to_string()), - description_hash: None, - expiry: None, - }; - - let invoice_response = system_nwc - .make_invoice(make_req) + let amount_msats = self + .bitcoin + .fiat_minor_to_msats(amount_due_minor, currency) + .await?; + self.bitcoin + .system_wallet()? + .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) .await - .map_err(|e| anyhow!("failed to create invoice: {e}"))?; - - system_nwc.shutdown().await; - - Ok(invoice_response.invoice) } pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> { @@ -899,7 +731,8 @@ impl Billing { let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?; let invoices = self - .stripe_list_invoices(&tenant.stripe_customer_id) + .stripe + .list_invoices(&tenant.stripe_customer_id) .await?; let invoices_arr = invoices.as_array().cloned().unwrap_or_default(); @@ -967,14 +800,16 @@ impl Billing { async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> { if !self - .stripe_has_payment_method(&tenant.stripe_customer_id) + .stripe + .has_payment_method(&tenant.stripe_customer_id) .await? { return Ok(()); } let invoices = self - .stripe_list_invoices(&tenant.stripe_customer_id) + .stripe + .list_invoices(&tenant.stripe_customer_id) .await?; let invoices_arr = invoices.as_array().cloned().unwrap_or_default(); @@ -987,7 +822,7 @@ impl Billing { continue; } - if let Err(error) = self.stripe_pay_invoice(invoice_id).await { + if let Err(error) = self.stripe.pay_invoice(invoice_id).await { tracing::error!( error = %error, invoice_id, @@ -999,273 +834,14 @@ impl Billing { Ok(()) } - pub async fn stripe_create_portal_session( - &self, - customer_id: &str, - return_url: Option<&str>, - ) -> Result { - let mut params = vec![("customer", customer_id.to_string())]; - if let Some(url) = return_url { - params.push(("return_url", url.to_string())); - } - let resp = self - .http - .post(format!("{STRIPE_API}/billing_portal/sessions")) - .bearer_auth(&self.stripe_secret_key) - .form(¶ms) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - let url = body["url"] - .as_str() - .ok_or_else(|| anyhow!("missing portal session url"))? - .to_string(); - - Ok(url) - } - - // --- Stripe API helpers --- - - fn idempotency_key(&self, parts: &[&str]) -> String { - let mut mac = HmacSha256::new_from_slice(self.stripe_secret_key.as_bytes()) - .expect("HMAC accepts any key length"); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - mac.update(b":"); - } - mac.update(part.as_bytes()); - } - hex::encode(mac.finalize().into_bytes()) - } - - /// Fetches a subscription, returning `None` if Stripe no longer knows about it - /// (so callers can recover from a stale `stripe_subscription_id`). - async fn stripe_get_subscription( - &self, - subscription_id: &str, - ) -> Result> { - let resp = self - .http - .get(format!("{STRIPE_API}/subscriptions/{subscription_id}")) - .bearer_auth(&self.stripe_secret_key) - .send() - .await?; - - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - Ok(Some(body)) - } - - /// Creates a subscription with one item per `(price_id, quantity)` entry. Returns - /// the subscription id and a map from price id to the created subscription item id. - async fn stripe_create_subscription( - &self, - customer_id: &str, - items: &BTreeMap, - ) -> Result<(String, BTreeMap)> { - let mut form: Vec<(String, String)> = vec![ - ("customer".to_string(), customer_id.to_string()), - ( - "collection_method".to_string(), - "charge_automatically".to_string(), - ), - ]; - let mut key_parts: Vec = - vec!["create_subscription".to_string(), customer_id.to_string()]; - for (index, (price_id, quantity)) in items.iter().enumerate() { - form.push((format!("items[{index}][price]"), price_id.clone())); - form.push((format!("items[{index}][quantity]"), quantity.to_string())); - key_parts.push(format!("{price_id}={quantity}")); - } - let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect(); - let idempotency_key = self.idempotency_key(&key_refs); - - let resp = self - .http - .post(format!("{STRIPE_API}/subscriptions")) - .bearer_auth(&self.stripe_secret_key) - .header("Idempotency-Key", idempotency_key) - .form(&form) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - let subscription_id = body["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription id"))? - .to_string(); - let mut price_to_item = BTreeMap::new(); - for item in body["items"]["data"] - .as_array() - .ok_or_else(|| anyhow!("missing subscription items"))? - { - let item_id = item["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription item id"))?; - let price_id = item["price"]["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription item price id"))?; - price_to_item.insert(price_id.to_string(), item_id.to_string()); - } - - Ok((subscription_id, price_to_item)) - } - - async fn stripe_create_subscription_item( - &self, - subscription_id: &str, - price_id: &str, - quantity: i64, - ) -> Result { - let idempotency_key = - self.idempotency_key(&["create_subscription_item", subscription_id, price_id]); - let quantity = quantity.to_string(); - let resp = self - .http - .post(format!("{STRIPE_API}/subscription_items")) - .bearer_auth(&self.stripe_secret_key) - .header("Idempotency-Key", idempotency_key) - .form(&[ - ("subscription", subscription_id), - ("price", price_id), - ("quantity", quantity.as_str()), - ]) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - let item_id = body["id"] - .as_str() - .ok_or_else(|| anyhow!("missing subscription item id"))? - .to_string(); - - Ok(item_id) - } - - /// Sets a subscription item's quantity. No idempotency key: this is a - /// reconcile-to-desired-state write, and re-applying the same target is a no-op. - async fn stripe_set_subscription_item_quantity( - &self, - item_id: &str, - quantity: i64, - ) -> Result<()> { - let resp = self - .http - .post(format!("{STRIPE_API}/subscription_items/{item_id}")) - .bearer_auth(&self.stripe_secret_key) - .form(&[("quantity", quantity.to_string())]) - .send() - .await?; - stripe_error_for_status(resp).await?; - - Ok(()) - } - - async fn stripe_delete_subscription_item(&self, item_id: &str) -> Result<()> { - let resp = self - .http - .delete(format!("{STRIPE_API}/subscription_items/{item_id}")) - .bearer_auth(&self.stripe_secret_key) - .send() - .await?; - stripe_error_for_status(resp).await?; - - Ok(()) - } - - async fn stripe_cancel_subscription(&self, subscription_id: &str) -> Result<()> { - let resp = self - .http - .delete(format!("{STRIPE_API}/subscriptions/{subscription_id}")) - .bearer_auth(&self.stripe_secret_key) - .send() - .await?; - stripe_error_for_status(resp).await?; - - Ok(()) - } - - async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> { - let idempotency_key = self.idempotency_key(&["pay_invoice", invoice_id]); - let resp = self - .http - .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) - .bearer_auth(&self.stripe_secret_key) - .header("Idempotency-Key", idempotency_key) - .send() - .await?; - stripe_error_for_status(resp).await?; - - Ok(()) - } - - async fn stripe_preview_upcoming_invoice( - &self, - customer_id: &str, - subscription_id: Option<&str>, - ) -> Result { - let mut req = self - .http - .get(format!("{STRIPE_API}/invoices/upcoming")) - .bearer_auth(&self.stripe_secret_key) - .query(&[("customer", customer_id)]); - - if let Some(subscription_id) = subscription_id { - req = req.query(&[("subscription", subscription_id)]); - } - - let body: serde_json::Value = stripe_error_for_status(req.send().await?) - .await? - .json() - .await?; - Ok(body) - } - - async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> { - let idempotency_key = self.idempotency_key(&["pay_invoice_oob", invoice_id]); - let resp = self - .http - .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) - .bearer_auth(&self.stripe_secret_key) - .header("Idempotency-Key", idempotency_key) - .form(&[("paid_out_of_band", "true")]) - .send() - .await?; - stripe_error_for_status(resp).await?; - - Ok(()) - } - - async fn stripe_has_payment_method(&self, customer_id: &str) -> Result { - let resp = self - .http - .get(format!("{STRIPE_API}/payment_methods")) - .bearer_auth(&self.stripe_secret_key) - .query(&[("customer", customer_id), ("type", "card")]) - .send() - .await?; - - let body: serde_json::Value = stripe_error_for_status(resp).await?.json().await?; - let has_method = body["data"] - .as_array() - .map(|a| !a.is_empty()) - .unwrap_or(false); - - Ok(has_method) - } - - // --- NWC helpers --- + // --- Lightning / NWC orchestration --- async fn mark_invoice_paid_out_of_band_after_nwc( &self, invoice_id: &str, tenant_pubkey: &str, ) -> Result<()> { - self.stripe_pay_invoice_out_of_band(invoice_id).await?; + self.stripe.pay_invoice_out_of_band(invoice_id).await?; self.command.clear_tenant_nwc_error(tenant_pubkey).await?; Ok(()) } @@ -1303,7 +879,7 @@ impl Billing { return Ok(invoice.clone()); } - if let Err(error) = self.stripe_pay_invoice_out_of_band(invoice_id).await { + if let Err(error) = self.stripe.pay_invoice_out_of_band(invoice_id).await { tracing::warn!( error = %error, invoice_id, @@ -1311,37 +887,16 @@ impl Billing { ); } - self.stripe_get_invoice(invoice_id).await + self.stripe.get_invoice(invoice_id).await } async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result { - let system_uri = Self::parse_nwc_uri(&self.nwc_url, "system")?; - let system_nwc = NWC::new(system_uri); - - let lookup_req = LookupInvoiceRequest { - payment_hash: None, - invoice: Some(bolt11.to_string()), - }; - - let lookup_result = system_nwc.lookup_invoice(lookup_req).await; - system_nwc.shutdown().await; - - let lookup_response = - lookup_result.map_err(|error| anyhow!("failed to lookup invoice: {error}"))?; - - Ok(Self::lookup_invoice_response_is_settled(&lookup_response)) - } - - fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool { - response.state == Some(TransactionState::Settled) || response.settled_at.is_some() - } - - fn parse_nwc_uri(nwc_url: &str, role: &str) -> Result { - nwc_url - .parse::() - .map_err(|_| anyhow!("invalid {role} NWC URL")) + self.bitcoin.system_wallet()?.invoice_settled(bolt11).await } + /// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11 + /// invoice for the fiat amount, the tenant's wallet pays it. A `pending` row in + /// `invoice_nwc_payment` guards against double-charging across retries. async fn nwc_pay_invoice( &self, invoice_id: &str, @@ -1357,42 +912,29 @@ impl Billing { return Ok(existing_outcome); } - let amount_msats = match self.fiat_minor_to_msats(amount_due_minor, currency).await { + let amount_msats = match self + .bitcoin + .fiat_minor_to_msats(amount_due_minor, currency) + .await + { Ok(amount_msats) => amount_msats, Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; - // Create a bolt11 invoice using the system wallet (self.nwc_url) - let system_uri = match Self::parse_nwc_uri(&self.nwc_url, "system") { - Ok(system_uri) => system_uri, + let system_wallet = match self.bitcoin.system_wallet() { + Ok(wallet) => wallet, Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; - let system_nwc = NWC::new(system_uri); - - let make_req = MakeInvoiceRequest { - amount: amount_msats, - description: Some("Relay subscription payment".to_string()), - description_hash: None, - expiry: None, + let bolt11 = match system_wallet + .make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION) + .await + { + Ok(bolt11) => bolt11, + Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; - let invoice_response = system_nwc.make_invoice(make_req).await; - - let invoice_response = match invoice_response { - Ok(invoice_response) => invoice_response, - Err(error) => { - system_nwc.shutdown().await; - return Ok(NwcInvoicePaymentOutcome::Fallback(anyhow!( - "failed to create invoice: {error}" - ))); - } - }; - - system_nwc.shutdown().await; - - // Pay the bolt11 invoice using the tenant's wallet - let tenant_uri = match Self::parse_nwc_uri(tenant_nwc_url, "tenant") { - Ok(tenant_uri) => tenant_uri, + let tenant_wallet = match Wallet::parse(tenant_nwc_url, "tenant") { + Ok(wallet) => wallet, Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)), }; @@ -1412,16 +954,8 @@ impl Billing { )); } - let tenant_nwc = NWC::new(tenant_uri); - - let pay_req = NwcPayInvoiceRequest::new(invoice_response.invoice); - - let pay_result = tenant_nwc.pay_invoice(pay_req).await; - - tenant_nwc.shutdown().await; - - match pay_result { - Ok(_) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await { + match tenant_wallet.pay_invoice(bolt11).await { + Ok(()) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await { Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid), Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!( "invoice {invoice_id} was charged over NWC but failed to persist paid state: {error}" @@ -1433,98 +967,9 @@ impl Billing { } } - async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result { - let normalized_currency = currency.to_uppercase(); - let btc_price = self.fetch_btc_spot_price(&normalized_currency).await?; - fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price) - } - fn should_reactivate_after_payment(relay: &Relay) -> bool { relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan) } - - async fn fetch_btc_spot_price(&self, currency: &str) -> Result { - fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await - } - - fn currency_minor_exponent(currency: &str) -> Result { - let normalized = currency.to_uppercase(); - let exponent = match normalized.as_str() { - // Zero-decimal currencies in Stripe. - "BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF" - | "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0, - // Three-decimal currencies in Stripe. - "BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3, - _ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2, - _ => return Err(anyhow!("invalid currency code: {currency}")), - }; - - Ok(exponent) - } -} - -/// Like [`reqwest::Response::error_for_status`], but on a 4xx/5xx response it reads -/// the body and folds Stripe's JSON error payload (`error.message`/`code`/`param`) -/// into the returned error, so callers get an actionable message instead of a bare -/// "400 Bad Request" with only the URL. -async fn stripe_error_for_status(resp: reqwest::Response) -> Result { - let status = resp.status(); - if !status.is_client_error() && !status.is_server_error() { - return Ok(resp); - } - - let url = resp.url().clone(); - let body = resp.text().await.unwrap_or_default(); - let detail = serde_json::from_str::(&body) - .ok() - .and_then(|json| { - let error = &json["error"]; - let message = error["message"].as_str()?.to_string(); - let mut detail = message; - if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) { - detail.push_str(&format!(" [{code}]")); - } - if let Some(param) = error["param"].as_str() { - detail.push_str(&format!(" (param: {param})")); - } - Some(detail) - }) - .unwrap_or_else(|| { - if body.trim().is_empty() { - "".to_string() - } else { - body - } - }); - - Err(anyhow!( - "Stripe API request to {url} failed with status {status}: {detail}" - )) -} - -pub async fn fetch_btc_spot_price_from_base( - http: &reqwest::Client, - api_base: &str, - currency: &str, -) -> Result { - let pair = format!("BTC-{currency}"); - let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/')); - let resp = http.get(url).send().await?; - let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?; - - let amount = body - .data - .amount - .parse::() - .map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?; - - if amount <= 0.0 { - return Err(anyhow!( - "invalid non-positive BTC spot quote for {currency}" - )); - } - - Ok(amount) } fn summarize_nwc_error_for_dm(error: &str) -> Option { @@ -1552,41 +997,9 @@ fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String { } } -pub fn fiat_minor_to_msats_from_quote( - amount_due_minor: i64, - currency: &str, - btc_price_in_fiat: f64, -) -> Result { - if amount_due_minor <= 0 { - return Err(anyhow!("amount_due must be positive")); - } - - if btc_price_in_fiat <= 0.0 { - return Err(anyhow!("btc_price_in_fiat must be positive")); - } - - let exponent = Billing::currency_minor_exponent(currency)?; - let divisor = 10_f64.powi(exponent as i32); - let amount_fiat = (amount_due_minor as f64) / divisor; - let amount_btc = amount_fiat / btc_price_in_fiat; - let raw_msats = amount_btc * 100_000_000_000.0; - // Guard against tiny floating point artifacts at integer boundaries. - let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 { - raw_msats.round() - } else { - raw_msats.ceil() - }; - - if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 { - return Err(anyhow!("calculated msat amount is out of bounds")); - } - - Ok(amount_msats as u64) -} - #[cfg(test)] mod tests { - use super::{Billing, fiat_minor_to_msats_from_quote}; + use super::Billing; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, }; @@ -1615,20 +1028,6 @@ mod tests { } } - #[test] - fn converts_usd_minor_units_with_quote() { - let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0) - .expect("conversion should succeed"); - assert_eq!(msats, 1_000_000); - } - - #[test] - fn converts_zero_decimal_currency_with_quote() { - let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0) - .expect("conversion should succeed"); - assert_eq!(msats, 1_000_000); - } - #[test] fn reactivates_only_delinquent_paid_relays_after_payment() { let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic"); @@ -1846,7 +1245,7 @@ mod tests { Robot::test_stub(), ); - assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); - assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); + assert_eq!(billing.stripe.secret_key, "sk_test_dummy"); + assert_eq!(billing.stripe.webhook_secret, "whsec_test_dummy"); } } diff --git a/backend/src/bitcoin.rs b/backend/src/bitcoin.rs new file mode 100644 index 0000000..fbc5427 --- /dev/null +++ b/backend/src/bitcoin.rs @@ -0,0 +1,220 @@ +//! Small wrappers around the Bitcoin-facing services this app talks to: Nostr +//! Wallet Connect wallets (for Lightning invoices/payments) and a fiat↔BTC spot +//! price feed, plus the fiat-minor-units → millisatoshi conversion that ties them +//! together. The billing-specific orchestration lives in [`crate::billing`]. + +use anyhow::{Result, anyhow}; +use nwc::prelude::{ + LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI, + PayInvoiceRequest, TransactionState, +}; + +const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; +/// Millisatoshis per bitcoin. +const MSATS_PER_BTC: f64 = 100_000_000_000.0; + +/// Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the +/// wallet used to *receive* payments — issue and look up bolt11 invoices) and the +/// HTTP client used for the fiat↔BTC spot price feed. +#[derive(Clone)] +pub struct Bitcoin { + system_nwc_url: String, + http: reqwest::Client, +} + +impl Bitcoin { + /// Reads `NWC_URL` (the system / receiving wallet). Unlike the Stripe keys this + /// is optional: if it's unset, Lightning operations fail at use time with a + /// clear error rather than at startup. + pub fn from_env() -> Self { + Self { + system_nwc_url: std::env::var("NWC_URL").unwrap_or_default(), + http: reqwest::Client::new(), + } + } + + /// The system wallet — issues and looks up the bolt11 invoices we want paid to + /// us. Errors if `NWC_URL` is unset or malformed. + pub fn system_wallet(&self) -> Result { + Wallet::parse(&self.system_nwc_url, "system") + } + + /// Fetches the live BTC spot price and converts a fiat amount in minor units + /// (cents, etc.) to millisatoshis. + pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result { + let currency = currency.to_uppercase(); + let btc_price = btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, ¤cy).await?; + fiat_minor_to_msats_from_quote(amount_due_minor, ¤cy, btc_price) + } +} + +#[derive(serde::Deserialize)] +struct CoinbaseSpotPriceResponse { + data: CoinbaseSpotPriceData, +} + +#[derive(serde::Deserialize)] +struct CoinbaseSpotPriceData { + amount: String, +} + +/// A handle to a single Nostr Wallet Connect wallet. Each operation opens a fresh +/// connection and tears it down afterwards. +pub struct Wallet { + uri: NostrWalletConnectURI, +} + +impl Wallet { + /// Parses an `nostr+walletconnect://` URI. `label` only flavours the error + /// message so callers can tell which wallet was misconfigured. + pub fn parse(uri: &str, label: &str) -> Result { + let uri = uri + .parse::() + .map_err(|_| anyhow!("invalid {label} NWC URL"))?; + Ok(Self { uri }) + } + + /// Issues a bolt11 invoice for `amount_msats`. + pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result { + let nwc = NWC::new(self.uri.clone()); + let result = nwc + .make_invoice(MakeInvoiceRequest { + amount: amount_msats, + description: Some(description.to_string()), + description_hash: None, + expiry: None, + }) + .await; + nwc.shutdown().await; + Ok(result + .map_err(|e| anyhow!("failed to create invoice: {e}"))? + .invoice) + } + + /// Pays a bolt11 invoice. + pub async fn pay_invoice(&self, bolt11: String) -> Result<()> { + let nwc = NWC::new(self.uri.clone()); + let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await; + nwc.shutdown().await; + result.map(|_| ()).map_err(|e| anyhow!("{e}")) + } + + /// Returns whether a bolt11 invoice (previously issued by this wallet) has been + /// settled. + pub async fn invoice_settled(&self, bolt11: &str) -> Result { + let nwc = NWC::new(self.uri.clone()); + let result = nwc + .lookup_invoice(LookupInvoiceRequest { + payment_hash: None, + invoice: Some(bolt11.to_string()), + }) + .await; + nwc.shutdown().await; + let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?; + Ok(lookup_invoice_response_is_settled(&response)) + } +} + +fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool { + response.state == Some(TransactionState::Settled) || response.settled_at.is_some() +} + +/// Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a +/// Coinbase-shaped API at `api_base`. Exposed so tests can stub the price feed; +/// production callers go through [`Bitcoin::fiat_minor_to_msats`]. +pub async fn btc_spot_price_from_base( + http: &reqwest::Client, + api_base: &str, + currency: &str, +) -> Result { + let pair = format!("BTC-{currency}"); + let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/')); + let resp = http.get(url).send().await?; + let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?; + + let amount = body + .data + .amount + .parse::() + .map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?; + if amount <= 0.0 { + return Err(anyhow!( + "invalid non-positive BTC spot quote for {currency}" + )); + } + Ok(amount) +} + +/// Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, +/// given a BTC price quote in that currency. Rounds up so we never under-charge, +/// but snaps to the nearest integer when within a hair of one to avoid floating +/// point artifacts at integer boundaries. +pub fn fiat_minor_to_msats_from_quote( + amount_due_minor: i64, + currency: &str, + btc_price_in_fiat: f64, +) -> Result { + if amount_due_minor <= 0 { + return Err(anyhow!("amount_due must be positive")); + } + if btc_price_in_fiat <= 0.0 { + return Err(anyhow!("btc_price_in_fiat must be positive")); + } + + let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32); + let amount_fiat = (amount_due_minor as f64) / divisor; + let amount_btc = amount_fiat / btc_price_in_fiat; + let raw_msats = amount_btc * MSATS_PER_BTC; + let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 { + raw_msats.round() + } else { + raw_msats.ceil() + }; + + if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 { + return Err(anyhow!("calculated msat amount is out of bounds")); + } + Ok(amount_msats as u64) +} + +/// Number of decimal places in `currency`'s minor unit, following Stripe's +/// currency conventions (most are 2, JPY/KRW/… are 0, BHD/KWD/… are 3). +fn currency_minor_exponent(currency: &str) -> Result { + let normalized = currency.to_uppercase(); + let exponent = match normalized.as_str() { + // Zero-decimal currencies in Stripe. + "BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF" | "UGX" + | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0, + // Three-decimal currencies in Stripe. + "BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3, + _ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2, + _ => return Err(anyhow!("invalid currency code: {currency}")), + }; + Ok(exponent) +} + +#[cfg(test)] +mod tests { + use super::fiat_minor_to_msats_from_quote; + + #[test] + fn converts_usd_minor_units_with_quote() { + let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0) + .expect("conversion should succeed"); + assert_eq!(msats, 1_000_000); + } + + #[test] + fn converts_zero_decimal_currency_with_quote() { + let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0) + .expect("conversion should succeed"); + assert_eq!(msats, 1_000_000); + } + + #[test] + fn rejects_malformed_currency_code() { + // Not three ASCII letters: rejected outright. + assert!(fiat_minor_to_msats_from_quote(100, "usdd", 100_000.0).is_err()); + assert!(fiat_minor_to_msats_from_quote(100, "us1", 100_000.0).is_err()); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 32359b6..043746d 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod billing; +pub mod bitcoin; pub mod cipher; pub mod command; pub mod infra; @@ -7,3 +8,4 @@ pub mod models; pub mod pool; pub mod query; pub mod robot; +pub mod stripe; diff --git a/backend/src/main.rs b/backend/src/main.rs index 5f92556..b017f1c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,5 +1,6 @@ mod api; mod billing; +mod bitcoin; mod cipher; mod command; mod infra; @@ -7,6 +8,7 @@ mod models; mod pool; mod query; mod robot; +mod stripe; use anyhow::Result; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs new file mode 100644 index 0000000..b4facab --- /dev/null +++ b/backend/src/stripe.rs @@ -0,0 +1,482 @@ +//! A thin async wrapper around the subset of the Stripe REST API this service uses. +//! +//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP +//! to Stripe and hands back `serde_json::Value` (or small typed results). The +//! domain logic lives in [`crate::billing`]. + +use anyhow::{Result, anyhow}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::collections::BTreeMap; + +type HmacSha256 = Hmac; + +const STRIPE_API: &str = "https://api.stripe.com/v1"; +const WEBHOOK_TOLERANCE_SECS: i64 = 300; + +/// Error returned by invoice lookups, distinguishing a Stripe 4xx (e.g. "no such +/// invoice") — which callers usually want to surface as a client error — from an +/// internal failure. +#[derive(Debug)] +pub enum InvoiceLookupError { + StripeClient { status: reqwest::StatusCode }, + Internal(anyhow::Error), +} + +impl std::fmt::Display for InvoiceLookupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StripeClient { status } => { + write!( + f, + "stripe invoice lookup failed with status {}", + status.as_u16() + ) + } + Self::Internal(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for InvoiceLookupError {} + +impl From for InvoiceLookupError { + fn from(value: anyhow::Error) -> Self { + Self::Internal(value) + } +} + +impl From for InvoiceLookupError { + fn from(value: reqwest::Error) -> Self { + Self::Internal(value.into()) + } +} + +/// A Stripe webhook event with its signature already verified. +#[derive(serde::Deserialize)] +pub struct Event { + #[serde(rename = "type")] + pub event_type: String, + pub data: EventData, +} + +#[derive(serde::Deserialize)] +pub struct EventData { + pub object: serde_json::Value, +} + +#[derive(Clone)] +pub struct Stripe { + pub(crate) secret_key: String, + pub(crate) webhook_secret: String, + http: reqwest::Client, +} + +impl Stripe { + /// Builds the client from the environment: `STRIPE_SECRET_KEY` and + /// `STRIPE_WEBHOOK_SECRET`, both required. Panics if either is missing or blank. + pub fn from_env() -> Self { + let secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); + if secret_key.trim().is_empty() { + panic!("missing STRIPE_SECRET_KEY environment variable"); + } + let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); + if webhook_secret.trim().is_empty() { + panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); + } + Self::new(secret_key, webhook_secret) + } + + pub fn new(secret_key: String, webhook_secret: String) -> Self { + Self { + secret_key, + webhook_secret, + http: reqwest::Client::new(), + } + } + + // --- Customers --- + + /// Creates a customer with the given display name, tagging it with the tenant + /// pubkey in metadata. Idempotent on the tenant pubkey. + pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result { + let resp = self + .http + .post(format!("{STRIPE_API}/customers")) + .bearer_auth(&self.secret_key) + .header( + "Idempotency-Key", + self.idempotency_key(&["create_customer", tenant_pubkey]), + ) + .form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)]) + .send() + .await?; + + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + let customer_id = body["id"] + .as_str() + .ok_or_else(|| anyhow!("missing customer id"))?; + if !customer_id.starts_with("cus_") { + return Err(anyhow!("unexpected customer id format")); + } + Ok(customer_id.to_string()) + } + + // --- Subscriptions --- + + /// Fetches a subscription, returning `None` if Stripe no longer knows about it + /// (so callers can recover from a stale subscription id). + pub async fn get_subscription( + &self, + subscription_id: &str, + ) -> Result> { + let resp = self + .http + .get(format!("{STRIPE_API}/subscriptions/{subscription_id}")) + .bearer_auth(&self.secret_key) + .send() + .await?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + Ok(Some(body)) + } + + /// Creates a subscription with one item per `(price_id, quantity)` entry, billed + /// automatically. Returns the subscription id and a map from price id to the + /// created subscription item id. Idempotent on the customer and the item set. + pub async fn create_subscription( + &self, + customer_id: &str, + items: &BTreeMap, + ) -> Result<(String, BTreeMap)> { + let mut form: Vec<(String, String)> = vec![ + ("customer".to_string(), customer_id.to_string()), + ( + "collection_method".to_string(), + "charge_automatically".to_string(), + ), + ]; + let mut key_parts: Vec = + vec!["create_subscription".to_string(), customer_id.to_string()]; + for (index, (price_id, quantity)) in items.iter().enumerate() { + form.push((format!("items[{index}][price]"), price_id.clone())); + form.push((format!("items[{index}][quantity]"), quantity.to_string())); + key_parts.push(format!("{price_id}={quantity}")); + } + let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect(); + + let resp = self + .http + .post(format!("{STRIPE_API}/subscriptions")) + .bearer_auth(&self.secret_key) + .header("Idempotency-Key", self.idempotency_key(&key_refs)) + .form(&form) + .send() + .await?; + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + + let subscription_id = body["id"] + .as_str() + .ok_or_else(|| anyhow!("missing subscription id"))? + .to_string(); + let mut price_to_item = BTreeMap::new(); + for item in body["items"]["data"] + .as_array() + .ok_or_else(|| anyhow!("missing subscription items"))? + { + let item_id = item["id"] + .as_str() + .ok_or_else(|| anyhow!("missing subscription item id"))?; + let price_id = item["price"]["id"] + .as_str() + .ok_or_else(|| anyhow!("missing subscription item price id"))?; + price_to_item.insert(price_id.to_string(), item_id.to_string()); + } + Ok((subscription_id, price_to_item)) + } + + pub async fn create_subscription_item( + &self, + subscription_id: &str, + price_id: &str, + quantity: i64, + ) -> Result { + let quantity = quantity.to_string(); + let resp = self + .http + .post(format!("{STRIPE_API}/subscription_items")) + .bearer_auth(&self.secret_key) + .header( + "Idempotency-Key", + self.idempotency_key(&["create_subscription_item", subscription_id, price_id]), + ) + .form(&[ + ("subscription", subscription_id), + ("price", price_id), + ("quantity", quantity.as_str()), + ]) + .send() + .await?; + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + body["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| anyhow!("missing subscription item id")) + } + + /// Sets a subscription item's quantity. No idempotency key: this is a + /// reconcile-to-desired-state write, and re-applying the same target is a no-op. + pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> { + let resp = self + .http + .post(format!("{STRIPE_API}/subscription_items/{item_id}")) + .bearer_auth(&self.secret_key) + .form(&[("quantity", quantity.to_string())]) + .send() + .await?; + error_for_status(resp).await?; + Ok(()) + } + + pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> { + let resp = self + .http + .delete(format!("{STRIPE_API}/subscription_items/{item_id}")) + .bearer_auth(&self.secret_key) + .send() + .await?; + error_for_status(resp).await?; + Ok(()) + } + + pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> { + let resp = self + .http + .delete(format!("{STRIPE_API}/subscriptions/{subscription_id}")) + .bearer_auth(&self.secret_key) + .send() + .await?; + error_for_status(resp).await?; + Ok(()) + } + + // --- Invoices --- + + /// Returns the `data` array of the customer's invoices. + pub async fn list_invoices(&self, customer_id: &str) -> Result { + let resp = self + .http + .get(format!("{STRIPE_API}/invoices")) + .bearer_auth(&self.secret_key) + .query(&[("customer", customer_id)]) + .send() + .await?; + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + Ok(body["data"].clone()) + } + + pub async fn get_invoice( + &self, + invoice_id: &str, + ) -> std::result::Result { + let resp = self + .http + .get(format!("{STRIPE_API}/invoices/{invoice_id}")) + .bearer_auth(&self.secret_key) + .send() + .await?; + if resp.status().is_client_error() { + return Err(InvoiceLookupError::StripeClient { + status: resp.status(), + }); + } + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + Ok(body) + } + + pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> { + let resp = self + .http + .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) + .bearer_auth(&self.secret_key) + .header( + "Idempotency-Key", + self.idempotency_key(&["pay_invoice", invoice_id]), + ) + .send() + .await?; + error_for_status(resp).await?; + Ok(()) + } + + /// Marks an invoice paid out of band — used when we've collected payment over + /// Lightning rather than through Stripe. + pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> { + let resp = self + .http + .post(format!("{STRIPE_API}/invoices/{invoice_id}/pay")) + .bearer_auth(&self.secret_key) + .header( + "Idempotency-Key", + self.idempotency_key(&["pay_invoice_oob", invoice_id]), + ) + .form(&[("paid_out_of_band", "true")]) + .send() + .await?; + error_for_status(resp).await?; + Ok(()) + } + + pub async fn preview_upcoming_invoice( + &self, + customer_id: &str, + subscription_id: Option<&str>, + ) -> Result { + let mut req = self + .http + .get(format!("{STRIPE_API}/invoices/upcoming")) + .bearer_auth(&self.secret_key) + .query(&[("customer", customer_id)]); + if let Some(subscription_id) = subscription_id { + req = req.query(&[("subscription", subscription_id)]); + } + let body: serde_json::Value = error_for_status(req.send().await?).await?.json().await?; + Ok(body) + } + + // --- Payment methods --- + + pub async fn has_payment_method(&self, customer_id: &str) -> Result { + let resp = self + .http + .get(format!("{STRIPE_API}/payment_methods")) + .bearer_auth(&self.secret_key) + .query(&[("customer", customer_id), ("type", "card")]) + .send() + .await?; + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + Ok(body["data"].as_array().is_some_and(|a| !a.is_empty())) + } + + // --- Billing portal --- + + pub async fn create_portal_session( + &self, + customer_id: &str, + return_url: Option<&str>, + ) -> Result { + let mut params = vec![("customer", customer_id.to_string())]; + if let Some(url) = return_url { + params.push(("return_url", url.to_string())); + } + let resp = self + .http + .post(format!("{STRIPE_API}/billing_portal/sessions")) + .bearer_auth(&self.secret_key) + .form(¶ms) + .send() + .await?; + let body: serde_json::Value = error_for_status(resp).await?.json().await?; + body["url"] + .as_str() + .map(str::to_string) + .ok_or_else(|| anyhow!("missing portal session url")) + } + + // --- Webhooks --- + + /// Verifies the `Stripe-Signature` header against the configured webhook secret + /// (including the timestamp tolerance check) and parses the event body. + pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result { + self.verify_webhook_signature(payload, sig_header)?; + Ok(serde_json::from_str(payload)?) + } + + fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> { + let mut timestamp = None; + let mut signature = None; + for part in sig_header.split(',') { + if let Some(t) = part.strip_prefix("t=") { + timestamp = Some(t); + } else if let Some(v) = part.strip_prefix("v1=") { + signature = Some(v); + } + } + let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?; + let signature = signature.ok_or_else(|| anyhow!("missing webhook signature"))?; + + let signed_payload = format!("{timestamp}.{payload}"); + let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes()) + .map_err(|e| anyhow!("invalid webhook secret: {e}"))?; + mac.update(signed_payload.as_bytes()); + let expected = hex::encode(mac.finalize().into_bytes()); + if expected != signature { + return Err(anyhow!("webhook signature mismatch")); + } + + let ts: i64 = timestamp + .parse() + .map_err(|_| anyhow!("bad webhook timestamp"))?; + let now = chrono::Utc::now().timestamp(); + if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS { + return Err(anyhow!("webhook timestamp outside tolerance")); + } + Ok(()) + } + + // --- Internals --- + + /// Derives a stable idempotency key by HMAC-ing `parts` with the secret key. + fn idempotency_key(&self, parts: &[&str]) -> String { + let mut mac = HmacSha256::new_from_slice(self.secret_key.as_bytes()) + .expect("HMAC accepts any key length"); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + mac.update(b":"); + } + mac.update(part.as_bytes()); + } + hex::encode(mac.finalize().into_bytes()) + } +} + +/// Like [`reqwest::Response::error_for_status`], but on a 4xx/5xx response it reads +/// the body and folds Stripe's JSON error payload (`error.message`/`code`/`param`) +/// into the returned error, so callers get an actionable message instead of a bare +/// "400 Bad Request" with only the URL. +async fn error_for_status(resp: reqwest::Response) -> Result { + let status = resp.status(); + if !status.is_client_error() && !status.is_server_error() { + return Ok(resp); + } + + let url = resp.url().clone(); + let body = resp.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|json| { + let error = &json["error"]; + let message = error["message"].as_str()?.to_string(); + let mut detail = message; + if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) { + detail.push_str(&format!(" [{code}]")); + } + if let Some(param) = error["param"].as_str() { + detail.push_str(&format!(" (param: {param})")); + } + Some(detail) + }) + .unwrap_or_else(|| { + if body.trim().is_empty() { + "".to_string() + } else { + body + } + }); + + Err(anyhow!( + "Stripe API request to {url} failed with status {status}: {detail}" + )) +} diff --git a/backend/tests/btc_quote_stub.rs b/backend/tests/btc_quote_stub.rs deleted file mode 100644 index 12a2dae..0000000 --- a/backend/tests/btc_quote_stub.rs +++ /dev/null @@ -1,31 +0,0 @@ -use axum::{Json, Router, routing::get}; -use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote}; - -#[tokio::test] -async fn quote_endpoint_can_be_stubbed_deterministically() { - async fn spot() -> Json { - Json(serde_json::json!({ "data": { "amount": "50000.00" } })) - } - - let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot)); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test server"); - let addr = listener.local_addr().expect("get local addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve quote stub"); - }); - - let client = reqwest::Client::new(); - let base = format!("http://{addr}/v2/prices"); - let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD") - .await - .expect("fetch stubbed quote"); - - assert_eq!(btc_price, 50_000.0); - - let msats = - fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount"); - assert_eq!(msats, 2_000_000); -}