forked from coracle/caravel
Refactor billing module
This commit is contained in:
+45
-35
@@ -2,29 +2,26 @@
|
|||||||
|
|
||||||
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
|
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:
|
Members:
|
||||||
|
|
||||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`
|
||||||
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`)
|
||||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
|
||||||
- `query: Query`
|
- `query: Query`
|
||||||
- `command: Command`
|
- `command: Command`
|
||||||
- `robot: Robot`
|
- `robot: Robot`
|
||||||
|
|
||||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||||
|
|
||||||
- Reads environment and populates members
|
- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()`
|
||||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional)
|
||||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
|
||||||
|
|
||||||
## `pub fn start(&self)`
|
## `pub fn start(&self)`
|
||||||
|
|
||||||
- Subscribes to `command.notify.subscribe()`
|
- 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`.
|
- 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.
|
||||||
## `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.
|
|
||||||
|
|
||||||
## `fn sync_tenant_subscription(&self, tenant_pubkey: &str)`
|
## `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<()>`
|
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
|
||||||
|
|
||||||
- Verify the webhook signature using `self.stripe_webhook_secret`
|
- Verify and parse the event via `self.stripe.construct_event(payload, signature)` (checks the `Stripe-Signature` HMAC and timestamp tolerance — see `spec/stripe.md`)
|
||||||
- Parse the event and dispatch by type:
|
- Dispatch by type:
|
||||||
- `invoice.created` -> `self.handle_invoice_created`
|
- `invoice.created` -> `self.handle_invoice_created`
|
||||||
- `invoice.paid` -> `self.handle_invoice_paid`
|
- `invoice.paid` -> `self.handle_invoice_paid`
|
||||||
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
|
- `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`
|
- `payment_method.attached` -> `self.handle_payment_method_attached`
|
||||||
- Unknown event types are ignored (return Ok)
|
- Unknown event types are ignored (return Ok)
|
||||||
|
|
||||||
|
## `pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String>`
|
||||||
|
|
||||||
|
- 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<Value>`
|
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
|
||||||
|
|
||||||
- Fetches invoices from Stripe API for the given customer
|
- Delegates to `stripe.list_invoices` — returns the `data` array of the customer's invoices
|
||||||
- Returns the `data` array from the Stripe response
|
|
||||||
|
|
||||||
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
|
## `pub async fn stripe_create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
|
||||||
|
|
||||||
- Fetches a single invoice from Stripe API by ID
|
- Delegates to `stripe.create_portal_session` — returns the Customer Portal session URL
|
||||||
- Returns the full Stripe invoice object
|
|
||||||
|
|
||||||
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
|
## `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<Value, InvoiceLookupError>`
|
||||||
|
|
||||||
|
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<String>`
|
||||||
|
|
||||||
|
- 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<String>`
|
||||||
|
|
||||||
|
- 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
|
- Returns the bolt11 invoice string
|
||||||
|
|
||||||
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
|
|
||||||
|
|
||||||
- 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<()>`
|
## `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.
|
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.
|
- 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`:
|
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||||
- Attempt NWC payment via `nwc_pay_invoice`.
|
- 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.
|
- 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<()>`
|
## `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.
|
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
|
||||||
|
|
||||||
- If tenant has no card payment method, return early.
|
- If tenant has no card payment method (`stripe.has_payment_method`), return early.
|
||||||
- List all Stripe invoices for `tenant.stripe_customer_id`.
|
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
|
||||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
- 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.
|
- Log and continue on failures.
|
||||||
|
|
||||||
## `fn handle_invoice_created(&self, invoice: &Invoice)`
|
## `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:
|
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`:
|
1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first):
|
||||||
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
|
- 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.
|
||||||
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
|
- 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 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 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 payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
|
- 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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<Wallet>`
|
||||||
|
|
||||||
|
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<u64>`
|
||||||
|
|
||||||
|
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<Self>`
|
||||||
|
|
||||||
|
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<String>`
|
||||||
|
|
||||||
|
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<bool>`
|
||||||
|
|
||||||
|
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<f64>`
|
||||||
|
|
||||||
|
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<u64>`
|
||||||
|
|
||||||
|
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`
|
||||||
@@ -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<String>`
|
||||||
|
|
||||||
|
- `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<Option<Value>>`
|
||||||
|
|
||||||
|
- `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<String, i64>) -> Result<(String, BTreeMap<String, String>)>`
|
||||||
|
|
||||||
|
- `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<String>`
|
||||||
|
|
||||||
|
- `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<Value>`
|
||||||
|
|
||||||
|
- `GET /v1/invoices?customer=…`
|
||||||
|
- Returns the `data` array
|
||||||
|
|
||||||
|
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Value, InvoiceLookupError>`
|
||||||
|
|
||||||
|
- `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<Value>`
|
||||||
|
|
||||||
|
- `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<bool>`
|
||||||
|
|
||||||
|
- `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<String>`
|
||||||
|
|
||||||
|
- `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<Event>`
|
||||||
|
|
||||||
|
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<anyhow::Error>` / `From<reqwest::Error>` (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 }`.
|
||||||
+2
-1
@@ -12,13 +12,14 @@ use base64::Engine;
|
|||||||
use nostr_sdk::{Event, JsonUtil, Kind};
|
use nostr_sdk::{Event, JsonUtil, Kind};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::billing::{Billing, InvoiceLookupError};
|
use crate::billing::Billing;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::infra::Infra;
|
use crate::infra::Infra;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||||
};
|
};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
|
use crate::stripe::InvoiceLookupError;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
+84
-685
@@ -1,82 +1,17 @@
|
|||||||
use anyhow::{Result, anyhow};
|
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 std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::bitcoin::{Bitcoin, Wallet};
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
|
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
|
use crate::stripe::{InvoiceLookupError, Stripe};
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
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 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_PREFIX: &str = "NWC auto-payment failed:";
|
||||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||||
|
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
|
||||||
#[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<anyhow::Error> for InvoiceLookupError {
|
|
||||||
fn from(value: anyhow::Error) -> Self {
|
|
||||||
Self::Internal(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NwcInvoicePaymentOutcome {
|
enum NwcInvoicePaymentOutcome {
|
||||||
Paid,
|
Paid,
|
||||||
@@ -86,10 +21,8 @@ enum NwcInvoicePaymentOutcome {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Billing {
|
pub struct Billing {
|
||||||
nwc_url: String,
|
stripe: Stripe,
|
||||||
stripe_secret_key: String,
|
bitcoin: Bitcoin,
|
||||||
stripe_webhook_secret: String,
|
|
||||||
http: reqwest::Client,
|
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
robot: Robot,
|
robot: Robot,
|
||||||
@@ -97,20 +30,9 @@ pub struct Billing {
|
|||||||
|
|
||||||
impl Billing {
|
impl Billing {
|
||||||
pub fn new(query: Query, command: Command, robot: Robot) -> Self {
|
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 {
|
Self {
|
||||||
nwc_url,
|
stripe: Stripe::from_env(),
|
||||||
stripe_secret_key,
|
bitcoin: Bitcoin::from_env(),
|
||||||
stripe_webhook_secret,
|
|
||||||
http: reqwest::Client::new(),
|
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
robot,
|
robot,
|
||||||
@@ -181,21 +103,15 @@ impl Billing {
|
|||||||
| "complete_relay_sync"
|
| "complete_relay_sync"
|
||||||
);
|
);
|
||||||
|
|
||||||
if needs_billing_sync {
|
if needs_billing_sync
|
||||||
self.sync_relay_subscription(activity).await?;
|
&& let Some(relay) = self.query.get_relay(&activity.resource_id).await?
|
||||||
|
{
|
||||||
|
self.sync_tenant_subscription(&relay.tenant).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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
|
/// Reconciles a tenant's single Stripe subscription with the set of relays that
|
||||||
/// should be billed.
|
/// should be billed.
|
||||||
///
|
///
|
||||||
@@ -237,7 +153,7 @@ impl Billing {
|
|||||||
// Resolve the live subscription, dropping a stale reference to one that no
|
// Resolve the live subscription, dropping a stale reference to one that no
|
||||||
// longer exists or has been canceled.
|
// longer exists or has been canceled.
|
||||||
let subscription = match tenant.stripe_subscription_id.as_deref() {
|
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)
|
Some(sub)
|
||||||
if !matches!(
|
if !matches!(
|
||||||
sub["status"].as_str().unwrap_or_default(),
|
sub["status"].as_str().unwrap_or_default(),
|
||||||
@@ -260,7 +176,7 @@ impl Billing {
|
|||||||
// No relays to bill: tear everything down.
|
// No relays to bill: tear everything down.
|
||||||
if desired.is_empty() {
|
if desired.is_empty() {
|
||||||
if let Some(ref subscription_id) = tenant.stripe_subscription_id {
|
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
|
self.command
|
||||||
.clear_tenant_subscription(tenant_pubkey)
|
.clear_tenant_subscription(tenant_pubkey)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -283,7 +199,8 @@ impl Billing {
|
|||||||
match subscription {
|
match subscription {
|
||||||
None => {
|
None => {
|
||||||
let (subscription_id, items) = self
|
let (subscription_id, items) = self
|
||||||
.stripe_create_subscription(&tenant.stripe_customer_id, &desired)
|
.stripe
|
||||||
|
.create_subscription(&tenant.stripe_customer_id, &desired)
|
||||||
.await?;
|
.await?;
|
||||||
self.command
|
self.command
|
||||||
.set_tenant_subscription(tenant_pubkey, &subscription_id)
|
.set_tenant_subscription(tenant_pubkey, &subscription_id)
|
||||||
@@ -315,13 +232,15 @@ impl Billing {
|
|||||||
if quantity < current_quantity {
|
if quantity < current_quantity {
|
||||||
downgraded = true;
|
downgraded = true;
|
||||||
}
|
}
|
||||||
self.stripe_set_subscription_item_quantity(&item_id, quantity)
|
self.stripe
|
||||||
|
.set_subscription_item_quantity(&item_id, quantity)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
price_to_item.insert(price_id.clone(), item_id);
|
price_to_item.insert(price_id.clone(), item_id);
|
||||||
} else {
|
} else {
|
||||||
let item_id = self
|
let item_id = self
|
||||||
.stripe_create_subscription_item(&subscription_id, price_id, quantity)
|
.stripe
|
||||||
|
.create_subscription_item(&subscription_id, price_id, quantity)
|
||||||
.await?;
|
.await?;
|
||||||
price_to_item.insert(price_id.clone(), item_id);
|
price_to_item.insert(price_id.clone(), item_id);
|
||||||
}
|
}
|
||||||
@@ -330,7 +249,7 @@ impl Billing {
|
|||||||
// Items for plans no relay is on anymore.
|
// Items for plans no relay is on anymore.
|
||||||
for (_, (item_id, _)) in current {
|
for (_, (item_id, _)) in current {
|
||||||
downgraded = true;
|
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<()> {
|
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
|
||||||
self.verify_webhook_signature(payload, signature)?;
|
let event = self.stripe.construct_event(payload, signature)?;
|
||||||
|
|
||||||
let event: StripeEvent = serde_json::from_str(payload)?;
|
|
||||||
let obj = &event.data.object;
|
let obj = &event.data.object;
|
||||||
|
|
||||||
match event.event_type.as_str() {
|
match event.event_type.as_str() {
|
||||||
@@ -429,40 +346,6 @@ impl Billing {
|
|||||||
Ok(())
|
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(
|
async fn handle_invoice_created(
|
||||||
&self,
|
&self,
|
||||||
stripe_customer_id: &str,
|
stripe_customer_id: &str,
|
||||||
@@ -536,7 +419,8 @@ impl Billing {
|
|||||||
|
|
||||||
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
||||||
if self
|
if self
|
||||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
.stripe
|
||||||
|
.has_payment_method(&tenant.stripe_customer_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -686,7 +570,8 @@ impl Billing {
|
|||||||
|
|
||||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
||||||
match self
|
match self
|
||||||
.stripe_preview_upcoming_invoice(
|
.stripe
|
||||||
|
.preview_upcoming_invoice(
|
||||||
&tenant.stripe_customer_id,
|
&tenant.stripe_customer_id,
|
||||||
tenant.stripe_subscription_id.as_deref(),
|
tenant.stripe_subscription_id.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -743,7 +628,7 @@ impl Billing {
|
|||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> {
|
) -> 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"]
|
let customer_id = invoice["customer"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?;
|
.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<String> {
|
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
|
||||||
let short_pubkey: String = tenant_pubkey.chars().take(8).collect();
|
let short_pubkey: String = tenant_pubkey.chars().take(8).collect();
|
||||||
let nostr_name = self.robot.fetch_nostr_name(tenant_pubkey).await;
|
let display_name = self
|
||||||
let display_name = nostr_name.unwrap_or_else(|| short_pubkey.clone());
|
.robot
|
||||||
let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]);
|
.fetch_nostr_name(tenant_pubkey)
|
||||||
|
.await
|
||||||
let resp = self
|
.unwrap_or(short_pubkey);
|
||||||
.http
|
self.stripe
|
||||||
.post(format!("{STRIPE_API}/customers"))
|
.create_customer(&display_name, tenant_pubkey)
|
||||||
.bearer_auth(&self.stripe_secret_key)
|
.await
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
||||||
let resp = self
|
self.stripe.list_invoices(customer_id).await
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stripe_get_invoice(
|
pub async fn stripe_create_portal_session(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
customer_id: &str,
|
||||||
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
|
return_url: Option<&str>,
|
||||||
let resp = self
|
) -> Result<String> {
|
||||||
.http
|
self.stripe
|
||||||
.get(format!("{STRIPE_API}/invoices/{invoice_id}"))
|
.create_portal_session(customer_id, return_url)
|
||||||
.bearer_auth(&self.stripe_secret_key)
|
.await
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
||||||
let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
|
let amount_msats = self
|
||||||
|
.bitcoin
|
||||||
let system_uri: NostrWalletConnectURI = self
|
.fiat_minor_to_msats(amount_due_minor, currency)
|
||||||
.nwc_url
|
.await?;
|
||||||
.parse()
|
self.bitcoin
|
||||||
.map_err(|_| anyhow!("invalid system NWC URL"))?;
|
.system_wallet()?
|
||||||
let system_nwc = NWC::new(system_uri);
|
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||||
|
|
||||||
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)
|
|
||||||
.await
|
.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<()> {
|
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 plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
|
||||||
|
|
||||||
let invoices = self
|
let invoices = self
|
||||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
.stripe
|
||||||
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
.await?;
|
.await?;
|
||||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
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<()> {
|
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||||
if !self
|
if !self
|
||||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
.stripe
|
||||||
|
.has_payment_method(&tenant.stripe_customer_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let invoices = self
|
let invoices = self
|
||||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
.stripe
|
||||||
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
.await?;
|
.await?;
|
||||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||||
|
|
||||||
@@ -987,7 +822,7 @@ impl Billing {
|
|||||||
continue;
|
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!(
|
tracing::error!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id,
|
invoice_id,
|
||||||
@@ -999,273 +834,14 @@ impl Billing {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stripe_create_portal_session(
|
// --- Lightning / NWC orchestration ---
|
||||||
&self,
|
|
||||||
customer_id: &str,
|
|
||||||
return_url: Option<&str>,
|
|
||||||
) -> Result<String> {
|
|
||||||
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<Option<serde_json::Value>> {
|
|
||||||
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<String, i64>,
|
|
||||||
) -> Result<(String, BTreeMap<String, String>)> {
|
|
||||||
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<String> =
|
|
||||||
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<String> {
|
|
||||||
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<serde_json::Value> {
|
|
||||||
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<bool> {
|
|
||||||
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 ---
|
|
||||||
|
|
||||||
async fn mark_invoice_paid_out_of_band_after_nwc(
|
async fn mark_invoice_paid_out_of_band_after_nwc(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
) -> Result<()> {
|
) -> 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?;
|
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1303,7 +879,7 @@ impl Billing {
|
|||||||
return Ok(invoice.clone());
|
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!(
|
tracing::warn!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id,
|
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<bool> {
|
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
||||||
let system_uri = Self::parse_nwc_uri(&self.nwc_url, "system")?;
|
self.bitcoin.system_wallet()?.invoice_settled(bolt11).await
|
||||||
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<NostrWalletConnectURI> {
|
|
||||||
nwc_url
|
|
||||||
.parse::<NostrWalletConnectURI>()
|
|
||||||
.map_err(|_| anyhow!("invalid {role} NWC URL"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(
|
async fn nwc_pay_invoice(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
@@ -1357,42 +912,29 @@ impl Billing {
|
|||||||
return Ok(existing_outcome);
|
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,
|
Ok(amount_msats) => amount_msats,
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a bolt11 invoice using the system wallet (self.nwc_url)
|
let system_wallet = match self.bitcoin.system_wallet() {
|
||||||
let system_uri = match Self::parse_nwc_uri(&self.nwc_url, "system") {
|
Ok(wallet) => wallet,
|
||||||
Ok(system_uri) => system_uri,
|
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||||
};
|
};
|
||||||
let system_nwc = NWC::new(system_uri);
|
let bolt11 = match system_wallet
|
||||||
|
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||||
let make_req = MakeInvoiceRequest {
|
.await
|
||||||
amount: amount_msats,
|
{
|
||||||
description: Some("Relay subscription payment".to_string()),
|
Ok(bolt11) => bolt11,
|
||||||
description_hash: None,
|
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||||
expiry: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let invoice_response = system_nwc.make_invoice(make_req).await;
|
let tenant_wallet = match Wallet::parse(tenant_nwc_url, "tenant") {
|
||||||
|
Ok(wallet) => wallet,
|
||||||
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,
|
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1412,16 +954,8 @@ impl Billing {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tenant_nwc = NWC::new(tenant_uri);
|
match tenant_wallet.pay_invoice(bolt11).await {
|
||||||
|
Ok(()) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await {
|
||||||
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 {
|
|
||||||
Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid),
|
Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid),
|
||||||
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
||||||
"invoice {invoice_id} was charged over NWC but failed to persist paid state: {error}"
|
"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<u64> {
|
|
||||||
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 {
|
fn should_reactivate_after_payment(relay: &Relay) -> bool {
|
||||||
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
|
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
|
||||||
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
|
||||||
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<reqwest::Response> {
|
|
||||||
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::<serde_json::Value>(&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() {
|
|
||||||
"<empty response body>".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<f64> {
|
|
||||||
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::<f64>()
|
|
||||||
.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<String> {
|
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||||
@@ -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<u64> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Billing, fiat_minor_to_msats_from_quote};
|
use super::Billing;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
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]
|
#[test]
|
||||||
fn reactivates_only_delinquent_paid_relays_after_payment() {
|
fn reactivates_only_delinquent_paid_relays_after_payment() {
|
||||||
let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic");
|
let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic");
|
||||||
@@ -1846,7 +1245,7 @@ mod tests {
|
|||||||
Robot::test_stub(),
|
Robot::test_stub(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
assert_eq!(billing.stripe.secret_key, "sk_test_dummy");
|
||||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
assert_eq!(billing.stripe.webhook_secret, "whsec_test_dummy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
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<u64> {
|
||||||
|
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<Self> {
|
||||||
|
let uri = uri
|
||||||
|
.parse::<NostrWalletConnectURI>()
|
||||||
|
.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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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<f64> {
|
||||||
|
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::<f64>()
|
||||||
|
.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<u64> {
|
||||||
|
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<u8> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod bitcoin;
|
||||||
pub mod cipher;
|
pub mod cipher;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
@@ -7,3 +8,4 @@ pub mod models;
|
|||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod robot;
|
pub mod robot;
|
||||||
|
pub mod stripe;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod billing;
|
mod billing;
|
||||||
|
mod bitcoin;
|
||||||
mod cipher;
|
mod cipher;
|
||||||
mod command;
|
mod command;
|
||||||
mod infra;
|
mod infra;
|
||||||
@@ -7,6 +8,7 @@ mod models;
|
|||||||
mod pool;
|
mod pool;
|
||||||
mod query;
|
mod query;
|
||||||
mod robot;
|
mod robot;
|
||||||
|
mod stripe;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|||||||
@@ -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<Sha256>;
|
||||||
|
|
||||||
|
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<anyhow::Error> for InvoiceLookupError {
|
||||||
|
fn from(value: anyhow::Error) -> Self {
|
||||||
|
Self::Internal(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> 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<String> {
|
||||||
|
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<Option<serde_json::Value>> {
|
||||||
|
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<String, i64>,
|
||||||
|
) -> Result<(String, BTreeMap<String, String>)> {
|
||||||
|
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<String> =
|
||||||
|
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<String> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value, InvoiceLookupError> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<bool> {
|
||||||
|
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<String> {
|
||||||
|
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<Event> {
|
||||||
|
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<reqwest::Response> {
|
||||||
|
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::<serde_json::Value>(&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() {
|
||||||
|
"<empty response body>".to_string()
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"Stripe API request to {url} failed with status {status}: {detail}"
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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<serde_json::Value> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user