Rework billing
This commit is contained in:
+50
-4
@@ -47,8 +47,8 @@ Notes:
|
||||
- Serves `GET /identity`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- If a tenant for the identity doesn't exist:
|
||||
- Call the Stripe API to create a new customer and subscription
|
||||
- Create a new tenant using `command.create_tenant` with payload and stripe info
|
||||
- Call the Stripe API to create a new customer
|
||||
- Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
|
||||
- Return `data` is an `Identity` struct
|
||||
|
||||
--- Tenant routes
|
||||
@@ -122,7 +122,7 @@ Notes:
|
||||
- Serves `POST /relays/:id/deactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
|
||||
- Call `billing.deactivate_relay`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
## `async fn reactivate_relay(...) -> Response`
|
||||
@@ -130,9 +130,55 @@ Notes:
|
||||
- Serves `POST /relays/:id/reactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already active, return a `400` with `code=relay-is-active`
|
||||
- Call `billing.reactivate_relay`
|
||||
- Call `command.activate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
--- Invoice routes
|
||||
|
||||
## `async fn list_tenant_invoices(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/invoices`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey, fetches invoices from Stripe API using `stripe_customer_id`
|
||||
- Return `data` is a list of Stripe invoice objects: `{ id, status, amount_due, currency, hosted_invoice_url, period_start, period_end }`
|
||||
|
||||
## `async fn get_invoice(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- Return `data` is a single Stripe invoice object
|
||||
- If invoice does not exist, return `404` with `code=not-found`
|
||||
|
||||
## `async fn get_invoice_bolt11(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id/bolt11`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- If invoice `status` is not `open`, return `400` with `code=invoice-not-open`
|
||||
- Creates a bolt11 Lightning invoice for the invoice's `amount_due` using `billing.create_bolt11(amount_due)`
|
||||
- Return `data` is `{ bolt11 }`
|
||||
|
||||
--- Stripe session route
|
||||
|
||||
## `async fn create_stripe_session(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/stripe/session`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey
|
||||
- Creates a Stripe Customer Portal session for the tenant's `stripe_customer_id`
|
||||
- Return `data` is `{ url }` — the portal session URL
|
||||
|
||||
--- Stripe webhook route
|
||||
|
||||
## `async fn stripe_webhook(...) -> Response`
|
||||
|
||||
- Serves `POST /stripe/webhook`
|
||||
- No NIP-98 authentication — uses Stripe signature verification instead
|
||||
- Reads raw request body and `Stripe-Signature` header
|
||||
- Calls `billing.handle_webhook(payload, signature)`
|
||||
- Returns `200` on success, `400` on signature verification failure
|
||||
|
||||
--- Utilities
|
||||
|
||||
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
|
||||
|
||||
+95
-9
@@ -1,10 +1,11 @@
|
||||
# `pub struct Billing`
|
||||
|
||||
Billing encapsulates logic related to synchronizing state with Stripe.
|
||||
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
|
||||
|
||||
Members:
|
||||
|
||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices
|
||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `robot: Robot`
|
||||
@@ -15,12 +16,97 @@ Members:
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
- Subscribes to `command.notify.notified`
|
||||
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription_item`.
|
||||
- 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_item(&self, activity: &Activity)`
|
||||
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
|
||||
|
||||
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
|
||||
|
||||
- Fetch the relay and tenant associated with the `activity`
|
||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay is `active` and on a paid plan**:
|
||||
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
|
||||
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
|
||||
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
|
||||
|
||||
## `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:
|
||||
- `invoice.created` -> `self.handle_invoice_created`
|
||||
- `invoice.paid` -> `self.handle_invoice_paid`
|
||||
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
|
||||
- `invoice.overdue` -> `self.handle_invoice_overdue`
|
||||
- `customer.subscription.updated` -> `self.handle_subscription_updated`
|
||||
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
|
||||
- Unknown event types are ignored (return Ok)
|
||||
|
||||
## `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
|
||||
|
||||
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
|
||||
|
||||
- Fetches a single invoice from Stripe API by ID
|
||||
- Returns the full Stripe invoice object
|
||||
|
||||
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
|
||||
|
||||
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
|
||||
- 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
|
||||
|
||||
## `fn handle_invoice_created(&self, invoice: &Invoice)`
|
||||
|
||||
Attempts to pay a new subscription invoice. 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.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
|
||||
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.
|
||||
|
||||
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_at` set:
|
||||
- Clear `past_due_at` via `command.clear_tenant_past_due`
|
||||
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
|
||||
- Reactivate each one via `command.activate_relay`
|
||||
|
||||
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- If tenant does not already have `past_due_at` set:
|
||||
- Set `past_due_at` to now via `command.set_tenant_past_due`
|
||||
- Send a DM via `robot.send_dm` notifying the tenant that their payment has failed and their relays may be deactivated if not resolved.
|
||||
|
||||
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Deactivate all active relays on paid plans via `command.deactivate_relay`
|
||||
- Send a DM via `robot.send_dm` notifying 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 `canceled` or `unpaid`:
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
|
||||
|
||||
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
|
||||
- Fetch the relay associated with the `activity`
|
||||
- If the relay has `sync_error`, `synced` is false, `plan` is `free`, or `status` is `inactive`, delete the relay's subscription item using the Stripe api, and clear it with `command.delete_relay_subscription_item`.
|
||||
- Otherwise, create/update the relay's subscription item to the appropriate Stripe price using the Stripe api and set it with `command.set_relay_subscription_item`.
|
||||
- This method should be idempotent
|
||||
|
||||
@@ -69,3 +69,33 @@ Notes:
|
||||
|
||||
- Sets `stripe_subscription_item_id`
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at` to the current timestamp
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
@@ -39,7 +39,7 @@ A plan represents a rate charged for relays at a given feature/usage limit. Plan
|
||||
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
|
||||
- `blossom` - whether blossom media hosting is available on this plan
|
||||
- `livekit` - whether livekit audio/video calls are available on this plan
|
||||
- `stripe_price_id` - the identifier of the price in Stripe
|
||||
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
|
||||
|
||||
There are three plans available:
|
||||
|
||||
@@ -52,10 +52,12 @@ There are three plans available:
|
||||
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
|
||||
|
||||
- `pubkey` is the nostr public key identifying the tenant
|
||||
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system
|
||||
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf
|
||||
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
|
||||
- `created_at` unix timestamp identifying tenant creation time
|
||||
- `stripe_customer_id` a string identifying the associated stripe customer
|
||||
- `stripe_subscription_id` a string identifying the associated stripe subscription
|
||||
- `stripe_subscription_id` (nullable) a string identifying the associated stripe subscription. Created when the first paid (non-free) relay is activated, deleted when the last paid relay is removed or deactivated. Free-only tenants have no subscription.
|
||||
- `past_due_at` (nullable) unix timestamp when the tenant's subscription became past due. Set on payment failure, cleared on payment success.
|
||||
|
||||
# Relay
|
||||
|
||||
@@ -66,7 +68,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
|
||||
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
|
||||
- `subdomain` - the relay's subdomain
|
||||
- `plan` - the relay's plan
|
||||
- `stripe_subscription_item_id` - the Stripe subscription item id.
|
||||
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
|
||||
- `status` - `active|inactive`. Only `active` relays count toward billing.
|
||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||
|
||||
@@ -35,6 +35,14 @@ Members:
|
||||
|
||||
- Returns matching relay
|
||||
|
||||
## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
|
||||
|
||||
- Returns the tenant matching the given `stripe_customer_id`
|
||||
|
||||
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
|
||||
|
||||
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
|
||||
|
||||
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
|
||||
|
||||
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
|
||||
|
||||
Reference in New Issue
Block a user