diff --git a/backend/spec/api.md b/backend/spec/api.md index 92be87e..6b5a11d 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -1,234 +1,259 @@ # `pub struct Api` -Api manages the HTTP interface for the application +Api owns the HTTP interface: the shared application state, the router, NIP-98 authentication, and the authorization helpers. The route handlers themselves live in `crate::routes` (`routes/identity.rs`, `plans.rs`, `tenants.rs`, `relays.rs`, `invoices.rs`, `stripe.rs`) and use the response helpers in `spec/web.md`. Members: -- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST` -- `admins: Vec` - a list of admin pubkeys from `ADMINS` +- `env: Env` - configuration (see `spec/env.md`); supplies the NIP-98 host check, admin pubkeys, encryption, etc. - `query: Query` - `command: Command` - `billing: Billing` +- `stripe: Stripe` +- `robot: Robot` - `infra: Infra` Notes: -- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request -- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant` -- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code. -- Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message. +- Authentication is done using NIP-98, comparing the event's `u` tag to `env.server_host`, not the incoming request URL. +- The shared `Api` is wrapped in an `Arc` and handed to every handler as `State>`. +- A handler that requires an authenticated caller takes an `AuthedPubkey` extractor; handlers that omit it are anonymous. +- Each handler is responsible for authorization using `require_admin` or `require_admin_or_tenant`. +- Successful responses are `{ data, code: "ok" }`; error responses are `{ error, code }`, both with an appropriate HTTP status (see `spec/web.md`). -## `pub fn new() -> Self` +## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self` -- Reads environment and populates members +- Stores the services and a clone of `env` -## `pub fn router(&self) -> Result<()>` +## `pub fn router(self) -> Router` -- Returns an `axum::Router` +- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes ---- Plan routes +## `pub fn is_admin(&self, pubkey: &str) -> bool` -## `async fn list_plans(...) -> Response` +- Whether `pubkey` is in `env.server_admin_pubkeys` -- Serves `GET /plans` -- No authentication required -- Return `data` is a list of plan structs from `Query::list_plans` +## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>` -## `async fn get_plan(...) -> Response` +- `Ok` if `authorized_pubkey` is an admin, otherwise a `403` -- Serves `GET /plans/:id` -- No authentication required -- Return `data` is a single plan struct matching `id` -- If plan does not exist, return `404` with `code=not-found` +## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>` ---- Identity routes +- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403` -## `async fn get_identity(...) -> Response` +## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result` -- Serves `GET /identity` -- Authorizes anyone, but must be authorized -- Side-effect-free: returns `{ pubkey, is_admin }` only -- Clients must call `POST /tenants` before any tenant-scoped write -- Return `data` is an `Identity` struct +- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error ---- Tenant routes +## `pub async fn get_relay_or_404(&self, id: &str) -> Result` -## `async fn list_tenants(...) -> Response` +- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error -- Serves `GET /tenants` -- Authorizes admin only -- Return `data` is a list of `TenantResponse` structs (contains `nwc_is_set: bool` instead of `nwc_url`) +# Authentication -## `async fn create_tenant(...) -> Response` +## `pub struct AuthedPubkey(pub String)` -- Serves `POST /tenants` -- Authorizes anyone, but must be authorized -- No request body; target pubkey is derived from NIP-98 auth -- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB -- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added. -- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant -- If Stripe customer creation fails, return `code=stripe-customer-create-failed` -- Always returns `200` (create-or-get is uniform) -- Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`) +An axum extractor (`FromRequestParts`) that authenticates a request via NIP-98 and yields the signer's pubkey. Adding it to a handler signature is what enforces "must be authenticated"; on failure the request is rejected with a `401`. -## `async fn get_tenant(...) -> Response` +## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result` / `fn decode_nip98_pubkey(&self, headers) -> Result` -- Serves `GET /tenants/:pubkey` -- Authorizes admin or matching tenant -- Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`) - -## `async fn update_tenant(...) -> Response` - -- Serves `PUT /tenants/:pubkey` -- Authorizes admin or matching tenant -- Accepts `nwc_url` in the request body; encrypts it before storage using `cipher::encrypt` -- Updates tenant using `command.update_tenant` -- Return `data` is the updated `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`) - -## `async fn list_tenant_relays(...) -> Response` - -- Serves `GET /tenants/:pubkey/relays` -- Authorizes admin or matching tenant -- Return `data` is a list of relay structs from `query.list_relays_for_tenant` - ---- Relay routes - -## `async fn list_relays(...) -> Response` - -- Serves `GET /relays` -- Authorizes admin only -- Return `data` is a list of relay structs from `query.list_relays` - -## `async fn get_relay(...) -> Response` - -- Serves `GET /relays/:id` -- Authorizes admin or relay owner -- Return `data` is a single relay struct from `query.get_relay` - -## `async fn list_relay_members(...) -> Response` - -- Serves `GET /relays/:id/members` -- Authorizes admin or relay owner -- For unsynced relays, returns an empty member list without calling zooid -- For synced relays, proxies the member list from zooid via `infra` -- Return `data` is `{ members }` - -## `async fn create_relay(...) -> Response` - -- Serves `POST /relays` -- Authorizes admin or matching tenant pubkey in request body -- Validates/prepares the relay data to be saved using `prepare_relay` -- Creates a new relay using `command.create_relay` -- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists` -- Return `data` is a single relay struct. Use HTTP `201`. - -## `async fn update_relay(...) -> Response` - -- Serves `PUT /relays/:id` -- Authorizes admin or relay owner -- Validates/prepares the relay data to be saved using `prepare_relay` -- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded` -- Updates the given relay using `command.update_relay` -- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists` -- Return `data` is a single relay struct. - -## `async fn list_relay_activity(...) -> Response` - -- Serves `GET /relays/:id/activity` -- Authorizes admin or relay owner -- Get activity from `query.list_activity_for_resource` -- Return `data` is `{activity}` - -## `async fn deactivate_relay(...) -> Response` - -- Serves `POST /relays/:id/deactivate` -- Authorizes admin or relay owner -- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive` -- Call `command.deactivate_relay` -- Return `data` is empty - -## `async fn reactivate_relay(...) -> Response` - -- Serves `POST /relays/:id/reactivate` -- Authorizes admin or relay owner -- If relay is already active, return a `400` with `code=relay-is-active` -- 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_lightning_invoice(...) -> 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 -- Startup requires non-empty `STRIPE_WEBHOOK_SECRET` - ---- Utilities - -## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result` - -- Parses `Authorization` header -- Validates event kind (`27235`) and signature using `nostr_sdk` -- Validates event `u` contains configured `HOST` -- Intentionally does **not** enforce exact request URL/method/query matching -- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache +- Parses the `Authorization` header, which must use the `Nostr ` scheme followed by a base64-encoded NIP-98 event +- Decodes and parses the event, requires kind `27235` (`HttpAuth`), and verifies its signature +- Requires the event's `u` tag to contain `env.server_host` (skipped when `server_host` is empty) +- Intentionally does **not** enforce exact request URL/method/query matching, and does **not** validate the `payload` tag/hash, `created_at` freshness window, or a replay nonce/cache - This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client -- Returns pubkey if header all checks pass +- Returns the signer pubkey (hex) when all checks pass; any failure surfaces as a `401` -Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible. +Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible. -## `require_admin(&self, authorized_pubkey: &str)` +# Routes -- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error +Handlers take `State>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`. -## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)` +--- Identity -- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey` +## `get_identity` — `GET /identity` -## `prepare_relay(&self, relay: Relay) -> anyhow::Result` +- Authenticated (any signer) +- Side-effect-free: returns `{ pubkey, is_admin }` +- Clients must call `POST /tenants` before any tenant-scoped write -- Validate `subdomain` -- Validate that `plan` matches a known plan id from `Query::list_plans` -- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature` -- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature` -- Populate `schema` if not already set -- Populate missing fields using reasonable defaults +--- Plans + +## `list_plans` — `GET /plans` + +- No authentication required +- `data` is the list of plans from `query.list_plans` + +## `get_plan` — `GET /plans/:id` + +- No authentication required +- `data` is the plan matching `id`; `404` `not-found` if it doesn't exist + +--- Tenants + +## `list_tenants` — `GET /tenants` + +- Admin only +- `data` is a list of `TenantResponse` (exposes `nwc_is_set: bool` instead of `nwc_url`) + +## `create_tenant` — `POST /tenants` + +- Authenticated (any signer); the target pubkey is the auth pubkey, no request body +- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB +- Otherwise resolve a display name via `robot.fetch_nostr_name` (falling back to the first 8 chars of the pubkey), create a Stripe customer via `stripe.create_customer`, and create the tenant. No subscription is created yet — that happens when the first paid relay is added. +- On a unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant +- Always returns `200`; `data` is a `TenantResponse` + +## `get_tenant` — `GET /tenants/:pubkey` + +- Admin or matching tenant +- `data` is a `TenantResponse` + +## `update_tenant` — `PUT /tenants/:pubkey` + +- Admin or matching tenant +- Accepts an optional `nwc_url`: an empty string clears it, otherwise it is encrypted at rest via `env.encrypt` +- Updates the tenant via `command.update_tenant` +- `data` is the updated `TenantResponse` + +## `list_tenant_relays` — `GET /tenants/:pubkey/relays` + +- Admin or matching tenant +- `data` is the tenant's relays from `query.list_relays_for_tenant` + +--- Relays + +## `list_relays` — `GET /relays` + +- Admin only +- `data` is all relays from `query.list_relays` + +## `get_relay` — `GET /relays/:id` + +- `404` `not-found` if the relay doesn't exist; then admin or relay owner +- `data` is the relay + +## `list_relay_members` — `GET /relays/:id/members` + +- Admin or relay owner +- For unsynced relays (`synced = 0`), returns an empty member list without calling zooid +- For synced relays, proxies the member list from zooid via `infra.list_relay_members` +- `data` is `{ members }` + +## `create_relay` — `POST /relays` + +- Admin or the `tenant` pubkey in the request body +- Generates the relay `id`/`schema`, validates and normalizes the relay via `prepare_relay`, and creates it via `command.create_relay` +- Duplicate subdomain → `422` `subdomain-exists` +- `data` is the relay; HTTP `201` + +## `update_relay` — `PUT /relays/:id` + +- `404` if missing; then admin or relay owner +- Applies the provided optional fields, then validates/normalizes via `prepare_relay` +- If the plan changes to one with a finite member limit and the current member count exceeds it, return `422` `member-limit-exceeded` +- Updates via `command.update_relay`; duplicate subdomain → `422` `subdomain-exists` +- `data` is the relay + +## `list_relay_activity` — `GET /relays/:id/activity` + +- `404` if missing; then admin or relay owner +- `data` is `{ activity }` from `query.list_activity_for_resource` + +## `deactivate_relay` — `POST /relays/:id/deactivate` + +- `404` if missing; then admin or relay owner +- If status is `delinquent`, return `400` `relay-is-delinquent`; if already `inactive`, return `400` `relay-is-inactive` +- Otherwise `command.deactivate_relay`; `data` is empty + +## `reactivate_relay` — `POST /relays/:id/reactivate` + +- `404` if missing; then admin or relay owner +- If status is `delinquent`, return `400` `relay-is-delinquent` (a delinquent relay must be resolved through payment, not reactivated by the user); if already `active`, return `400` `relay-is-active` +- Otherwise `command.activate_relay`; `data` is empty + +--- Invoices + +## `list_tenant_invoices` — `GET /tenants/:pubkey/invoices` + +- Admin or matching tenant +- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id` +- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency }` + +## `get_invoice` — `GET /invoices/:id` + +- Fetches the invoice from Stripe (`404` `not-found` if it doesn't exist) +- Looks up the tenant by the invoice's `customer` (`404` if none), then authorizes admin or matching tenant +- Runs `billing.reconcile_invoice` (marks it paid if its bolt11 already settled out of band) +- `data` is the (possibly refreshed) `StripeInvoice` + +## `get_lightning_invoice` — `GET /invoices/:id/bolt11` + +- Fetches the invoice from Stripe (`404` if it doesn't exist) and the tenant by `customer` (`404` if none), then authorizes admin or matching tenant +- Runs `billing.reconcile_invoice`, then `billing.ensure_lightning_invoice` to get or (re)issue the bolt11 for the invoice's `amount_due`/`currency` +- `data` is the `LightningInvoice` (including its `bolt11`) + +--- Stripe portal + +## `create_stripe_session` — `GET /tenants/:pubkey/stripe/session` + +- Admin or matching tenant; accepts an optional `return_url` query parameter +- Looks up the tenant and creates a Stripe Customer Portal session for its `stripe_customer_id` +- `data` is `{ url }` — the portal session URL + +--- Stripe webhook + +## `stripe_webhook` — `POST /stripe/webhook` + +- No NIP-98 authentication — verified via the `Stripe-Signature` header over the raw body +- Reads the raw body and signature, verifies/parses the event via `stripe.get_webhook_event`, and dispatches to the handlers below +- Returns `200` on success, `400` (`webhook-error`) on verification/parse failure + +# Webhook event handlers + +Implemented in `routes/stripe.rs`. They translate verified Stripe events into domain actions, looking the tenant up by `stripe_customer_id` and ignoring events whose customer doesn't map to a tenant. Unknown event types are ignored. + +## `invoice.created` + +Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fires immediately when a paid relay is added or a plan is upgraded). Skips `amount_due` of 0. Ensures a `LightningInvoice` exists, then in priority order: + +1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through. +2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt. +3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment. + +## `invoice.paid` + +- If the tenant has `past_due_at` set, clear it (`command.clear_tenant_past_due`) and reactivate each `delinquent` relay on a paid plan via `command.activate_relay` + +## `invoice.payment_failed` + +- If the tenant doesn't already have `past_due_at` set, set it (`command.set_tenant_past_due`) and DM the tenant that payment failed and their relays may be deactivated if unresolved + +## `invoice.overdue` + +- Mark every `active` relay on a paid plan `delinquent` (`command.mark_relay_delinquent`) and DM the tenant that their paid relays were deactivated for non-payment + +## `customer.subscription.updated` + +- If the subscription status is `canceled` or `unpaid`, clear `stripe_subscription_id` (`command.clear_tenant_subscription`) and mark every `active` paid relay `delinquent` + +## `customer.subscription.deleted` + +- Clear `stripe_subscription_id` (`command.clear_tenant_subscription`) + +## `payment_method.attached` + +- Retry Stripe collection (`stripe.pay_invoice`) for every `open` invoice with `amount_due > 0`, so invoices that were due before the card was added are charged immediately + +# Helpers + +## `prepare_relay(api: &Api, relay: Relay) -> Result` + +- Validates `subdomain` against the allowed pattern and a reserved list (`api`, `admin`, `internal`) → `422` `invalid-subdomain` +- Validates that `plan` matches a known plan → `422` `invalid-plan` +- If the relay enables `blossom`/`livekit` but the selected plan doesn't include it → `422` `premium-feature` +- Normalizes the boolean relay flags to sane defaults + +# `TenantResponse` + +The tenant shape returned by tenant endpoints. Same as `Tenant` but replaces `nwc_url` with `nwc_is_set: bool` (true when a `nwc_url` is stored) and never exposes the stored URL: `{ pubkey, nwc_is_set, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at }`. diff --git a/backend/spec/billing.md b/backend/spec/billing.md index b5e703b..0882835 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -1,8 +1,8 @@ # `pub struct Billing` -Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle. +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`). +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: @@ -10,149 +10,87 @@ Members: - `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`) - `query: Query` - `command: Command` -- `robot: Robot` +- `env: Env` - used to decrypt a tenant's stored `nwc_url` -## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` +## `pub fn new(query: Query, command: Command, env: &Env) -> Self` -- Builds `stripe` via `Stripe::from_env()` and `wallet` from `NWC_URL` -- Panics if `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, or `NWC_URL` is missing or malformed +- 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 fn start(&self)` +## `pub async fn start(self)` - Subscribes to `command.notify.subscribe()` -- 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. +- 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 -## `fn sync_tenant_subscription(&self, tenant_pubkey: &str)` +## `async fn reconcile_subscriptions(&self, source: &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. +- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors -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 we don't persist it on the relay. +## `async fn handle_activity(&self, activity: &Activity)` -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. +- 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` -- Fetch the tenant and its relays. Build the desired state: for each `active` relay on a paid plan with a non-empty `stripe_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 is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and 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 call `command.clear_tenant_subscription`. 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 via `command.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. -- **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. +## `async fn reconcile_subscription(&self, tenant: &Tenant)` -## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` +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. -- 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` - - `invoice.overdue` -> `self.handle_invoice_overdue` - - `customer.subscription.updated` -> `self.handle_subscription_updated` - - `customer.subscription.deleted` -> `self.handle_subscription_deleted` - - `payment_method.attached` -> `self.handle_payment_method_attached` -- Unknown event types are ignored (return Ok) +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. -## `pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result` +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`). -- 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 +- 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. -## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result` +## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result>` -- Delegates to `stripe.list_invoices` — returns the `data` array of the customer's invoices +- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map. -## `pub async fn stripe_create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result` +## `async fn get_subscription(&self, tenant: &Tenant) -> Result>` -- Delegates to `stripe.create_portal_session` — returns the Customer Portal session URL +- 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` -## `pub async fn get_invoice_with_tenant(&self, invoice_id: &str) -> Result<(Value, Tenant), InvoiceLookupError>` +## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result` -- 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 +- 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` -## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result` +## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)` -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. +- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription` -## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result` +## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)` -- 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) +- 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 create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result` +## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result` -- Converts the fiat amount to msats via `bitcoin::fiat_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`) -- Issues a bolt11 invoice for that amount on the system NWC wallet (`self.wallet.make_invoice(...)`) -- Returns the bolt11 invoice string +- 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_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>` +## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> 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. +Pays a Lightning invoice from the tenant's own wallet. -- If `tenant.nwc_url` is empty, 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`: - - Attempt NWC payment via `pay_invoice_nwc`. - - 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. +- 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 pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>` +## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result` -Attempts Stripe-side collection for open invoices when the tenant has a card on file. +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 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.pay_invoice` to retry collection using the card on file. - - Log and continue on failures. +- 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 -## `fn handle_invoice_created(&self, invoice: &Invoice)` +## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)` -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`, run `pay_invoice_nwc` (decrypting the tenant's stored `nwc_url` first): - - The system wallet (`self.wallet`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::from_url` 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. - -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 `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment) - - 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` -- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `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` - - Mark all active paid relays as delinquent via `command.mark_relay_delinquent` - -## `fn handle_subscription_deleted(&self, subscription: &Subscription)` - -- Look up tenant by `stripe_customer_id` -- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - -## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)` - -- Look up tenant by `stripe_customer_id` -- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately +- `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)` diff --git a/backend/spec/command.md b/backend/spec/command.md index a97ce89..d34e362 100644 --- a/backend/spec/command.md +++ b/backend/spec/command.md @@ -9,91 +9,101 @@ Members: Notes: -- All public write methods should be atomic -- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)` -- `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`) -- After each successful commit, sends the `Activity` on the broadcast channel +- Write methods that mutate tenants/relays are atomic and accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`, run inside a single transaction via the `with_activity` helper. +- `insert_activity` builds and returns the `Activity` struct (resolving `tenant` from the resource — directly for `tenant` resources, by looking up `relay.tenant` for `relay` resources — and using `chrono::Utc::now()` for `created_at`). +- After each successful commit, the `Activity` is sent on the broadcast channel. +- The subscription/error/past-due setters and the lightning-invoice writes below intentionally do **not** log activity and write directly to the pool. -## `pub fn new(&self, pool: SqlitePool) -> Self` +## `pub fn new(pool: SqlitePool) -> Self` -- Assigns pool to self -- Creates the broadcast channel +- Stores the pool and creates the broadcast channel -## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>` +## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>` -- Creates tenant, may throw sqlite uniqueness error on pubkey -- Logs activity as `(create_tenant, tenant_id)` +- Creates tenant (writes `pubkey`, `nwc_url`, `created_at`, `stripe_customer_id`), may throw sqlite uniqueness error on pubkey +- Logs activity as `(create_tenant, tenant, pubkey)` -## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>` +## `pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()>` -- Updates tenant -- Logs activity as `(update_tenant, tenant_id)` +- Updates the tenant's `nwc_url` +- Logs activity as `(update_tenant, tenant, pubkey)` -## `pub fn create_relay(&self, relay: &Relay) -> Result<()>` +## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>` -- Creates relay, may throw sqlite uniqueness error on subdomain -- Sets relay status to `active` -- Logs activity as `(create_relay, relay_id)` +- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain +- Logs activity as `(create_relay, relay, id)` -## `pub fn update_relay(&self, relay: &Relay) -> Result<()>` +## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>` -- Updates relay, may throw sqlite uniqueness error on subdomain -- Logs activity as `(update_relay, relay_id)` +- Updates relay (all mutable fields), resets `synced = 0` so it re-syncs to zooid; may throw sqlite uniqueness error on subdomain +- Logs activity as `(update_relay, relay, id)` -## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>` +## `pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()>` -- Sets relay status to `inactive` -- Logs activity as `(deactivate_relay, relay_id)` +- Sets relay status to `inactive` and `synced = 0` +- Logs activity as `(deactivate_relay, relay, id)` - Used for user/admin-initiated deactivation only -## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>` +## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>` -- Sets relay status to `delinquent` -- Logs activity as `(deactivate_relay, relay_id)` +- Sets relay status to `delinquent` and `synced = 0` +- Logs activity as `(mark_relay_delinquent, relay, id)` - Used exclusively by the billing system when a relay's subscription becomes past due - `delinquent` relays are automatically reactivated via `activate_relay` when payment is received -## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>` +## `pub async fn activate_relay(&self, relay: &Relay) -> Result<()>` -- Sets relay status to `active` -- Logs activity as `(activate_relay, relay_id)` +- Sets relay status to `active` and `synced = 0` +- Logs activity as `(activate_relay, relay, id)` -## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>` +## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>` -- Sets `sync_error` on the relay -- Logs activity as `(fail_relay_sync, relay_id)` +- Sets `synced = 0` and `sync_error` on the relay +- Logs activity as `(fail_relay_sync, relay, id)` -## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>` +## `pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()>` - Sets `synced = 1`, clears `sync_error` -- Logs activity as `(complete_relay_sync, relay_id)` +- Logs activity as `(complete_relay_sync, relay, id)` -## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>` +## `pub async 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<()>` +## `pub async 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<()>` +## `pub async 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<()>` +## `pub async 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<()>` +## `pub async 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<()>` +## `pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>` - Sets `past_due_at = null` on the tenant - Does not log activity + +## `pub async fn insert_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64) -> Result>` + +- Upserts the `pending` bolt11 row for a Stripe invoice, returning the resulting row +- On conflict the stored `bolt11`/`expires_at` are replaced (this is how an expired invoice is regenerated), **except** once the invoice is `paid`: the `status = 'pending'` guard makes the update a no-op and `None` is returned so the caller can fall back to reading the settled row +- Does not log activity + +## `pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()>` + +- Marks a `pending` invoice `paid`, recording `paid_method = method` (`nwc` or `manual`) +- The `status = 'pending'` guard makes this idempotent and first-writer-wins: a later reconcile won't clobber the method recorded by whoever settled it first +- Does not log activity diff --git a/backend/spec/env.md b/backend/spec/env.md new file mode 100644 index 0000000..d0372cb --- /dev/null +++ b/backend/spec/env.md @@ -0,0 +1,47 @@ +# `pub struct Env` + +Env is the application's configuration, loaded once at startup and cloned into every service that needs it (`Api`, `Query`, `Command`, `Billing`, `Infra`, `Robot`, `Stripe`). It is the single source of truth for environment-derived settings, and it also owns the robot nostr key used for signing, NIP-44 encryption, and NIP-98 auth. + +Members (all populated from environment variables): + +- `server_host: String` - from `SERVER_HOST`; also used for the NIP-98 `u` host check +- `server_port: u16` - from `SERVER_PORT` +- `server_admin_pubkeys: Vec` - admin pubkeys from `SERVER_ADMIN_PUBKEYS` +- `server_allow_origins: Vec` - CORS origins from `SERVER_ALLOW_ORIGINS` +- `database_url: String` - from `DATABASE_URL` +- `robot_name: String` - from `ROBOT_NAME` +- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices +- `robot_picture: String` - from `ROBOT_PICTURE` +- `robot_description: String` - from `ROBOT_DESCRIPTION` +- `robot_outbox_relays: Vec` - from `ROBOT_OUTBOX_RELAYS` +- `robot_indexer_relays: Vec` - from `ROBOT_INDEXER_RELAYS` +- `robot_messaging_relays: Vec` - from `ROBOT_MESSAGING_RELAYS` +- `blossom_s3_region` / `blossom_s3_bucket` / `blossom_s3_endpoint` / `blossom_s3_access_key` / `blossom_s3_secret_key: String` - from the matching `BLOSSOM_S3_*` vars +- `zooid_api_url: String` - from `ZOOID_API_URL` +- `relay_domain: String` - from `RELAY_DOMAIN` +- `livekit_url` / `livekit_api_key` / `livekit_api_secret: String` - from the matching `LIVEKIT_*` vars +- `stripe_secret_key: String` - from `STRIPE_SECRET_KEY` +- `stripe_webhook_secret: String` - from `STRIPE_WEBHOOK_SECRET` +- `stripe_price_basic: String` - Stripe price id for the Basic plan, from `STRIPE_PRICE_BASIC` +- `stripe_price_growth: String` - Stripe price id for the Growth plan, from `STRIPE_PRICE_GROWTH` +- `keys: Keys` - parsed from `ROBOT_SECRET`; used for nostr signing, NIP-44 encryption, and NIP-98 auth + +## `pub fn load() -> Self` + +- Reads every variable above and panics if any is missing or malformed. +- String vars must be present and non-blank (trimmed). +- The port must parse as a `u16`. +- CSV vars are split on commas, trimmed, and empties dropped; each must contain at least one entry. +- `keys` is parsed from `ROBOT_SECRET` and panics if it is not a valid nostr secret key. + +## `pub fn encrypt(&self, plaintext: &str) -> Result` + +- NIP-44 (v2) encrypts `plaintext` to the robot's own key. Used to encrypt a tenant's `nwc_url` at rest. + +## `pub fn decrypt(&self, ciphertext: &str) -> Result` + +- NIP-44 decrypts a value previously produced by `encrypt`. + +## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result` + +- Builds a NIP-98 `Authorization` header value for an outgoing request to `url` with `method`, signed with `keys`. Used by `Infra` to authenticate requests to zooid. diff --git a/backend/spec/infra.md b/backend/spec/infra.md index 1b833c6..aa081e9 100644 --- a/backend/spec/infra.md +++ b/backend/spec/infra.md @@ -1,40 +1,59 @@ # `pub struct Infra` -Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`. +Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance. Members: -- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL` -- `blossom_s3: Option` - shared Blossom S3 settings from `BLOSSOM_S3_*` when region, bucket, access key, and secret are all non-empty after trim +- `env: Env` - configuration; supplies `zooid_api_url`, `relay_domain`, the `BLOSSOM_S3_*` and `LIVEKIT_*` settings, and the robot key used to sign requests - `query: Query` - `command: Command` -## `pub fn new(query: Query, command: Command) -> Self` +## `pub fn new(query: Query, command: Command, env: &Env) -> Self` -- Reads environment and populates members +- Stores `query`, `command`, and a clone of `env` ## `pub async fn start(self)` - Subscribes to `command.notify` -- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty. -- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`. +- Runs `reconcile_relay_state("startup")` before entering the loop +- Loops on `rx.recv()`, calling `handle_activity` for each `Activity` +- On `Lagged`, runs `reconcile_relay_state("lagged")`; on `Closed`, exits ## `async fn handle_activity(&self, activity: &Activity)` -- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately. -- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay. -- Retry scheduling stops after the configured max attempts to avoid infinite retry loops. -- Other activity types are ignored (e.g. `complete_relay_sync`). +- Ignores anything that isn't a `relay` resource with activity type `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, or `fail_relay_sync` +- For `fail_relay_sync`, schedules a delayed retry via `schedule_relay_sync_retry` +- Otherwise resolves the relay (skip if gone) and calls `sync_relay` -## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)` +## `async fn reconcile_relay_state(&self, source: &str)` -- Calls `sync_relay` and on success calls `command.complete_relay_sync`. -- On failure calls `command.fail_relay_sync`. +- Lists relays still pending sync (`query.list_relays_pending_sync`) +- For each: `sync_relay` immediately if its `sync_error` is empty, otherwise `schedule_relay_sync_retry` -## `async fn sync_relay(&self, relay: &Relay, is_new: bool)` +## `async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str)` -- If `is_new`, sends `POST /relay/:id` to create the relay in zooid. -- Otherwise, sends `PATCH /relay/:id` to update it. -- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity. -- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles. -- When `blossom_s3` is configured and the relay has blossom enabled, the blossom section includes `adapter: "s3"`, S3 fields from the environment, and `s3.key_prefix` set to the relay's `schema`. Otherwise blossom omits S3 (zooid defaults to local storage) or sends `{ "enabled": false }` when blossom is disabled. +- Counts the relay's consecutive trailing `fail_relay_sync` activities to derive the attempt number +- Computes an exponential backoff (base 30s, doubling, capped at 15 minutes); gives up after `RELAY_SYNC_RETRY_MAX_ATTEMPTS` (6) to avoid infinite retry loops +- Spawns a task that sleeps for the delay, then re-reads the relay and `sync_relay`s it (no-op if the relay is gone) + +## `async fn sync_relay(&self, relay: &Relay)` + +- Calls `try_sync_relay`; on success `command.complete_relay_sync`, on failure `command.fail_relay_sync` with the error + +## `async fn try_sync_relay(&self, relay: &Relay)` + +- A relay is "new" only if it has never completed a sync (`synced != 1` and no `complete_relay_sync` activity exists). New relays are created with `POST /relay/:id`; existing relays are updated with `PATCH /relay/:id`. +- A freshly generated `secret` is included only for creation (`POST`), so updates don't rotate relay identity and we never store the secret. +- The body carries relay configuration: `host` (= `subdomain.relay_domain`), `schema`, `inactive` (true when status is `inactive` or `delinquent`), `info` (name/icon/description/pubkey), `policy`, `groups`, `management`, `blossom`, `livekit`, `push`, and hard-coded `roles`. +- When `blossom_enabled`, the blossom section uses `adapter: "s3"` with the `BLOSSOM_S3_*` settings and `s3.key_prefix` set to the relay's `schema`; otherwise it sends `{ "enabled": false }`. +- When `livekit_enabled`, the livekit section carries the `LIVEKIT_*` settings; otherwise `{ "enabled": false }`. + +## `pub async fn list_relay_members(&self, relay_id: &str) -> Result>` + +- `GET /relay/:id/members` from zooid; returns the `members` array + +## `async fn request(&self, method, path, body)` + +- Sends an authenticated request to the zooid API at `path` (relative to `env.zooid_api_url`), with a 5s timeout +- Authenticates each request with a NIP-98 header via `env.make_auth` +- Returns the response on 2xx; bails with the status and body text otherwise diff --git a/backend/spec/main.md b/backend/spec/main.md index c7e44dd..51b7c2a 100644 --- a/backend/spec/main.md +++ b/backend/spec/main.md @@ -1,9 +1,17 @@ # `async fn main() -> Result<()>` -- Configures logging -- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra` -- Get an axum router from `api.router` -- Adds CORS middleware based on `origins` -- Calls `axum::serve` with a listener -- Spawns `infra.start` -- Spawns `billing.start` +- Loads `.env` (via `dotenvy`) and configures tracing/logging from the default env filter +- Calls `Env::load()` to read and validate all configuration +- Calls `create_pool(&env.database_url)` to get a `SqlitePool` +- Constructs the services, passing `&env` where needed: + - `Robot::new(&env).await` (publishes the robot's nostr identity) + - `Stripe::new(&env)` + - `Query::new(pool.clone(), &env)` + - `Command::new(pool)` + - `Billing::new(query.clone(), command.clone(), &env)` + - `Infra::new(query.clone(), command.clone(), &env)` + - `Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env)` +- Builds a CORS layer from `env.server_allow_origins`: permissive when empty, otherwise restricted to the listed origins +- Gets the axum router from `api.router()` and applies the CORS layer +- Spawns `infra.start()` and `billing.start()` as background tasks +- Binds a `TcpListener` on `env.server_host:env.server_port` and calls `axum::serve` diff --git a/backend/spec/models.md b/backend/spec/models.md index ab9dcd4..139a485 100644 --- a/backend/spec/models.md +++ b/backend/spec/models.md @@ -24,6 +24,7 @@ Activity is an audit log of all actions performed by a user or a worker process. - `update_relay` - `activate_relay` - `deactivate_relay` + - `mark_relay_delinquent` - `fail_relay_sync` - `complete_relay_sync` - `resource_type` is a string identifying the resource type being modified. @@ -35,7 +36,7 @@ A plan represents a rate charged for relays at a given feature/usage limit. Plan - `id` - the plan slug - `name` - the plan name -- `amount` - the plan monthly cost in USD +- `amount` - the plan's monthly cost in USD minor units (cents); e.g. `500` for $5/mo - `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 @@ -52,7 +53,7 @@ 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 on the tenant's behalf; stored encrypted at rest using NIP-44 via `ENCRYPTION_SECRET`; never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead +- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 with the robot key (`ROBOT_SECRET`, via `Env::encrypt`); never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead - `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 @@ -63,7 +64,7 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique. -- `id` - calculated based on `subdomain` + 8 random hex chars +- `id` - calculated based on `subdomain` (with `-` replaced by `_`) + `_` + 8 random hex chars - `tenant` - the tenant's pubkey - `schema` - the relay's db schema (read only, same as `id`) - `subdomain` - the relay's subdomain @@ -89,3 +90,15 @@ Some attributes persisted to zooid via API have special handling: - The value of `inactive` is calculated based on `status` - The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`. - The relay's `roles` are hard-coded for now. + +# LightningInvoice + +Tracks the bolt11 invoice issued on the system wallet to collect a single Stripe invoice over Lightning (either NWC auto-pay or a manual payment from the app). One row per Stripe invoice. + +- `stripe_invoice_id` - the Stripe invoice this bolt11 collects (primary key) +- `tenant_pubkey` - the owning tenant +- `bolt11` - the bolt11 invoice string issued on the system wallet +- `status` - one of `pending|paid` +- `paid_method` (nullable) - how it settled, one of `nwc|manual`; set when `status` becomes `paid` +- `expires_at` - unix timestamp after which the `pending` bolt11 is considered expired and may be regenerated +- `created_at` / `updated_at` - unix timestamps diff --git a/backend/spec/pool.md b/backend/spec/pool.md index d42ad79..c7827e7 100644 --- a/backend/spec/pool.md +++ b/backend/spec/pool.md @@ -1,14 +1,15 @@ -# `pub async fn create_pool() -> Result` +# `pub async fn create_pool(database_url: &str) -> Result` Creates and returns a sqlite connection pool. Notes: -- Database table names are singular: `activity`, `tenant`, `relay` +- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice` Steps: -- Reads `DATABASE_URL` from environment -- Ensures that any directories referred to in `DATABASE_URL` exist -- Initializes the sqlx pool +- Normalizes `database_url`: a relative `sqlite://` path is resolved under the crate manifest directory (`CARGO_MANIFEST_DIR`); absolute paths and `:memory:` are left as-is +- Ensures any directory referred to in the (normalized) URL exists +- Opens the pool with `create_if_missing` enabled +- Enables WAL journaling (`PRAGMA journal_mode = WAL`) - Runs migrations found in the `migrations` directory diff --git a/backend/spec/query.md b/backend/spec/query.md index 633a690..dbb5cad 100644 --- a/backend/spec/query.md +++ b/backend/spec/query.md @@ -5,41 +5,65 @@ Query reads from the database. Members: - `pool: SqlitePool` - a sqlite connection pool +- `env: Env` - configuration; used to fill in plan `stripe_price_id`s from `STRIPE_PRICE_*` -## `pub fn new(&self, pool: SqlitePool) -> Self` +## `pub fn new(pool: SqlitePool, env: &Env) -> Self` -- Assigns pool to self +- Stores the pool and a clone of `env` -## `pub fn list_tenants(&self) -> Result>` +## `pub fn list_plans(&self) -> Vec` + +- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`) +- The `basic`/`growth` `stripe_price_id`s come from `env` (`stripe_price_basic` / `stripe_price_growth`); `free` has none +- This is the source of truth for plan metadata exposed via API + +## `pub fn get_plan(&self, plan_id: &str) -> Option` + +- Returns the plan matching `plan_id`, if any + +## `pub fn is_paid_plan(&self, plan_id: &str) -> bool` + +- Returns whether `plan_id` is a known plan with `amount > 0` + +## `pub async fn list_tenants(&self) -> Result>` - Returns all tenants -## `pub fn get_tenant(&self, pubkey: &str) -> Result` +## `pub async fn get_tenant(&self, pubkey: &str) -> Result>` -- Returns matching tenant +- Returns the matching tenant, or `None` if not found -## `pub fn list_plans() -> Vec` +## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result>` -- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`) -- This is the source of truth for plan metadata exposed via API +- Returns the tenant matching the given `stripe_customer_id`, or `None` -## `pub fn list_relays(&self) -> Result>` +## `pub async fn list_relays(&self) -> Result>` - Returns all relays -## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result>` +## `pub async fn list_relays_pending_sync(&self) -> Result>` + +- Returns all relays where `synced = 0` or `sync_error` is non-empty +- Used by `Infra` to reconcile relays that still need to be pushed to zooid + +## `pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result>` - Returns all relays belonging to the given tenant -## `pub fn get_relay(&self, id: &str) -> Result` +## `pub async fn get_relay(&self, id: &str) -> Result>` -- Returns matching relay +- Returns the matching relay, or `None` if not found -## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result` +## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result>` -- Returns the tenant matching the given `stripe_customer_id` +- Returns the `lightning_invoice` row for the given Stripe invoice, or `None` -## `pub fn list_activity_for_resource(&self, relay_id: &str) -> Result>` +## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result>` -- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id` +- Returns all activity where `resource_id = resource_id` - Ordered newest-first + +## `pub async fn get_latest_activity_for_resource_and_type(&self, resource_id: &str, activity_type: &str) -> Result>` + +- Returns the most recent activity for `resource_id` with the given `activity_type`, or `None` +- Used by `Infra` to decide whether a relay has ever completed a sync diff --git a/backend/spec/robot.md b/backend/spec/robot.md index 58468ec..15cb16c 100644 --- a/backend/spec/robot.md +++ b/backend/spec/robot.md @@ -1,25 +1,32 @@ # `pub struct Robot` -Robot is a nostr identity which acts on behalf of the application. +Robot is the nostr identity that acts on behalf of the application — it publishes the app's profile/relay lists and sends DMs to tenants. It signs with the robot key in `env.keys` and builds nostr clients on demand from the relay lists in `env`. Members: -- `secret: String` - a nostr secret key, from `ROBOT_SECRET` -- `name: String` - the name of the bot, from `ROBOT_NAME` -- `description: String` - the description of the bot, from `ROBOT_DESCRIPTION` -- `picture: String` - the picture URL for the bot, from `ROBOT_PICTURE` -- `outbox_client: nostr_sdk::Client` - used for publishing relay lists and metadata, connects to `ROBOT_OUTBOX_RELAYS` -- `indexer_client: nostr_sdk::Client` - used for publishing relay lists, connects to `ROBOT_INDEXER_RELAYS` -- `messagins_client: nostr_sdk::Client` - used for sending and receiving dms, connects to `ROBOT_MESSAGING_RELAYS` +- `env: Env` - configuration; supplies the robot key and the outbox/indexer/messaging relay lists and profile metadata +- `outbox_cache` / `dm_cache` - per-recipient caches (5 minute TTL) of discovered outbox and messaging relays -## `pub fn new() -> Self` +## `pub async fn new(env: &Env) -> Result` -- Reads environment and populates members. Relay urls should be split and normalized. -- Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections +- Stores a clone of `env` and initializes the caches +- Calls `publish_identity`, which publishes a `kind 0` profile and a `kind 10002` relay list (the `ROBOT_OUTBOX_RELAYS`, as `r` tags) to the outbox relays, and a `kind 10050` DM relay list (the `ROBOT_MESSAGING_RELAYS`, as `relay` tags) via the indexer relays ## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>` -- Fetches recipient's outbox relays from `indexer_relays` (cached) -- Fetches recipient's messaging relays from their outbox relays (cached) -- Sends DM to recipient via their messaging relays -- If no outbox/messaging relays are found, return an error +- Resolves the recipient's outbox relays (`fetch_outbox_relays`), then their messaging relays from those outbox relays (`fetch_messaging_relays_from_outbox`) +- Sends a NIP-17 private message to the recipient via their messaging relays +- Errors if no outbox or messaging relays are found + +## `pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option` + +- Fetches the recipient's `kind 0` metadata from the indexer relays and returns its `display_name` (falling back to `name`), trimmed and non-empty +- Returns `None` on any failure — used to derive a Stripe customer display name + +## `async fn fetch_outbox_relays(&self, recipient: &str) -> Result>` + +- Returns the `r` tags from the recipient's latest `kind 10002` event, fetched from the indexer relays; cached for 5 minutes + +## `async fn fetch_messaging_relays_from_outbox(&self, recipient: &str, outbox_relays: &[String]) -> Result>` + +- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes diff --git a/backend/spec/stripe.md b/backend/spec/stripe.md index e029606..05b8158 100644 --- a/backend/spec/stripe.md +++ b/backend/spec/stripe.md @@ -1,41 +1,36 @@ # `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`. +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 small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.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 +- `env: Env` - configuration; supplies the Stripe secret key (bearer token + idempotency HMAC key) and the webhook signing secret - `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. +All requests authenticate with `env.stripe_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 the secret key. Reconcile-to-desired-state writes (e.g. setting an item quantity, deleting/canceling) 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` +## `pub fn new(env: &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. +Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call. -## `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` +## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result` - `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 +- Returns the new customer id -## `pub async fn get_subscription(&self, subscription_id: &str) -> Result>` +## `pub async fn get_subscription(&self, subscription_id: &str) -> Result>` - `GET /v1/subscriptions/:id` -- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the subscription object +- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the parsed `StripeSubscription` -## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap) -> Result<(String, BTreeMap)>` +## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap) -> Result` - `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 +- Returns the created `StripeSubscription` (including its items) ## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>` @@ -55,16 +50,15 @@ Constructs the client with a fresh `reqwest::Client` from explicit keys (does no - `DELETE /v1/subscriptions/:id` -## `pub async fn list_invoices(&self, customer_id: &str) -> Result` +## `pub async fn list_invoices(&self, customer_id: &str) -> Result>` - `GET /v1/invoices?customer=…` -- Returns the `data` array +- Returns the parsed `data` array -## `pub async fn get_invoice(&self, invoice_id: &str) -> Result` +## `pub async fn get_invoice(&self, invoice_id: &str) -> Result>` - `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 +- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice` ## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>` @@ -74,12 +68,7 @@ Constructs the client with a fresh `reqwest::Client` from explicit keys (does no ## `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_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result` - -- `GET /v1/invoices/upcoming?customer=…[&subscription=…]` -- Used to validate proration when a subscription is downgraded +- Idempotent on `invoice_id` (under a distinct key from `pay_invoice`) ## `pub async fn has_payment_method(&self, customer_id: &str) -> Result` @@ -89,24 +78,21 @@ Constructs the client with a fresh `reqwest::Client` from explicit keys (does no ## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result` - `POST /v1/billing_portal/sessions` with `customer` and optional `return_url` -- Returns the portal session URL +- Returns the Customer Portal session URL -## `pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result` +## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result` -Verifies the `Stripe-Signature` header against `webhook_secret` and parses the body. +Verifies the `Stripe-Signature` header against `env.stripe_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 +- Compute `HMAC-SHA256(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 } }`) +- Returns the deserialized `StripeWebhookEvent` -# `pub enum InvoiceLookupError` +# Typed results -- `StripeClient { status: reqwest::StatusCode }` - Stripe returned a 4xx for an invoice lookup -- `Internal(anyhow::Error)` - any other failure - -Implements `Display`/`Error` and `From` / `From` (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 }`. +- `StripeWebhookEvent { event_type: String, data: StripeWebhookEventData }`, `StripeWebhookEventData { object: serde_json::Value }` — the verified, parsed webhook event (`event_type` deserializes from the JSON `type` field) +- `StripeSubscription { id, status, items: Vec }` (`items` flattened from Stripe's `{ data: [...] }` list) +- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent) +- `StripePrice { id }` +- `StripeInvoice { id, customer, status, amount_due, currency }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`) diff --git a/backend/spec/wallet.md b/backend/spec/wallet.md index a0bf36e..f06591c 100644 --- a/backend/spec/wallet.md +++ b/backend/spec/wallet.md @@ -1,6 +1,6 @@ # `pub struct Wallet` -A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. +A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. Each operation opens a fresh NWC connection and shuts it down afterwards. Member: @@ -10,9 +10,9 @@ Member: Parses an `nostr+walletconnect://` URI. -## `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result` +## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result` -Issues a bolt11 invoice for `amount_msats` and returns it. +Issues a bolt11 invoice for `amount_msats` with the given `description` and expiry, and returns the bolt11 string. ## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>` diff --git a/backend/spec/web.md b/backend/spec/web.md new file mode 100644 index 0000000..a0ea7d5 --- /dev/null +++ b/backend/spec/web.md @@ -0,0 +1,39 @@ +# `web` — HTTP response helpers + +General-purpose helpers shared across the route handlers in `spec/api.md` (implemented under `src/routes/`). They standardize the success/error envelope and a couple of small utilities. + +Successful responses are `{ data, code: "ok" }` with an appropriate HTTP status. Error responses are `{ error, code }` with an appropriate HTTP status, where `code` is a short machine-readable string (e.g. `subdomain-exists`) and `error` is a human-readable message. + +## `pub struct ApiError(pub Box)` + +A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`. + +## `pub type ApiResult = Result` + +The return type of every route handler. Success builders return `ApiResult` so they sit at the end of a handler without an `Ok(..)` wrap; error builders return `ApiError`. + +## Response bodies + +- `DataResponse { data: T, code: "ok" }` - the success envelope +- `ErrorResponse { error: String, code: String }` - the error envelope + +## Success builders (return `ApiResult`) + +- `res(status, data)` - `{ data, code: "ok" }` with `status` +- `ok(data)` - `res(200, data)` +- `created(data)` - `res(201, data)` + +## Error builders (return `ApiError`) + +- `err(status, code, message)` - the base `{ error, code }` builder +- `unauthorized(reason)` - `401`, `code = "unauthorized"` +- `forbidden(message)` - `403`, `code = "forbidden"` +- `not_found(message)` - `404`, `code = "not-found"` +- `bad_request(code, message)` - `400` with the given `code` +- `unprocessable(code, message)` - `422` with the given `code` +- `internal(reason)` - `500`, `code = "internal"` + +## Utilities + +- `parse_bool_default(value: i64, default: i64) -> i64` - returns `value` if it is `0` or `1`, otherwise `default`. Used to normalize boolean-ish relay flags. +- `map_unique_error(err: &anyhow::Error) -> Option<&'static str>` - recognizes sqlite UNIQUE constraint violations so callers can translate them into `422`s instead of `500`s. Returns `pubkey-exists` or `subdomain-exists` when the violated column message matches, else `None`.