11 KiB
pub struct Billing
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
It owns the domain logic only: Stripe REST calls go through Stripe (see spec/stripe.md), NWC wallet operations through Wallet (see spec/wallet.md), and fiat → msats conversion through bitcoin (see spec/bitcoin.md).
Members:
stripe: Stripe- Stripe REST wrapper (seespec/stripe.md)wallet: Wallet- the system NWC wallet, used to issue and look up bolt11 invoices (seespec/wallet.md)query: Querycommand: Commandrobot: Robot
pub fn new(query: Query, command: Command, robot: Robot) -> Self
- Builds
stripeviaStripe::from_env()andwalletfromNWC_URL - Panics if
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, orNWC_URLis missing or malformed
pub fn start(&self)
- Subscribes to
command.notify.subscribe() - On
create_relay,update_relay,activate_relay,deactivate_relay,fail_relay_sync, andcomplete_relay_sync: resolve the relay named by the activity (skip if it no longer exists) and reconcile its tenant viasync_tenant_subscription. - The startup/lagged reconcile loop calls
sync_tenant_subscriptionfor every tenant.
fn sync_tenant_subscription(&self, tenant_pubkey: &str)
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as one subscription item per plan (price), with quantity equal to the number of the tenant's active relays on that plan. Every such relay's stripe_subscription_item_id points at the shared item for its plan; relays that aren't billed (free, inactive, delinquent) have it cleared.
Stripe uses pay-in-advance by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The invoice.created webhook fires shortly after and handle_invoice_created attempts payment.
- Fetch the tenant and its relays. Build the desired state: for each
activerelay on a paid plan with a non-emptystripe_price_id, count relays per price. - Resolve the live subscription: if the tenant has a
stripe_subscription_id, fetch it. If Stripe no longer knows about it, or its status iscanceled/incomplete_expired, callcommand.clear_tenant_subscriptionand treat the tenant as having no subscription. - No relays to bill (desired state empty): if the tenant still has a
stripe_subscription_id, cancel the Stripe subscription and callcommand.clear_tenant_subscription. Clearstripe_subscription_item_idon every relay that has one. Return. - No subscription yet: create a Stripe subscription for the customer with
collection_method: "charge_automatically"and one item per(price, quantity). Save the subscription ID viacommand.set_tenant_subscription. - Existing subscription: fetch its current items. For each desired
(price, quantity): update the matching item's quantity if it differs, otherwise create the item. Delete any item whose price no longer appears in the desired state. - Point relays at items: for each relay, set
stripe_subscription_item_id(viacommand.set_relay_subscription_item) to the shared item for its plan, or clear it (viacommand.delete_relay_subscription_item) if the relay is not billed. - Downgrade validation: if any quantity decreased or any item was removed, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>
- Verify and parse the event via
self.stripe.construct_event(payload, signature)(checks theStripe-SignatureHMAC and timestamp tolerance — seespec/stripe.md) - Dispatch by type:
invoice.created->self.handle_invoice_createdinvoice.paid->self.handle_invoice_paidinvoice.payment_failed->self.handle_invoice_payment_failedinvoice.overdue->self.handle_invoice_overduecustomer.subscription.updated->self.handle_subscription_updatedcustomer.subscription.deleted->self.handle_subscription_deletedpayment_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>
- Delegates to
stripe.list_invoices— returns thedataarray of the customer's invoices
pub async fn stripe_create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>
- Delegates to
stripe.create_portal_session— returns the Customer Portal session URL
pub async fn get_invoice_with_tenant(&self, invoice_id: &str) -> Result<(Value, Tenant), InvoiceLookupError>
- Fetches the invoice via
stripe.get_invoice(a Stripe 4xx surfaces asInvoiceLookupError::StripeClient) - Looks up the tenant by the invoice's
customerfield; 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 (self.wallet.is_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 withcommand.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_to_msats(fetches the live BTC spot price — seespec/bitcoin.md) - Issues a bolt11 invoice for that amount on the system NWC wallet (
self.wallet.make_invoice(...)) - Returns the bolt11 invoice string
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_urlis empty, return early. - List all Stripe invoices for
tenant.stripe_customer_idviastripe.list_invoices. - For each invoice with
status == "open"andamount_due > 0:- Attempt NWC payment via
nwc_pay_invoice. - On success: mark the Stripe invoice paid out of band (
stripe.pay_invoice_out_of_band) and callcommand.clear_tenant_nwc_error. - On failure: call
command.set_tenant_nwc_errorand log the error; continue to the next invoice.
- Attempt NWC payment via
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 (
stripe.has_payment_method), return early. - List all Stripe invoices for
tenant.stripe_customer_idviastripe.list_invoices. - For each invoice with
status == "open"andamount_due > 0:- Call
stripe.pay_invoiceto retry collection using the card on file. - Log and continue on failures.
- Call
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:
- NWC auto-pay: If the tenant has a
nwc_url, runnwc_pay_invoice(decrypting the tenant's storednwc_urlfirst):- The system wallet (
self.wallet) issues a bolt11 invoice for the fiat amount; the tenant's wallet (Wallet::from_urlof the decrypted URL) pays it. Apendingrow ininvoice_nwc_paymentguards against double-charging across retries. - If payment succeeds: mark the Stripe invoice paid out of band (
stripe.pay_invoice_out_of_band) and clearnwc_errorviacommand.clear_tenant_nwc_error. Done. - If it fails before any charge could have gone out: set
nwc_erroron the tenant viacommand.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_errorand return the error without falling through — a human must reconcile before any retry.
- The system wallet (
- 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.
- Manual payment: If neither NWC nor card is available, send a DM via
robot.send_dmnotifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with amount_due of 0.
fn handle_invoice_paid(&self, invoice: &Invoice)
- Look up tenant by
stripe_customer_id - If tenant has
past_due_atset:- Clear
past_due_atviacommand.clear_tenant_past_due - Find all
delinquentrelays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment) - Reactivate each one via
command.activate_relay
- Clear
fn handle_invoice_payment_failed(&self, invoice: &Invoice)
- Look up tenant by
stripe_customer_id - If tenant does not already have
past_due_atset:- Set
past_due_atto now viacommand.set_tenant_past_due - Send a DM via
robot.send_dmnotifying the tenant that their payment has failed and their relays may be deactivated if not resolved.
- Set
fn handle_invoice_overdue(&self, invoice: &Invoice)
- Look up tenant by
stripe_customer_id - Mark all active relays on paid plans as delinquent via
command.mark_relay_delinquent(sets status todelinquent, distinct from user-initiateddeactivate_relay) - Send a DM via
robot.send_dmnotifying the tenant that their paid relays have been deactivated due to non-payment
fn handle_subscription_updated(&self, subscription: &Subscription)
- Look up tenant by
stripe_customer_id - If subscription status is
canceledorunpaid:- Clear
stripe_subscription_idviacommand.clear_tenant_subscription - Mark all active paid relays as delinquent via
command.mark_relay_delinquent
- Clear
fn handle_subscription_deleted(&self, subscription: &Subscription)
- Look up tenant by
stripe_customer_id - Clear
stripe_subscription_idviacommand.clear_tenant_subscription
fn handle_payment_method_attached(&self, stripe_customer_id: &str)
- Look up tenant by
stripe_customer_id - Call
pay_outstanding_card_invoicesso invoices that were due before card setup are retried immediately