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.
|
||||
|
||||
It owns the domain logic only: every raw Stripe REST call goes through the `Stripe` wrapper (see `spec/stripe.md`), and every Lightning (NWC) wallet operation and the fiat-minor-units → millisatoshi conversion go through the helpers in `spec/bitcoin.md`.
|
||||
|
||||
Members:
|
||||
|
||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
||||
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||
- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`
|
||||
- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`)
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `robot: Robot`
|
||||
|
||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
||||
- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()`
|
||||
- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional)
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
- Subscribes to `command.notify.subscribe()`
|
||||
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`.
|
||||
|
||||
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
|
||||
|
||||
Resolves the relay associated with `activity` and reconciles that relay's tenant via `sync_tenant_subscription`. The startup/lagged reconcile loop calls `sync_tenant_subscription` for every tenant.
|
||||
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`: resolve the relay named by the activity (skip if it no longer exists) and reconcile its tenant via `sync_tenant_subscription`.
|
||||
- The startup/lagged reconcile loop calls `sync_tenant_subscription` for every tenant.
|
||||
|
||||
## `fn sync_tenant_subscription(&self, tenant_pubkey: &str)`
|
||||
|
||||
@@ -44,8 +41,8 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
|
||||
|
||||
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
|
||||
|
||||
- Verify the webhook signature using `self.stripe_webhook_secret`
|
||||
- Parse the event and dispatch by type:
|
||||
- Verify and parse the event via `self.stripe.construct_event(payload, signature)` (checks the `Stripe-Signature` HMAC and timestamp tolerance — see `spec/stripe.md`)
|
||||
- Dispatch by type:
|
||||
- `invoice.created` -> `self.handle_invoice_created`
|
||||
- `invoice.paid` -> `self.handle_invoice_paid`
|
||||
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
|
||||
@@ -55,56 +52,69 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
|
||||
- `payment_method.attached` -> `self.handle_payment_method_attached`
|
||||
- Unknown event types are ignored (return Ok)
|
||||
|
||||
## `pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<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>`
|
||||
|
||||
- Fetches invoices from Stripe API for the given customer
|
||||
- Returns the `data` array from the Stripe response
|
||||
- Delegates to `stripe.list_invoices` — returns the `data` array of the customer's invoices
|
||||
|
||||
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<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
|
||||
- Returns the full Stripe invoice object
|
||||
- Delegates to `stripe.create_portal_session` — returns the Customer Portal session URL
|
||||
|
||||
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<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
|
||||
|
||||
## `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<()>`
|
||||
|
||||
Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
|
||||
|
||||
- If `tenant.nwc_url` is empty, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Attempt NWC payment via `nwc_pay_invoice`.
|
||||
- On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`.
|
||||
- On success: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and call `command.clear_tenant_nwc_error`.
|
||||
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
|
||||
|
||||
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
|
||||
|
||||
- If tenant has no card payment method, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id`.
|
||||
- If tenant has no card payment method (`stripe.has_payment_method`), return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file.
|
||||
- Call `stripe.pay_invoice` to retry collection using the card on file.
|
||||
- Log and continue on failures.
|
||||
|
||||
## `fn handle_invoice_created(&self, invoice: &Invoice)`
|
||||
|
||||
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
|
||||
|
||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
|
||||
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
|
||||
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
|
||||
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
|
||||
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
|
||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first):
|
||||
- The system wallet (`bitcoin.system_wallet()`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::parse` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries.
|
||||
- If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done.
|
||||
- If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM).
|
||||
- If it fails after a charge may have gone out (needs reconciliation): set `nwc_error` and return the error without falling through — a human must reconcile before any retry.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt.
|
||||
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user