# `pub struct Billing` Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment). 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`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`. Members: - `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`) - `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`) - `query: Query` - `command: Command` - `env: Env` - used to decrypt a tenant's stored `nwc_url` ## `pub fn new(query: Query, command: Command, env: &Env) -> Self` - Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)` - Panics if `ROBOT_WALLET` is not a valid NWC URL ## `pub async fn start(self)` - Subscribes to `command.notify.subscribe()` - Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop - Loops on `rx.recv()`, calling `handle_activity` for each `Activity` - On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits ## `async fn reconcile_subscriptions(&self, source: &str)` - Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors ## `async fn handle_activity(&self, activity: &Activity)` - On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription` ## `async fn reconcile_subscription(&self, tenant: &Tenant)` 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. 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**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay. 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 the webhook handler attempts payment (see `spec/api.md`). - Build the desired state via `get_quantity_by_price_id`. - **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return. - Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items. ## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result>` - For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map. ## `async fn get_subscription(&self, tenant: &Tenant) -> Result>` - If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s) - If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None` ## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result` - Returns the existing subscription if there is one - Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription` ## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)` - If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription` ## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)` - For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item - Delete any existing item whose price no longer appears in the desired state ## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result` - Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired - Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid) ## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>` Pays a Lightning invoice from the tenant's own wallet. - Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet` - Pay `invoice.bolt11` from the tenant wallet - On success, `settle_invoice(..., "nwc")` - On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error ## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result` Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid. - If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged - If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s - On any settlement-lookup failure, log and return the invoice unchanged ## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)` - `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins) - `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent) - `command.clear_tenant_nwc_error(tenant_pubkey)`