Compare commits

...

17 Commits

Author SHA1 Message Date
Jon Staab e4e0172972 Add agents stuff
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-06-01 13:15:27 -07:00
Jon Staab 31c8e596a6 Avoid spammy payment DMs 2026-06-01 12:59:19 -07:00
Jon Staab f5403b6aef Massive user-story-oriented refactor 2026-06-01 12:38:58 -07:00
Jon Staab 0018a5d4f3 Improve transactionality, align invoice model with frontend 2026-05-29 14:31:58 -07:00
Jon Staab ae3e1c316e Track payment method 2026-05-29 12:24:39 -07:00
Jon Staab d5047dedb1 Add dunning 2026-05-29 11:32:06 -07:00
Jon Staab f7bd3e53fe Add snapshots to activity 2026-05-28 15:53:02 -07:00
Jon Staab eb0123abef Rename tenant fields to tenant_pubkey and plan to plan_id 2026-05-28 15:18:41 -07:00
Jon Staab 9f599d66be Clean up billing a bit 2026-05-28 14:34:19 -07:00
Jon Staab 72b30489b9 Add BillingPeriod helper 2026-05-28 13:20:17 -07:00
Jon Staab b11fb5dc25 Fix possible race condition related to billing an activity 2026-05-28 12:45:21 -07:00
Jon Staab 35d9aab02a Make infra module free functions 2026-05-27 17:26:47 -07:00
Jon Staab 0f47b483aa Update docs 2026-05-27 16:56:34 -07:00
Jon Staab cd70ca6654 Move renewed_at to tenant 2026-05-27 15:35:02 -07:00
Jon Staab f37bb55286 Significant refactor of activity reconciliation 2026-05-27 15:22:33 -07:00
Jon Staab 7a2baf6f82 Refactor billing to manage subscriptions/invoices internally 2026-05-27 10:27:13 -07:00
Jon Staab 28cd7b0a9a remove spec 2026-05-26 14:53:26 -07:00
62 changed files with 3214 additions and 3280 deletions
+84
View File
@@ -0,0 +1,84 @@
---
name: billing-model
description: Conceptual, user-story-level overview of how Caravel bills tenants for their relays — plans, proration, invoices, automatic payment collection, payment-method errors, dunning, churn/delinquency, and reactivation. Use this when working on or reasoning about anything billing-related, to understand the intended domain behavior before touching the code.
---
# Caravel billing model
This explains *what* the billing system is supposed to do and *why*, in plain domain terms. It is deliberately free of implementation detail — no functions, tables, or fields — so it stays true as the code evolves. Reach for the code for the *how*; reach for this for the *intent*.
## The cast
- **Tenant** — a customer account, identified by a Nostr pubkey. Everything is billed to a tenant.
- **Relay** — a hosted relay a tenant runs. A tenant can have many. Each relay sits on one plan and is either active, inactive, or delinquent.
- **Plan** — a tier (e.g. free, basic, growth). The free tier costs nothing; paid tiers have a flat monthly price and unlock features.
Only active relays on a paid plan ever cost money. Free relays, inactive relays, and delinquent relays are not charged.
## The guiding idea: bill from history, not from "now"
Every meaningful change to a relay — created, plan changed, deactivated, reactivated — is recorded as a dated event that also captures what the relay looked like at that instant (its plan and status). Billing is computed by replaying these events, never by reading the relay's *current* settings.
Why this matters: if a customer was on the growth plan last week and downgraded today, last week must still be billed at the growth price. Pricing the past from the present would overcharge or undercharge. Treat the event history as the source of truth for money.
## Billing periods
Each tenant is billed in monthly cycles. The cycle is anchored to the moment of their first billable activity, and each period is a whole calendar month from that anchor. A customer who starts on the 7th is billed on the 7th, and so on.
## How charges arise (the user stories)
**"I created a paid relay mid-month."** The customer is charged immediately, but only for the slice of the current period that remains — a prorated charge, not a full month.
**"I upgraded (or downgraded) a relay mid-month."** The customer is charged (or credited) the prorated *difference* between the old and new plan for the rest of the period. Downgrades and removals can produce credits.
**"I deactivated a relay mid-month."** The customer receives a prorated credit for the unused remainder of the period.
**"A new month started."** Every relay that was active on a paid plan at the moment the new period began is charged a full month. A relay created partway through the previous month already paid its prorated slice, so the renewal and the proration compose to exactly one fair month — never double-charged.
Credits and charges accumulate together. A customer's net balance can be zero or negative; in that case nothing is billed and the credit simply carries forward to the next time there is something to pay.
## Invoices
When a tenant has a positive outstanding balance, the pending charges and credits are gathered into a single invoice for the period. An invoice moves through a simple lifecycle expressed as *when* things happened rather than as a status label:
- **Open** — issued and awaiting payment.
- **Paid** — settled (and we remember how: Lightning, card, or a manual/out-of-band payment).
- **Void** — forgiven and no longer collectible (used when an account churns).
## Collecting payment (the cascade)
When an invoice is open, the system tries to collect on the customer's behalf, in order of least friction:
1. **The customer's Lightning wallet**, if they've connected one for automatic payments.
2. **A saved card**, if they have one on file.
3. **A manual nudge** — if automatic methods don't go through, the customer is sent a direct message with a link to pay the invoice themselves, by Lightning or card.
The system also guards against double payment: if an invoice was already settled out of band (e.g. the customer paid the Lightning request directly), that is recognized and the invoice is not collected again.
## When automatic payment fails
**The failure reason is remembered and shown to the customer.** If the Lightning wallet or the card is declined, we keep the most recent error for each method so the UI can warn the customer that something is wrong with their payment setup — separately for the wallet and the card, since they can fail independently.
**A stored error never stops us from trying again.** Recording the problem is purely informational. The next collection attempt still runs; the relevant warning is cleared automatically the moment that method succeeds.
**Unpaid invoices are retried, within a grace period.** An open invoice is re-attempted on each billing cycle. The customer has a **7-day grace period** from when the invoice was issued to get payment working.
## Churn and delinquency
If an invoice is still unpaid once its grace period has elapsed, the account **churns**:
- The tenant's active relays are marked **delinquent**, pausing service.
- The outstanding balance is **forgiven** (the invoice is voided). We stop chasing money the customer clearly isn't going to pay; the unpaid amount is not carried as a debt.
Delinquency is the visible signal — to the customer and to admins — that the account lapsed for non-payment.
## Coming back (reactivation)
A churned customer who re-engages with the service is welcomed back automatically: their churn is cleared and their delinquent relays are restored to active. Critically, **old unpaid invoices do not have to be settled to return** — the past balance was already forgiven at churn, so reactivation is a clean slate rather than a debt collection.
## Principles to preserve
- **Bill the past at its historical price.** Always price a change from the state captured when it happened, not from the relay's current settings.
- **Never double-charge.** Proration and renewal must compose to one fair month; collection must tolerate retries and out-of-band payments idempotently.
- **Errors inform, they don't block.** Surface payment problems to the customer, but keep retrying.
- **Forgive on churn, don't accrue debt.** A lapsed customer's old balance is written off, and returning never requires paying it.
-2
View File
@@ -1,5 +1,3 @@
ref
target
.agents
.playwright-cli
node_modules
+1
View File
@@ -6,3 +6,4 @@ data
.env
**/.env
.playwright-cli
.agents/settings.local.json
+44
View File
@@ -0,0 +1,44 @@
# Style guide
## Comments
Keep comments minimal, one line if possible.
There is one right place to document any given information:
- Functions should have a doc comment explaining the purpose of the function, not its implementation.
- Only very strange behavior should be documented using non-doc comments.
- Models and their fields are documented in models.rs, not in migrations, implementations, or anywhere on the frontend.
- Database indexes are documented in migrations.
## Data Modeling
When naming a foreign key, always use `{model}_{pk}`, for example `relay.tenant_pubkey`.
When referring to a tenant's pubkey, always name it `tenant_pubkey`, not `tenant` or `pubkey`. The exception to this is when we're in a context where we're already talking about tenants, e.g. `tenant.pubkey`, `get_tenant(pubkey)` or any tenant-related routes.
## Migrations
Pre-release: squash schema changes into `0001_init.sql` rather than adding new migration files. Once released, migrations become append-only.
## Markdown
Do not hard-break markdown files at a certain number of characters. Allow readers to implement line wrapping naturally instead.
## Rust
Prefer `&str` over `&String` for function parameters — `&str` accepts both `&String` (via deref coercion) and string literals, so it's strictly more flexible. Only take `String` when you need ownership (storing in a struct, mutating, or transferring ownership).
Avoid passing `&mut` to functions. The performance improvement often comes at the cost of poor abstraction boundaries and error prone business logic. Instead, return results to the caller which can manage mutability itself, or re-calculate/fetch mutable data.
Don't be overly DRY. Deep call trees are harder to read; factoring functions into many tiny pieces means that function boundaries are defined less by the domain or the responsibility of a given piece of code than by coincidental similarity. New functions should be created when 1. they represent a different concern that is the responsibility of a different part of the codebase, 2. the contained logic is repeated 3+ times, or 3. the contents of the function are complex and naming them makes the logical flow of the code easier to follow.
## Verification
Check justfile and frontend/package.json for common commands for linting/building.
## Skills
Skills should be created and maintained when updating the codebase. Before creating a skill, check to see if a relevant one already exists.
Avoid including code in skills unless the purpose of the skill is to illustrate coding principles; architecture and domain should be explained in plain English and provide a high-level overview of the topic without going into implementation details.
-3
View File
@@ -34,6 +34,3 @@ BLOSSOM_S3_SECRET_KEY=
# Billing
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000
STRIPE_PRICE_BASIC=
STRIPE_PRICE_GROWTH=
+83 -35
View File
@@ -1,27 +1,34 @@
CREATE TABLE IF NOT EXISTS activity (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tenant (
pubkey TEXT PRIMARY KEY,
nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT,
stripe_error TEXT,
created_at INTEGER NOT NULL,
billing_anchor INTEGER,
stripe_customer_id TEXT NOT NULL,
stripe_subscription_id TEXT,
past_due_at INTEGER
stripe_payment_method_id TEXT,
renewed_at INTEGER,
churned_at INTEGER
);
CREATE TABLE IF NOT EXISTS activity (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
billed_at INTEGER,
snapshot TEXT NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS relay (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
tenant_pubkey TEXT NOT NULL,
subdomain TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL,
plan_id TEXT NOT NULL,
status TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
sync_error TEXT NOT NULL DEFAULT '',
@@ -35,32 +42,73 @@ CREATE TABLE IF NOT EXISTS relay (
blossom_enabled INTEGER NOT NULL DEFAULT 0,
livekit_enabled INTEGER NOT NULL DEFAULT 0,
push_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS lightning_invoice (
stripe_invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
bolt11 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
CREATE TABLE IF NOT EXISTS invoice (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
amount INTEGER NOT NULL,
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
created_at INTEGER NOT NULL,
paid_at INTEGER,
voided_at INTEGER,
notified_at INTEGER,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
ON relay (tenant, id);
CREATE TABLE IF NOT EXISTS invoice_item (
id TEXT PRIMARY KEY,
invoice_id TEXT,
activity_id TEXT,
tenant_pubkey TEXT NOT NULL,
relay_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
ON relay (tenant, status, plan);
CREATE TABLE IF NOT EXISTS bolt11 (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
lnbc TEXT NOT NULL,
msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
ON activity (resource_type, resource_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS intent (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
ON lightning_invoice (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant_pubkey, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant_pubkey, created_at) WHERE billed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
-- Dunning scans a tenant's still-open invoices oldest-first to retry payment.
CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_at) WHERE paid_at IS NULL AND voided_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
-- At most one line item per billable activity to ensure no double-billing.
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
-259
View File
@@ -1,259 +0,0 @@
# `pub struct Api`
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:
- `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 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<Arc<Api>>`.
- 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(query, command, billing, stripe, robot, infra, env: &Env) -> Self`
- Stores the services and a clone of `env`
## `pub fn router(self) -> Router`
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes
## `pub fn is_admin(&self, pubkey: &str) -> bool`
- Whether `pubkey` is in `env.server_admin_pubkeys`
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
# Authentication
## `pub struct AuthedPubkey(pub String)`
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`.
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
- 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 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. Uses `nostr_sdk` functionality where possible.
# Routes
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
--- Identity
## `get_identity` — `GET /identity`
- Authenticated (any signer)
- Side-effect-free: returns `{ pubkey, is_admin }`
- Clients must call `POST /tenants` before any tenant-scoped write
--- 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`, 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, period_start, period_end }`
## `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). The DM includes a link to the dashboard payment view for this invoice — `{env.app_url}/account?invoice={stripe_invoice_id}` — where the tenant can review the invoice and pay by Lightning or card.
## `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<Relay, ApiError>`
- 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 }`.
-96
View File
@@ -1,96 +0,0 @@
# `pub struct Billing`
Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment).
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`.
Members:
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
- `query: Query`
- `command: Command`
- `env: Env` - used to decrypt a tenant's stored `nwc_url`
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
- Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)`
- Panics if `ROBOT_WALLET` is not a valid NWC URL
## `pub async fn start(self)`
- Subscribes to `command.notify.subscribe()`
- Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
- On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits
## `async fn reconcile_subscriptions(&self, source: &str)`
- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors
## `async fn handle_activity(&self, activity: &Activity)`
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription`
## `async fn reconcile_subscription(&self, tenant: &Tenant)`
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe; free-only tenants have no subscription. Idempotent.
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay.
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and the webhook handler attempts payment (see `spec/api.md`).
- Build the desired state via `get_quantity_by_price_id`.
- **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return.
- Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items.
## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>>`
- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map.
## `async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>>`
- If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s)
- If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None`
## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result<StripeSubscription>`
- Returns the existing subscription if there is one
- Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription`
## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)`
- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription`
## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)`
- For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item
- Delete any existing item whose price no longer appears in the desired state
## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result<LightningInvoice>`
- Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired
- Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid)
## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>`
Pays a Lightning invoice from the tenant's own wallet.
- Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet`
- Pay `invoice.bolt11` from the tenant wallet
- On success, `settle_invoice(..., "nwc")`
- On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error
## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice>`
Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid.
- If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged
- If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s
- On any settlement-lookup failure, log and return the invoice unchanged
## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)`
- `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins)
- `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent)
- `command.clear_tenant_nwc_error(tenant_pubkey)`
-11
View File
@@ -1,11 +0,0 @@
# `bitcoin` — fiat ↔ Bitcoin conversion
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
-109
View File
@@ -1,109 +0,0 @@
# `pub struct Command`
Command writes to the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
- `pub notify: broadcast::Sender<Activity>` - callers can subscribe via `command.notify.subscribe()`
Notes:
- 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(pool: SqlitePool) -> Self`
- Stores the pool and creates the broadcast channel
## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- 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 async fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates the tenant's `nwc_url`
- Logs activity as `(update_tenant, tenant, pubkey)`
## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain
- Logs activity as `(create_relay, relay, id)`
## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>`
- 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 async fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive` and `synced = 0`
- Logs activity as `(deactivate_relay, relay, id)`
- Used for user/admin-initiated deactivation only
## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- 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 async fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active` and `synced = 0`
- Logs activity as `(activate_relay, relay, id)`
## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>`
- Sets `synced = 0` and `sync_error` on the relay
- Logs activity as `(fail_relay_sync, relay, id)`
## `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)`
## `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 async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
- Sets `stripe_subscription_id = null` on the tenant
- Does not log activity
## `pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
- Sets `nwc_error` on the tenant
- Does not log activity
## `pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
- Sets `nwc_error = null` on the tenant
- Does not log activity
## `pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at` to the current timestamp
- Does not log activity
## `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<Option<LightningInvoice>>`
- 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
-48
View File
@@ -1,48 +0,0 @@
# `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<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
- `app_url: String` - public base URL of the frontend app from `APP_URL`, with any trailing slash stripped; used to build tenant-facing links such as the invoice payment link in billing DMs
- `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<String>` - from `ROBOT_OUTBOX_RELAYS`
- `robot_indexer_relays: Vec<String>` - from `ROBOT_INDEXER_RELAYS`
- `robot_messaging_relays: Vec<String>` - 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<String>`
- 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<String>`
- NIP-44 decrypts a value previously produced by `encrypt`.
## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String>`
- 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.
-59
View File
@@ -1,59 +0,0 @@
# `pub struct Infra`
Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance.
Members:
- `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, env: &Env) -> Self`
- Stores `query`, `command`, and a clone of `env`
## `pub async fn start(self)`
- Subscribes to `command.notify`
- 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)`
- 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 reconcile_relay_state(&self, source: &str)`
- 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 schedule_relay_sync_retry(&self, relay_id: &str, source: &str)`
- 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 `id`; 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<Vec<String>>`
- `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
-17
View File
@@ -1,17 +0,0 @@
# `async fn main() -> Result<()>`
- 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 restricted to the parsed `env.server_allow_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`
-103
View File
@@ -1,103 +0,0 @@
This file describes the domain model. This description should be translated into standard structs and sqlite schemas in a way that makes sense.
- Fields marked as private should use `#[serde(skip_serializing)]` in their definition.
- Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition.
# Identity
Identity is a description of a user.
- `pubkey` - the user's nostr pubkey
- `is_admin` - whether the user is an admin
# Activity
Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior.
- `id` - a random activity ID
- `tenant` - a tenant ID
- `created_at` - unix timestamp when the activity was created
- `activity_type` is one of:
- `create_tenant`
- `update_tenant`
- `create_relay`
- `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.
- `resource_id` is a string identifying the resource id being modified.
# Plan
A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth.
- `id` - the plan slug
- `name` - the plan name
- `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
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
There are three plans available:
- `free` - $0/mo, up to 10 members, no blossom/livekit
- `basic` - $5/mo, up to 100 members, includes blossom/livekit
- `growth` - $25/mo, unlimited members, includes blossom/livekit
# Tenant
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 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
- `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
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` (with `-` replaced by `_`) + `_` + 8 random hex chars
- `tenant` - the tenant's pubkey
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
- `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name
- `info_icon` - the relay's icon image URL
- `info_description` - the relay's description
- `policy_public_join` - whether to allow non-members to join the relay without an invite code
- `policy_strip_signatures` - whether to remove signatures when serving events to non-admins
- `groups_enabled` - whether NIP 29 groups are enabled
- `management_enabled` - whether NIP 86 management API is enabled
- `blossom_enabled` - whether blossom file storage is enabled
- `livekit_enabled` - whether livekit calls are enabled
- `push_enabled` - whether relay push is enabled
Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- 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
-15
View File
@@ -1,15 +0,0 @@
# `pub async fn create_pool(database_url: &str) -> Result<SqlitePool>`
Creates and returns a sqlite connection pool.
Notes:
- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice`
Steps:
- 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
-69
View File
@@ -1,69 +0,0 @@
# `pub struct Query`
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(pool: SqlitePool, env: &Env) -> Self`
- Stores the pool and a clone of `env`
## `pub fn list_plans(&self) -> Vec<Plan>`
- 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<Plan>`
- 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<Vec<Tenant>>`
- Returns all tenants
## `pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>>`
- Returns the matching tenant, or `None` if not found
## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>>`
- Returns the tenant matching the given `stripe_customer_id`, or `None`
## `pub async fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>>`
- 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<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>>`
- Returns the matching relay, or `None` if not found
## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result<Option<LightningInvoice>>`
- Returns the `lightning_invoice` row for the given Stripe invoice, or `None`
## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>>`
- 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<Option<Activity>>`
- 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
-32
View File
@@ -1,32 +0,0 @@
# `pub struct Robot`
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:
- `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 async fn new(env: &Env) -> Result<Self>`
- 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<()>`
- 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<String>`
- 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<Vec<String>>`
- 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<Vec<String>>`
- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes
-98
View File
@@ -1,98 +0,0 @@
# `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 small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.md`.
Members:
- `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 `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 new(env: &Env) -> Self`
Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call.
## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String>`
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
- Idempotent on `tenant_pubkey`
- Returns the new customer id
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<StripeSubscription>>`
- `GET /v1/subscriptions/:id`
- 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<String, i64>) -> Result<StripeSubscription>`
- `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 created `StripeSubscription` (including its items)
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items`
- Idempotent on `(subscription_id, price_id)`
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items/:id` with `quantity`
- No idempotency key (reconcile-to-target write)
## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>`
- `DELETE /v1/subscription_items/:id`
## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>`
- `DELETE /v1/subscriptions/:id`
## `pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>>`
- `GET /v1/invoices?customer=…`
- Returns the parsed `data` array
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>>`
- `GET /v1/invoices/:id`
- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice`
## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>`
- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method)
- Idempotent on `invoice_id`
## `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` (under a distinct key from `pay_invoice`)
## `pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool>`
- `GET /v1/payment_methods?customer=…&type=card`
- Returns whether the customer has at least one card on file
## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url`
- Returns the Customer Portal session URL
## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent>`
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(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 `StripeWebhookEvent`
# Typed results
- `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<StripeSubscriptionItem> }` (`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, period_start, period_end }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
-23
View File
@@ -1,23 +0,0 @@
# `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. Each operation opens a fresh NWC connection and shuts it down afterwards.
Member:
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
## `pub fn from_url(url: &str) -> Result<Self>`
Parses an `nostr+walletconnect://` URI.
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result<String>`
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<()>`
Pays a bolt11 invoice.
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
-39
View File
@@ -1,39 +0,0 @@
# `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<Response>)`
A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From<Response>`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`.
## `pub type ApiResult = Result<Response, ApiError>`
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<T> { data: T, code: "ok" }` - the success envelope
- `ErrorResponse { error: String, code: String }` - the error envelope
## Success builders (return `ApiResult`)
- `res<T>(status, data)` - `{ data, code: "ok" }` with `status`
- `ok<T>(data)` - `res(200, data)`
- `created<T>(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`.
+31 -35
View File
@@ -18,8 +18,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow, ensure};
use axum::{
Router,
async_trait,
Router, async_trait,
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
routing::{get, post},
@@ -28,55 +27,37 @@ use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind};
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::env;
use crate::models::{Relay, Tenant};
use crate::query::Query;
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::routes::identity::get_identity;
use crate::routes::invoices::{get_invoice, get_lightning_invoice, list_tenant_invoices};
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items};
use crate::routes::plans::{get_plan, list_plans};
use crate::routes::relays::{
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
list_relays, reactivate_relay, update_relay,
};
use crate::routes::stripe::{create_stripe_session, stripe_webhook};
use crate::routes::tenants::{
create_tenant, get_tenant, list_tenant_relays, list_tenants, update_tenant,
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
list_tenants, update_tenant,
};
use crate::stripe::Stripe;
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
#[derive(Clone)]
pub struct Api {
pub env: Env,
pub query: Query,
pub command: Command,
pub billing: Billing,
pub stripe: Stripe,
pub robot: Robot,
pub infra: Infra,
}
impl Api {
pub fn new(
query: Query,
command: Command,
billing: Billing,
stripe: Stripe,
robot: Robot,
infra: Infra,
env: &Env,
) -> Self {
pub fn new(billing: Billing, stripe: Stripe, robot: Robot) -> Self {
Self {
env: env.clone(),
query,
command,
billing,
stripe,
robot,
infra,
}
}
@@ -90,24 +71,27 @@ impl Api {
.route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route(
"/tenants/:pubkey/stripe/session",
get(create_stripe_session),
)
.route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/bolt11", get(get_lightning_invoice))
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/stripe/webhook", post(stripe_webhook))
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
.route("/invoices/:id/items", get(list_invoice_items))
.with_state(api)
}
// --- authorization helpers ----------------------------------------------
pub fn is_admin(&self, pubkey: &str) -> bool {
self.env.server_admin_pubkeys.iter().any(|a| a == pubkey)
env::get().server_admin_pubkeys.iter().any(|a| a == pubkey)
}
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> {
@@ -118,6 +102,18 @@ impl Api {
}
}
pub fn require_tenant(
&self,
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> Result<(), ApiError> {
if authorized_pubkey == tenant_pubkey {
Ok(())
} else {
Err(forbidden("not authorized"))
}
}
pub fn require_admin_or_tenant(
&self,
authorized_pubkey: &str,
@@ -131,7 +127,7 @@ impl Api {
}
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> {
match self.query.get_tenant(pubkey).await {
match query::get_tenant(pubkey).await {
Ok(Some(t)) => Ok(t),
Ok(None) => Err(not_found("tenant not found")),
Err(e) => Err(internal(e)),
@@ -139,7 +135,7 @@ impl Api {
}
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> {
match self.query.get_relay(id).await {
match query::get_relay(id).await {
Ok(Some(r)) => Ok(r),
Ok(None) => Err(not_found("relay not found")),
Err(e) => Err(internal(e)),
@@ -189,7 +185,7 @@ impl Api {
.ok_or_else(|| anyhow!("missing u tag"))?;
ensure!(
self.env.server_host.is_empty() || got_u.contains(&self.env.server_host),
got_u == env::get().server_host,
"authorization host mismatch"
);
+489 -247
View File
@@ -1,82 +1,74 @@
use anyhow::{Result, anyhow};
use std::collections::BTreeMap;
use std::time::Duration;
use crate::bitcoin;
use crate::command::Command;
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
use crate::query::Query;
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
use crate::command;
use crate::env;
use crate::models::{
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
};
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::wallet::Wallet;
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60;
/// Hold the manual-payment DM until an open invoice is at least this old. A freshly
/// issued invoice is surfaced to the tenant in-app first (e.g. right after they
/// create a relay), so we don't also nag by DM on the first dunning cycles.
const FRESH_INVOICE_DM_GRACE_SECS: i64 = 24 * 60 * 60;
const MANUAL_PAYMENT_DM_INTERVAL_SECS: i64 = 12 * 24 * 60 * 60;
const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
const CHURN_DM: &str = "Your relay subscription is past due, so your relays have been paused. You can restore service any time by adding a payment method or paying an invoice from your dashboard:";
/// Owns subscription billing: it reconciles tenant activity into invoice items,
/// renews subscriptions each period, and collects payment (Lightning, then a
/// card on file, then a manual DM link).
#[derive(Clone)]
pub struct Billing {
stripe: Stripe,
wallet: Wallet,
query: Query,
command: Command,
env: Env,
robot: Robot,
}
impl Billing {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
pub fn new(robot: Robot) -> Self {
Self {
stripe: Stripe::new(env),
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
query,
command,
env: env.clone(),
stripe: Stripe::new(),
wallet: Wallet::from_url(&env::get().robot_wallet).expect("invalid ROBOT_WALLET"),
robot,
}
}
// --- lifecycle methods ---
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_subscriptions("startup").await {
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
}
let mut interval = tokio::time::interval(POLL_INTERVAL);
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = self.handle_activity(&activity).await {
tracing::error!(error = %e, "billing handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "billing lagged");
interval.tick().await;
if let Err(error) = self.reconcile_subscriptions("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
if let Err(error) = self.reconcile_subscriptions().await {
tracing::error!(error = %error, "billing poll failed");
}
}
}
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
let tenants = self.query.list_tenants().await?;
if tenants.is_empty() {
return Ok(());
}
async fn reconcile_subscriptions(&self) -> Result<()> {
let tenants = query::list_tenants().await?;
tracing::info!(
source,
tenant_count = tenants.len(),
"reconciling relay billing state"
"reconciling all subscriptions"
);
for tenant in tenants {
if let Err(error) = self.reconcile_subscription(&tenant).await {
if let Err(error) = self.reconcile_subscription(&tenant, true).await {
tracing::error!(
source,
tenant = %tenant.pubkey,
error = ?error,
"failed to reconcile relay billing state"
"failed to reconcile subscription"
);
}
}
@@ -84,266 +76,516 @@ impl Billing {
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!(
activity.activity_type.as_str(),
"create_relay"
| "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
);
// --- Reconciliation of activity/renewals ---
if needs_billing_sync
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
/// Reconciles a tenant's billing: re-activates them if a churned tenant has
/// re-engaged, folds billable activity into line items (setting the billing
/// anchor based on the first one), renews the current period if due, and claims
/// outstanding items onto an invoice.
pub async fn reconcile_subscription(
&self,
tenant: &Tenant,
attempt_payment: bool,
) -> Result<()> {
let mut tenant = tenant.clone();
let activities = query::list_billable_activity(&tenant.pubkey).await?;
// A churned tenant with fresh billable activity is using the service
// again: re-activate billing (and restore their relays) before billing it.
if tenant.churned_at.is_some() && !activities.is_empty() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::reactivate_tenant(&tenant.pubkey, &relays).await?;
tenant.churned_at = None;
}
// Reconcile all activity, setting the tenant's billing anchor on the first
// activity if not already set.
for activity in activities {
if tenant.billing_anchor.is_none() {
tenant.billing_anchor = Some(activity.created_at);
command::set_tenant_billing_anchor(&tenant).await?;
}
self.reconcile_activity(&tenant, &activity).await?;
}
// If the tenant has a billing anchor, renew the current period if due and
// claim any outstanding items onto an invoice.
if let Some(period) = BillingPeriod::current(&tenant) {
if tenant.renewed_at.is_none_or(|at| at < period.start) {
self.reconcile_renewal(&tenant, &period).await?;
}
command::create_invoice(&tenant, &period).await?;
}
// Attempt payment on every open invoice after syncing with stripe.
if attempt_payment {
self.sync_stripe_customer(&mut tenant).await?;
self.collect_open_invoices(&tenant).await?;
}
Ok(())
}
// --- Subscriptions ---
/// Reconciles a tenant's single Stripe subscription with the set of relays that
/// should be billed.
///
/// 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.
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
// If we've got no subscription items, we can cancel and clear the tenant's subscription
if quantity_by_price_id.is_empty() {
self.ensure_subscription_is_inactive(tenant).await?;
return Ok(());
}
let subscription = self
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
.await?;
self.ensure_subscription_items(subscription, quantity_by_price_id).await
}
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
let mut quantity_by_price_id = BTreeMap::new();
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? {
if relay.status != RELAY_STATUS_ACTIVE {
continue;
/// Reconcile one activity into the ledger: build its line item (if any) and
/// persist it with the activity's billed marker. Activities that produce no
/// item (e.g. free-plan changes) are still marked billed so they aren't
/// re-scanned.
async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result<()> {
let invoice_item = match activity.activity_type.as_str() {
"create_relay" => {
self.make_prorated_item(tenant, activity, 1, "New relay created")
.await?
}
let Some(price_id) = self.query.get_plan(&relay.plan).and_then(|p| p.stripe_price_id) else {
continue;
};
*quantity_by_price_id.entry(price_id).or_insert(0) += 1;
}
Ok(quantity_by_price_id)
}
/// Fetch the tenant's current subscription from Stripe, if it has one
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> {
let subscription = match &tenant.stripe_subscription_id {
Some(id) => self.stripe.get_subscription(id).await?,
None => None,
"activate_relay" => {
self.make_prorated_item(tenant, activity, 1, "Relay reactivated")
.await?
}
"deactivate_relay" => {
self.make_prorated_item(tenant, activity, -1, "Relay deactivated (prorated credit)")
.await?
}
"update_relay" => self.make_plan_change_item(tenant, activity).await?,
_ => None,
};
// If it's canceled, clear the subscription id and return nothing for simplicity
if subscription
.as_ref()
.is_some_and(|s| matches!(s.status.as_str(), "canceled" | "incomplete_expired"))
{
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
match invoice_item {
Some(ref item) => command::insert_invoice_item_for_activity(item, &activity.id).await,
None => command::mark_activity_billed(&activity.id).await,
}
}
/// A prorated charge (or credit, with `sign` = -1) for the plan recorded on
/// the activity's snapshot — the plan as of the activity, not the relay's
/// current plan — covering the fraction of the period remaining at the
/// activity. `None` for a free plan.
async fn make_prorated_item(
&self,
tenant: &Tenant,
activity: &Activity,
sign: i64,
description: &str,
) -> Result<Option<InvoiceItem>> {
let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot;
let plan = query::get_plan(plan_id)?;
if plan.amount <= 0 {
return Ok(None);
}
Ok(subscription)
let period = BillingPeriod::at(tenant, activity.created_at)
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let amount = sign * period.prorate(plan.amount, activity.created_at);
Ok(Some(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: Some(activity.id.clone()),
tenant_pubkey: activity.tenant_pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan_id: plan.id,
amount,
description: description.to_string(),
created_at: activity.created_at,
}))
}
/// Make sure the tenant has an active subscription, creating one with the desired
/// items if it doesn't (Stripe rejects an itemless subscription).
async fn ensure_subscription_is_active(
/// The prorated delta for a plan change, read straight from the activity log:
/// `new` is this `update_relay` activity's recorded plan, `old` is the relay's
/// plan immediately before it. Because the renewal charges the relay's plan as
/// of the period boundary, this delta composes to the correct total regardless
/// of ordering and needs no coverage gate. `None` when nothing changed.
async fn make_plan_change_item(
&self,
tenant: &Tenant,
quantity_by_price_id: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
if let Some(sub) = self.get_subscription(tenant).await? {
return Ok(sub);
activity: &Activity,
) -> Result<Option<InvoiceItem>> {
let new_plan_id = match &*activity.snapshot {
Snapshot::Relay { plan, .. } => plan,
};
let Some(old_plan_id) =
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
else {
return Err(anyhow!("no previous plan found for relay update activity"));
};
if &old_plan_id == new_plan_id {
return Ok(None);
}
let new_plan = query::get_plan(new_plan_id)?;
let old_plan = query::get_plan(&old_plan_id)?;
let period = BillingPeriod::at(tenant, activity.created_at)
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let amount = period.prorate(new_plan.amount, activity.created_at)
- period.prorate(old_plan.amount, activity.created_at);
if amount == 0 {
return Ok(None);
}
let sub = self
.stripe
.create_subscription(&tenant.stripe_customer_id, quantity_by_price_id)
.await?;
self.command.set_tenant_subscription(&tenant.pubkey, &sub.id).await?;
Ok(sub)
let description = format!("Plan changed from {} to {}", old_plan.name, new_plan.name);
Ok(Some(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: Some(activity.id.clone()),
tenant_pubkey: activity.tenant_pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan_id: new_plan.id,
amount,
description,
created_at: activity.created_at,
}))
}
/// If the tenant has a subscription, cancel and clear it
async fn ensure_subscription_is_inactive(&self, tenant: &Tenant) -> Result<()> {
if let Some(s) = self.get_subscription(tenant).await? {
self.stripe.cancel_subscription(&s.id).await?;
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
/// Charge a full-period renewal for every relay that was active on a paid plan
/// as of `period.start`, reading that state from each relay's most recent
/// activity snapshot before the boundary (relays with no prior activity didn't
/// exist yet and are skipped). Idempotent per period via the tenant's
/// `renewed_at` marker, so calling it on every generation can't renew twice;
/// a relay created/activated *within* the period isn't active before the
/// boundary, so it's covered by its own prorated charge instead.
async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
let mut line_items = Vec::new();
for relay in relays {
let Some(activity) =
query::get_latest_relay_activity_before(&relay.id, period.start).await?
else {
continue;
};
let Snapshot::Relay {
plan: plan_id,
status,
..
} = &*activity.snapshot;
if status != RELAY_STATUS_ACTIVE {
continue;
}
let plan = query::get_plan(plan_id)?;
if plan.amount <= 0 {
continue;
}
line_items.push(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: None,
tenant_pubkey: tenant.pubkey.clone(),
relay_id: relay.id,
plan_id: plan.id,
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period.start,
});
}
// Inserts the items and advances `renewed_at` to `period.start` in one
// transaction (idempotent via an in-tx guard), so a re-tick is a no-op.
command::insert_invoice_items_for_renewal(&line_items, period).await
}
// --- Payments ---
/// Dunning pass over a tenant's open invoices: if the oldest has been unpaid
/// past the grace period, churn the tenant; otherwise retry payment on each.
async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> {
let open = query::list_open_invoices(&tenant.pubkey).await?;
let Some(oldest) = open.first() else {
return Ok(());
};
let now = chrono::Utc::now().timestamp();
if now - oldest.created_at >= GRACE_PERIOD_SECS {
if tenant.churned_at.is_none() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
// Notify the tenant once, on the transition into churn (the guard
// above fires this a single time). Log-and-continue on failure.
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
}
}
return Ok(());
}
for invoice in &open {
self.attempt_payment(tenant, invoice).await?;
}
Ok(())
}
/// Sync desired quantity_by_price_id with stripe
async fn ensure_subscription_items(
&self,
subscription: StripeSubscription,
quantity_by_price_id: BTreeMap<String, i64>,
) -> Result<()> {
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
for item in subscription.items {
current.insert(item.price.id, (item.id, item.quantity));
}
/// Collect an invoice via NWC, then a saved card, then a manual DM. A failing
/// method's error is stored on the tenant (to warn them in the UI) but never
/// aborts the cascade or future retries; a method's error is cleared when it
/// next succeeds.
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let mut error_message: Option<String> = None;
for (price_id, &quantity) in &quantity_by_price_id {
if let Some((item_id, current_quantity)) = current.remove(price_id) {
if current_quantity != quantity {
self.stripe
.set_subscription_item_quantity(&item_id, quantity)
.await?;
}
} else {
self.stripe
.create_subscription_item(&subscription.id, price_id, quantity)
.await?;
// 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
if !tenant.nwc_url.is_empty() {
match self.attempt_payment_using_nwc(tenant, invoice).await {
Ok(()) => return Ok(()),
Err(e) => error_message = Some(format!("{e}")),
}
}
for (_, (item_id, _)) in current {
self.stripe.delete_subscription_item(&item_id).await?;
// 2. Out-of-band lightning: catches partially failed NWC or manual payment
if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await?
&& bolt11.settled_at.is_none()
&& self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false)
{
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
return Ok(());
}
// 3. Payment method on file: charge the tenant's cached Stripe payment
// method, kept fresh by sync_stripe_payment_method before collection.
if let Some(payment_method) = &tenant.stripe_payment_method_id {
match self
.attempt_payment_using_stripe(tenant, invoice, payment_method)
.await
{
Ok(()) => return Ok(()),
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
}
}
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
if let Err(e) = self
.attempt_payment_using_dm(tenant, invoice, error_message)
.await
{
tracing::error!(
tenant = %tenant.pubkey,
error = %e,
"failed to send manual payment DM"
);
}
Ok(())
}
// --- Invoices ---
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let result: Result<()> = async {
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
let bolt11 = self.ensure_bolt11(invoice).await?;
/// return or generate a lightning invoice for an open stripe invoice
pub async fn ensure_lightning_invoice(
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
command::settle_invoice_via_nwc(&tenant.pubkey, &bolt11.id, &invoice.id).await
}
.await;
// Record the failure on the tenant (to warn them in the UI) but still
// surface it, so the cascade can fall through and summarize it in the DM.
if let Err(error) = &result {
command::set_tenant_nwc_error(&tenant.pubkey, &format!("{error}")).await?;
}
result
}
async fn attempt_payment_using_stripe(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
amount_due: i64,
currency: &str,
) -> Result<LightningInvoice> {
tenant: &Tenant,
invoice: &Invoice,
payment_method_id: &str,
) -> Result<()> {
let result: Result<()> = async {
let intent_id = self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
payment_method_id,
&invoice.id,
invoice.amount,
"usd",
)
.await?;
command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await
}
.await;
// Record the failure on the tenant (to warn them in the UI) but still
// surface it, so the cascade can fall through and summarize it in the DM.
if let Err(error) = &result {
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
}
result
}
async fn attempt_payment_using_dm(
&self,
tenant: &Tenant,
invoice: &Invoice,
error: Option<String>,
) -> Result<()> {
let now = chrono::Utc::now().timestamp();
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await?
&& (existing.status != "pending" || now < existing.expires_at)
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
if now - invoice.created_at < FRESH_INVOICE_DM_GRACE_SECS {
return Ok(());
}
// The dunning poll runs hourly; avoid excessive reminder DMs.
if invoice.notified_at.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS) {
return Ok(());
}
// Build the message
let invoice_id = &invoice.id;
let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match error {
Some(error) if !error.is_empty() => {
let limit: usize = 240;
let summary = error.chars().take(limit.saturating_sub(3)).collect::<String>();
format!("{base}\n\nAuto-payment failed: {summary}")
}
_ => base,
};
// Send via NIP 17
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
// Record the send to avoid spammy notifications.
command::mark_invoice_notified(invoice_id).await
}
// --- Bolt11 utils ---
pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
let now = chrono::Utc::now().timestamp();
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
&& (existing.settled_at.is_none() || now < existing.expires_at)
{
return Ok(existing);
}
let expiry: i64 = 3600;
let info = "Relay subscription payment";
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let msats = bitcoin::fiat_to_msats(invoice.amount, "usd").await?;
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let invoice = match self
.command
.insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
command::insert_bolt11(&invoice.id, &lnbc, msats as i64, now + expiry)
.await?
{
Some(invoice) => invoice,
None => self
.query
.get_lightning_invoice(stripe_invoice_id)
.await?
.ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
};
Ok(invoice)
}
/// Attempt to pay and settle an invoice via nwc
pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await {
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
Err(pay_error) => {
// The pay request errored, but the payment may have landed
// before the response was lost. Confirm against the system
// wallet before reporting failure.
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
} else {
Err(pay_error)
}
}
}
.ok_or_else(|| anyhow!("failed to insert bolt11"))
}
/// Catch an out-of-band payment we never recorded — e.g. the user paid the
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has
/// settled on the robot wallet, mark it paid and return the refreshed Stripe
/// invoice; otherwise return it unchanged. Meant to run before presenting a
/// payable invoice so we never hand back one that's already been paid.
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
if invoice.status != "open" {
return Ok(invoice.clone());
/// settled on the robot wallet, mark it paid and return the refreshed record;
/// otherwise return it unchanged. Meant to run before presenting a payable
/// invoice so we never hand back one that's already been paid.
pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
let bolt11 = self.ensure_bolt11(invoice).await?;
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
// Re-fetch so the caller sees that it's been settled.
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
} else {
Ok(bolt11)
}
}
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
return Ok(invoice.clone());
};
// --- Stripe utils ---
let settled = match self.wallet.is_settled(&row.bolt11).await {
Ok(settled) => settled,
/// Copy down any stripe-related stuff to our local tenant model. Fail gracefully.
pub async fn sync_stripe_customer(&self, tenant: &mut Tenant) -> Result<()> {
match self.sync_stripe_payment_method(tenant).await {
Ok(payment_method_id) => {
tenant.stripe_payment_method_id = payment_method_id;
}
Err(error) => {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to look up bolt11 invoice settlement"
);
return Ok(invoice.clone());
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
}
};
if !settled {
return Ok(invoice.clone());
}
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to record settled bolt11 invoice as paid"
);
}
// Re-fetch so the caller sees the now-paid status; fall back to the
// pre-reconcile snapshot if Stripe momentarily 404s.
Ok(self
.stripe
.get_invoice(&invoice.id)
.await?
.unwrap_or_else(|| invoice.clone()))
}
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
/// first-writer-wins, so the recorded method reflects whoever settled first.
async fn settle_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
method: &str,
) -> Result<()> {
self.command
.mark_lightning_invoice_paid(stripe_invoice_id, method)
.await?;
self.stripe.pay_invoice_out_of_band(stripe_invoice_id).await?;
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
Ok(())
}
/// Refresh the cached Stripe payment method from Stripe so collection can charge
/// it directly and the UI reflects cards added via the portal.
async fn sync_stripe_payment_method(&self, tenant: &Tenant) -> Result<Option<String>> {
let payment_method_id = self
.stripe
.get_saved_payment_method(&tenant.stripe_customer_id)
.await?;
if payment_method_id != tenant.stripe_payment_method_id {
command::set_tenant_stripe_payment_method(&tenant.pubkey, &payment_method_id).await?;
}
Ok(payment_method_id)
}
}
/// One tenant's monthly billing period containing some timestamp, anchored at
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
/// exactly `end` belongs to the next period.
pub struct BillingPeriod {
pub start: i64,
pub end: i64,
}
impl BillingPeriod {
/// The period containing `chrono::Utc::now()` for `tenant`. `None` when the
/// tenant has no `billing_anchor` yet — i.e. no billable activity has been seen.
fn current(tenant: &Tenant) -> Option<Self> {
Self::at(tenant, chrono::Utc::now().timestamp())
}
/// The period containing `at` for `tenant`. `None` when the tenant has no
/// `billing_anchor` yet — i.e. no billable activity has been seen.
fn at(tenant: &Tenant, at: i64) -> Option<Self> {
use chrono::{DateTime, Months, Utc};
let anchor = tenant.billing_anchor?;
let anchor_dt = DateTime::<Utc>::from_timestamp(anchor, 0).unwrap_or_default();
// Walk forward in whole calendar months from the anchor until the next
// step would pass `at`, so boundaries track months (2831 days) rather
// than a fixed span of seconds.
let mut start = anchor_dt;
let mut months = 1u32;
while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) {
if next.timestamp() > at {
break;
}
start = next;
months += 1;
}
let end = start.checked_add_months(Months::new(1)).unwrap_or(start);
Some(Self {
start: start.timestamp(),
end: end.timestamp(),
})
}
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// prorating a mid-period charge or credit.
fn fraction_remaining(&self, at: i64) -> f64 {
let len = (self.end - self.start) as f64;
if len <= 0.0 {
return 1.0;
}
(((self.end - at) as f64) / len).clamp(0.0, 1.0)
}
/// Prorate a minor-unit `amount` by the fraction of this period remaining
/// at `at`, rounded to the nearest unit.
fn prorate(&self, amount: i64, at: i64) -> i64 {
(amount as f64 * self.fraction_remaining(at)).round() as i64
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
use anyhow::{Result, anyhow};
/// Convert a fiat amount in minor units (e.g. USD cents) to millisatoshis at the
/// current spot price, for pricing a Lightning invoice from an invoice total.
pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
let price = get_bitcoin_price(&currency.to_uppercase()).await?;
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
@@ -18,14 +20,14 @@ struct CoinbaseSpotPriceData {
amount: String,
}
/// The current Bitcoin spot price in `currency`, from Coinbase.
pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
let http = reqwest::Client::new();
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
body
.data
body.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
+676 -337
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::OnceLock;
use anyhow::Result;
use sqlx::{
Sqlite, SqlitePool, Transaction,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::sync::broadcast;
use crate::env;
use crate::models::Activity;
/// Process-wide connection pool. Set once at startup via [`init`]; read
/// everywhere else via [`pool`], so command/query stay free functions instead of
/// threading a handle through every service.
static POOL: OnceLock<SqlitePool> = OnceLock::new();
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
/// [`publish`] after their transaction commits; reactors (billing, infra)
/// [`subscribe`] to react to durable changes.
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
/// Create the connection pool from `env`, run migrations, and store it as the
/// process-wide global. Panics if called more than once.
pub async fn init() -> Result<()> {
let pool = create_pool(&env::get().database_url).await?;
POOL.set(pool).expect("pool already initialized");
let (notify, _) = broadcast::channel(64);
NOTIFY.set(notify).expect("notify already initialized");
Ok(())
}
/// The global pool. Panics if [`init`] hasn't run yet.
pub fn pool() -> &'static SqlitePool {
POOL.get().expect("pool not initialized")
}
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
pub fn subscribe() -> broadcast::Receiver<Activity> {
NOTIFY.get().expect("notify not initialized").subscribe()
}
/// Broadcast an activity to subscribers. Called after the writing transaction
/// commits, so reactors only ever observe durable rows. A send with no current
/// subscribers is intentionally ignored.
pub fn publish(activity: Activity) {
if let Some(notify) = NOTIFY.get() {
let _ = notify.send(activity);
}
}
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
/// it returns an error. Returns whatever `f` produces. Callers compose the
/// transaction-scoped `command`/`query` functions inside `f` to make a
/// multi-step write atomic.
pub async fn with_tx<F, T>(f: F) -> Result<T>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
{
let mut tx = pool().begin().await?;
let value = f(&mut tx).await?;
tx.commit().await?;
Ok(value)
}
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+19 -7
View File
@@ -1,6 +1,24 @@
use std::sync::OnceLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
/// Process-wide configuration, loaded once from the environment at startup via
/// [`init`] and read everywhere else via [`get`].
static ENV: OnceLock<Env> = OnceLock::new();
/// Load configuration from the environment and store it as the global. Panics
/// if a required variable is missing or if called more than once.
pub fn init() {
ENV.set(Env::load())
.unwrap_or_else(|_| panic!("env already initialized"));
}
/// The global configuration. Panics if [`init`] hasn't run yet.
pub fn get() -> &'static Env {
ENV.get().expect("env not initialized")
}
#[derive(Clone)]
pub struct Env {
pub server_host: String,
@@ -27,15 +45,12 @@ pub struct Env {
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
pub stripe_webhook_secret: String,
pub stripe_price_basic: String,
pub stripe_price_growth: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
pub fn load() -> Self {
fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
@@ -64,9 +79,6 @@ impl Env {
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
keys,
}
}
+256 -267
View File
@@ -1,306 +1,295 @@
//! The relay-provisioning reactor: it keeps the external relay backend (the
//! zooid API) in sync with our relay rows, reacting to relay activity and
//! retrying failed syncs with backoff.
use anyhow::Result;
use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::env::Env;
use crate::command;
use crate::db;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query;
use crate::query;
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
#[derive(Clone)]
pub struct Infra {
env: Env,
query: Query,
command: Command,
/// Run the reactor for the life of the process: reconcile any relays left
/// unsynced from a previous run, then sync each relay as its activity arrives.
pub async fn start() {
let mut rx = db::subscribe();
if let Err(error) = reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = handle_activity(&activity).await {
tracing::error!(error = %e, "infra handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
if let Err(error) = reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
impl Infra {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
Self {
env: env.clone(),
query,
command,
async fn handle_activity(activity: &Activity) -> Result<()> {
let needs_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
);
if activity.resource_type != "relay" || !needs_sync {
return Ok(());
}
if activity.activity_type == "fail_relay_sync" {
schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(());
};
sync_relay(&relay).await;
Ok(())
}
async fn reconcile_relay_state(source: &str) -> Result<()> {
let relays = query::list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(
source,
relay_count = relays.len(),
"reconciling pending relay state"
);
for relay in relays {
if relay.sync_error.trim().is_empty() {
sync_relay(&relay).await;
} else {
schedule_relay_sync_retry(&relay.id, source).await?;
}
}
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
Ok(())
}
if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
async fn schedule_relay_sync_retry(relay_id: &str, source: &str) -> Result<()> {
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
let retry_attempt = consecutive_failures.max(1);
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
return None;
}
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = self.handle_activity(&activity).await {
tracing::error!(error = %e, "infra handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
let exponent = (retry_attempt - 1).min(31);
let multiplier = 1u64 << exponent;
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
.saturating_mul(multiplier)
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
if let Err(error) = self.reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
Some(Duration::from_secs(delay_secs))
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
);
let activities = query::list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count();
if activity.resource_type != "relay" || !needs_sync {
return Ok(());
}
if activity.activity_type == "fail_relay_sync" {
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
self.sync_relay(&relay).await;
Ok(())
}
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
let relays = self.query.list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
for relay in relays {
if relay.sync_error.trim().is_empty() {
self.sync_relay(&relay).await;
} else {
self.schedule_relay_sync_retry(&relay.id, source).await?;
}
}
Ok(())
}
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
let retry_attempt = consecutive_failures.max(1);
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
return None;
}
let exponent = (retry_attempt - 1).min(31);
let multiplier = 1u64 << exponent;
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
.saturating_mul(multiplier)
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
Some(Duration::from_secs(delay_secs))
}
let activities = self.query.list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count();
let Some(delay) = get_retry_delay(consecutive_failures) else {
tracing::warn!(
relay = relay_id,
consecutive_failures,
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
"relay sync retries exhausted; awaiting manual intervention"
);
return Ok(());
};
tracing::info!(
let Some(delay) = get_retry_delay(consecutive_failures) else {
tracing::warn!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
"relay sync retries exhausted; awaiting manual intervention"
);
return Ok(());
};
let relay_id = relay_id.to_string();
let infra = self.clone();
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let relay_id = relay_id.to_string();
match infra.query.get_relay(&relay_id).await {
Ok(Some(relay)) => infra.sync_relay(&relay).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
}
});
tokio::spawn(async move {
tokio::time::sleep(delay).await;
Ok(())
}
async fn sync_relay(&self, relay: &Relay) {
match self.try_sync_relay(relay).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
}
}
match query::get_relay(&relay_id).await {
Ok(Some(relay)) => sync_relay(&relay).await,
Ok(None) => {}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
}
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
}
}
});
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> {
// A relay is "new" (POST with a freshly generated secret) only if it has
// never completed a sync. `synced == 1` short-circuits the activity lookup;
// otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
let is_new = relay.synced != 1
&& self
.query
.get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
Ok(())
}
let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain),
"schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
"description": relay.info_description,
"pubkey": relay.tenant,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": if relay.blossom_enabled == 1 {
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": self.env.blossom_s3_endpoint,
"region": self.env.blossom_s3_region,
"bucket": self.env.blossom_s3_bucket,
"access_key": self.env.blossom_s3_access_key,
"secret_key": self.env.blossom_s3_secret_key,
"key_prefix": relay.id,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.env.livekit_url,
"api_key": self.env.livekit_api_key,
"api_secret": self.env.livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side.
if is_new && let Some(obj) = body.as_object_mut() {
obj.insert(
"secret".to_string(),
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()),
);
async fn sync_relay(relay: &Relay) {
match try_sync_relay(relay).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = command::complete_relay_sync(relay).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
}
}
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
self.request(method, &format!("relay/{}", relay.id), Some(&body))
.await?;
Ok(())
}
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
members: Vec<String>,
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
}
}
let response = self
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
.await?;
let parsed: MembersResponse = response.json().await?;
Ok(parsed.members)
}
// Internal utilities
/// Sends an authenticated request to the zooid API at `path` (relative to
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
/// text otherwise.
async fn request(
&self,
method: HttpMethod,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<reqwest::Response> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let base = self.env.zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = self.env.make_auth(&url, method).await?;
let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::PATCH => reqwest::Method::PATCH,
};
let mut req = client
.request(reqwest_method, &url)
.header("Authorization", auth);
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
}
Ok(response)
}
}
async fn try_sync_relay(relay: &Relay) -> Result<()> {
// A relay is "new" (POST with a freshly generated secret) only if it has
// never completed a sync. `synced == 1` short-circuits the activity lookup;
// otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
let is_new = relay.synced != 1
&& query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
"schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
"description": relay.info_description,
"pubkey": relay.tenant_pubkey,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": if relay.blossom_enabled == 1 {
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": env::get().blossom_s3_endpoint,
"region": env::get().blossom_s3_region,
"bucket": env::get().blossom_s3_bucket,
"access_key": env::get().blossom_s3_access_key,
"secret_key": env::get().blossom_s3_secret_key,
"key_prefix": relay.id,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": env::get().livekit_url,
"api_key": env::get().livekit_api_key,
"api_secret": env::get().livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side.
if is_new && let Some(obj) = body.as_object_mut() {
obj.insert(
"secret".to_string(),
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()),
);
}
let method = if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
};
request(method, &format!("relay/{}", relay.id), Some(&body)).await?;
Ok(())
}
/// Fetch the member pubkeys of a relay from the zooid API.
pub async fn list_relay_members(relay_id: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
members: Vec<String>,
}
let response = request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None).await?;
let parsed: MembersResponse = response.json().await?;
Ok(parsed.members)
}
/// Sends an authenticated request to the zooid API at `path` (relative to
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
/// text otherwise.
async fn request(
method: HttpMethod,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<reqwest::Response> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let base = env::get().zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = env::get().make_auth(&url, method).await?;
let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::PATCH => reqwest::Method::PATCH,
};
let mut req = client
.request(reqwest_method, &url)
.header("Authorization", auth);
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
}
Ok(response)
}
+1 -1
View File
@@ -2,10 +2,10 @@ pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod db;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
pub mod query;
pub mod robot;
pub mod routes;
+18 -20
View File
@@ -2,10 +2,10 @@ mod api;
mod billing;
mod bitcoin;
mod command;
mod db;
mod env;
mod infra;
mod models;
mod pool;
mod query;
mod robot;
mod routes;
@@ -16,14 +16,10 @@ mod web;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
use crate::stripe::Stripe;
@@ -36,18 +32,16 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let env = Env::load();
env::init();
let pool = pool::create_pool(&env.database_url).await?;
let robot = Robot::new(&env).await?;
let stripe = Stripe::new(&env);
let query = Query::new(pool.clone(), &env);
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), &env);
let infra = Infra::new(query.clone(), command.clone(), &env);
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env);
db::init().await?;
let parsed = env
let robot = Robot::new().await?;
let stripe = Stripe::new();
let billing = Billing::new(robot.clone());
let api = Api::new(billing.clone(), stripe, robot);
let parsed = env::get()
.server_allow_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
@@ -59,16 +53,20 @@ async fn main() -> Result<()> {
let app = api.router().layer(cors);
tokio::spawn(async move {
infra.start().await;
tokio::spawn(async {
infra::start().await;
});
tokio::spawn(async move {
billing.start().await;
});
let listener =
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
let listener = tokio::net::TcpListener::bind(format!(
"{}:{}",
env::get().server_host,
env::get().server_port
))
.await?;
axum::serve(listener, app).await?;
Ok(())
}
+96 -22
View File
@@ -1,17 +1,26 @@
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
pub const RELAY_STATUS_ACTIVE: &str = "active";
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity {
pub id: String,
pub tenant: String,
pub created_at: i64,
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
/// Per-resource_type snapshot of a resource's state captured on each activity,
/// stored as JSON in `activity.snapshot`. Tagged on `resource_type` so the JSON
/// is self-describing and the variant matches the activity row's column. Add a
/// variant per resource type that needs state preserved on the activity log.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "resource_type", rename_all = "snake_case")]
pub enum Snapshot {
Relay { plan: String, status: String },
}
impl Snapshot {
pub fn resource_type(&self) -> &'static str {
match self {
Self::Relay { .. } => "relay",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -22,38 +31,53 @@ pub struct Plan {
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
pub stripe_price_id: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Tenant {
pub pubkey: String,
pub nwc_url: String,
/// Last NWC auto-payment error, or `None` when the wallet last paid (or has
/// never been tried). Surfaced in the UI to warn the user; it never blocks a
/// retry — the next reconcile attempts payment again regardless. Also cleared
/// when the tenant updates their NWC credentials.
pub nwc_error: Option<String>,
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
pub stripe_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
/// The tenant's saved Stripe payment method, or `None` if they have not set
/// up a card yet. Set when the tenant adds a card via the Stripe portal.
pub stripe_payment_method_id: Option<String>,
/// `period_start` of the most recent period this tenant was renewed for, or
/// `None` if never renewed. The per-period renewal idempotency marker.
pub renewed_at: Option<i64>,
/// When the tenant was churned because an invoice went unpaid past the grace
/// period; its relays are delinquent while this is set. Cleared when billing
/// is re-activated (the tenant has new billable activity), at which point the
/// then-open invoices are voided rather than collected. `None` in good standing.
pub churned_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct LightningInvoice {
pub stripe_invoice_id: String,
pub struct Activity {
pub id: String,
pub tenant_pubkey: String,
pub bolt11: String,
pub status: String,
pub paid_method: Option<String>,
pub expires_at: i64,
pub created_at: i64,
pub updated_at: i64,
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
pub billed_at: Option<i64>,
pub snapshot: Json<Snapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Relay {
pub id: String,
pub tenant: String,
pub tenant_pubkey: String,
pub subdomain: String,
pub plan: String,
pub plan_id: String,
pub status: String,
pub sync_error: String,
pub info_name: String,
@@ -73,9 +97,9 @@ impl Default for Relay {
fn default() -> Self {
Self {
id: String::new(),
tenant: String::new(),
tenant_pubkey: String::new(),
subdomain: String::new(),
plan: String::new(),
plan_id: String::new(),
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: String::new(),
@@ -92,3 +116,53 @@ impl Default for Relay {
}
}
}
/// A tenant's bill for one period. Its lifecycle is recorded as timestamps
/// rather than a status column: open while both `paid_at` and `voided_at` are
/// null, paid once `paid_at` is set, and void once `voided_at` is set (e.g. a
/// balance forgiven when the tenant churns).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice {
pub id: String,
pub tenant_pubkey: String,
/// The total owed, fixed when the invoice is cut from its outstanding line
/// items, so collection never has to re-sum them.
pub amount: i64,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
/// When the manual-payment reminder DM was sent for this invoice, or `None` if
/// it hasn't been sent in order to avoid duplicate reminders for the same invoice.
pub notified_at: Option<i64>,
/// How the invoice was paid — `nwc`, `stripe`, or `oob` (out-of-band
/// Lightning) — set when it is marked paid; `None` while open or void.
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
/// `None` while outstanding; set once the item is claimed onto an invoice.
pub invoice_id: Option<String>,
/// `None` for renewal items, which have no source activity.
pub activity_id: Option<String>,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan_id: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Bolt11 {
pub id: String,
pub invoice_id: String,
pub lnbc: String,
pub msats: i64,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+228 -155
View File
@@ -1,8 +1,7 @@
use anyhow::Result;
use sqlx::SqlitePool;
use anyhow::{Result, anyhow};
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
use crate::db::pool;
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
@@ -16,161 +15,235 @@ fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
env: Env,
// --- Plans ---
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
},
]
}
impl Query {
pub fn new(pool: SqlitePool, env: &Env) -> Self {
Self {
pool,
env: env.clone(),
}
}
pub fn get_plan(plan_id: &str) -> Result<Plan> {
list_plans()
.into_iter()
.find(|p| p.id == plan_id)
.ok_or_else(|| anyhow!("plan not found: {plan_id}"))
}
// Plans
// --- Tenants ---
pub fn list_plans(&self) -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
stripe_price_id: None,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
stripe_price_id: Some(self.env.stripe_price_basic.clone()),
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
},
]
}
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(pool())
.await?)
}
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
self.list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(&self, plan_id: &str) -> bool {
self.get_plan(plan_id).is_some_and(|p| p.amount > 0)
}
// Tenants
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn get_tenant_by_stripe_customer_id(
&self,
stripe_customer_id: &str,
) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?"))
.bind(stripe_customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Relays
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(
"WHERE synced = 0 OR TRIM(sync_error) != ''",
))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Invoices
pub async fn get_lightning_invoice(
&self,
stripe_invoice_id: &str,
) -> Result<Option<LightningInvoice>> {
let row = sqlx::query_as::<_, LightningInvoice>(
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
)
.bind(stripe_invoice_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Activity
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC"))
.bind(resource_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_latest_activity_for_resource_and_type(
&self,
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
let row = sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
.fetch_optional(pool())
.await?,
)
}
// --- Relays ---
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?,
)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(pool())
.await?)
}
/// The relay's plan immediately before `before`, read from the most recent
/// relay-activity snapshot with `created_at < before`. Billing uses this as
/// the `old` side of a plan-change delta.
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT json_extract(snapshot, '$.plan') FROM activity
WHERE resource_id = ?
AND resource_type = 'relay'
AND created_at < ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(relay_id)
.bind(before)
.fetch_optional(pool())
.await?)
}
// --- Invoices ---
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
Ok(
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
.bind(invoice_id)
.fetch_optional(pool())
.await?,
)
}
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// The line items claimed onto an invoice, oldest first. Used to render an
/// invoice's contents (and its downloadable copy) from what was actually billed.
pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item WHERE invoice_id = ? ORDER BY created_at ASC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?)
}
/// A tenant's open invoices — neither paid nor voided — oldest first. Dunning
/// retries each and treats the oldest one's `created_at` as the grace-period start.
pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
// --- Bolt11 ---
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
Ok(
sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
.bind(bolt11_id)
.fetch_optional(pool())
.await?,
)
}
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>(
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
// --- Activity ---
/// Billable activity for a tenant not yet folded into an invoice. The
/// activity-type filter and the `billed_at IS NULL` guard live here so the
/// caller reconciles off a precise marker rather than a timestamp watermark.
/// Ordered oldest-first so line items and proration apply in event order.
pub async fn list_billable_activity(tenant_pubkey: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant_pubkey = ?
AND billed_at IS NULL
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
)
ORDER BY created_at ASC",
))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// The relay's most recent activity strictly before `before`, or `None` if it
/// had no activity yet — i.e. the relay didn't exist at that point. Billing
/// reads its snapshot to recover the relay's state as of a period boundary.
/// Strict `<` so a relay created exactly at the boundary isn't counted active
/// there (its own creation charge covers that period).
pub async fn get_latest_relay_activity_before(
relay_id: &str,
before: i64,
) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ?
AND resource_type = 'relay'
AND created_at < ?
ORDER BY created_at DESC
LIMIT 1",
))
.bind(relay_id)
.bind(before)
.fetch_optional(pool())
.await?)
}
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? ORDER BY created_at DESC",
))
.bind(resource_id)
.fetch_all(pool())
.await?)
}
pub async fn get_latest_activity_for_resource_and_type(
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(pool())
.await?)
}
+34 -23
View File
@@ -5,11 +5,13 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env::Env;
use crate::env;
/// The service's Nostr identity: it publishes the robot's profile and relay
/// lists and sends encrypted direct messages to tenants, caching recipients'
/// relay lists between sends.
#[derive(Clone)]
pub struct Robot {
env: Env,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
}
@@ -21,9 +23,9 @@ struct CacheEntry {
}
impl Robot {
pub async fn new(env: &Env) -> Result<Self> {
/// Build the robot and publish its Nostr identity (profile and relay lists).
pub async fn new() -> Result<Self> {
let robot = Self {
env: env.clone(),
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
};
@@ -33,7 +35,7 @@ impl Robot {
}
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(self.env.keys.clone());
let client = Client::new(env::get().keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
@@ -41,29 +43,27 @@ impl Robot {
Ok(client)
}
async fn publish_identity(
&self,
) -> Result<()> {
async fn publish_identity(&self) -> Result<()> {
let mut metadata = Metadata::new();
if !self.env.robot_name.is_empty() {
metadata = metadata.name(&self.env.robot_name);
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
}
if !self.env.robot_description.is_empty() {
metadata = metadata.about(&self.env.robot_description);
if !env::get().robot_description.is_empty() {
metadata = metadata.about(&env::get().robot_description);
}
if !self.env.robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
}
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = self.env.robot_outbox_relays
let outbox_tags = env::get()
.robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -71,7 +71,8 @@ impl Robot {
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = self.env.robot_messaging_relays
let messaging_tags = env::get()
.robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -82,6 +83,7 @@ impl Robot {
Ok(())
}
/// Send an encrypted direct message to a recipient over their messaging relays.
pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()> {
let outbox = self.fetch_outbox_relays(recipient).await?;
if outbox.is_empty() {
@@ -97,7 +99,9 @@ impl Robot {
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.make_client(&dm_relays).await?;
client.send_private_msg(recipient_pubkey, message, []).await?;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
Ok(())
}
@@ -108,7 +112,7 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let client = self.make_client(&self.env.robot_indexer_relays).await?;
let client = self.make_client(&env::get().robot_indexer_relays).await?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
let mut relays = Vec::new();
@@ -125,11 +129,18 @@ impl Robot {
Ok(relays)
}
/// The recipient's display name from their Nostr profile, if they have one.
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let client = self
.make_client(&env::get().robot_indexer_relays)
.await
.ok()?;
let events = client
.fetch_events(filter, Duration::from_secs(5))
.await
.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
+35 -55
View File
@@ -3,84 +3,64 @@ use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey};
use crate::query;
use crate::web::{ApiResult, internal, not_found, ok};
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await
.map_err(internal)?;
ok(invoices)
}
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice)
}
pub async fn get_lightning_invoice(
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
/// needed and first settling it if it was already paid out of band.
pub async fn get_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Path(invoice_id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let invoice = api
let bolt11 = api
.billing
.reconcile_invoice(&invoice)
.ensure_and_reconcile_bolt11(&invoice)
.await
.map_err(internal)?;
let lightning_invoice = api
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
.await
.map_err(internal)?;
ok(serde_json::json!(lightning_invoice))
ok(serde_json::json!(bolt11))
}
/// The line items billed on an invoice, for rendering its contents and a
/// downloadable copy.
pub async fn list_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let items = query::list_invoice_items_for_invoice(&invoice_id)
.await
.map_err(internal)?;
ok(items)
}
-1
View File
@@ -2,5 +2,4 @@ pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod stripe;
pub mod tenants;
+6 -7
View File
@@ -3,15 +3,14 @@ use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::Api;
use crate::query;
use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
ok(api.query.list_plans())
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
ok(query::list_plans())
}
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match api.query.get_plan(&id) {
Some(plan) => ok(plan),
None => Err(not_found("plan not found")),
}
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
let plan = query::get_plan(&id).map_err(|_| not_found("plan not found"))?;
ok(plan)
}
+112 -110
View File
@@ -9,19 +9,64 @@ use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::web::{
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
parse_bool_default, unprocessable,
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
unprocessable,
};
use crate::{command, infra, query};
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let relays = query::list_relays().await.map_err(internal)?;
ok(relays)
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
ok(relay)
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let activity = query::list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let members = fetch_relay_members(&relay).await.map_err(internal)?;
ok(serde_json::json!({ "members": members }))
}
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub tenant_pubkey: String,
pub subdomain: String,
pub plan: String,
pub plan_id: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
@@ -34,76 +79,12 @@ pub struct CreateRelayRequest {
pub push_enabled: i64,
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let relays = api.query.list_relays().await.map_err(internal)?;
ok(relays)
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
ok(relay)
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let activity = api
.query
.list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
ok(serde_json::json!({ "members": members }))
}
pub async fn create_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Json(payload): Json<CreateRelayRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &payload.tenant)?;
api.require_admin_or_tenant(&auth, &payload.tenant_pubkey)?;
let relay_id = format!(
"{}_{}",
@@ -113,9 +94,9 @@ pub async fn create_relay(
let relay = Relay {
id: relay_id.clone(),
tenant: payload.tenant,
tenant_pubkey: payload.tenant_pubkey,
subdomain: payload.subdomain,
plan: payload.plan,
plan_id: payload.plan_id,
info_name: payload.info_name,
info_icon: payload.info_icon,
info_description: payload.info_description,
@@ -129,15 +110,31 @@ pub async fn create_relay(
..Default::default()
};
let relay = prepare_relay(&api, relay)?;
let relay = prepare_relay(relay)?;
api.command
.create_relay(&relay)
command::create_relay(&relay)
.await
.map_err(map_relay_write_error)?;
created(relay)
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan_id: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -145,16 +142,17 @@ pub async fn update_relay(
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let current_plan = relay.plan_id.clone();
let requested_plan = payload.plan_id.clone();
if let Some(v) = payload.subdomain {
relay.subdomain = v;
}
if let Some(v) = requested_plan.clone() {
relay.plan = v;
relay.plan_id = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
@@ -187,22 +185,16 @@ pub async fn update_relay(
relay.push_enabled = v;
}
let relay = prepare_relay(&api, relay)?;
let relay = prepare_relay(relay)?;
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
let selected_plan = query::get_plan(&relay.plan_id).map_err(internal)?;
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&api, &relay)
.await
.map_err(internal)?
.len() as i64;
let current_members = fetch_relay_members(&relay).await.map_err(internal)?.len() as i64;
if current_members > limit {
let message = format!(
@@ -214,10 +206,10 @@ pub async fn update_relay(
}
}
api.command
.update_relay(&relay)
command::update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
@@ -227,20 +219,21 @@ pub async fn deactivate_relay(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_INACTIVE {
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
return Err(bad_request(
"relay-is-inactive",
"relay is already inactive",
));
}
api.command
.deactivate_relay(&relay)
.await
.map_err(internal)?;
command::deactivate_relay(&relay).await.map_err(internal)?;
ok(())
}
@@ -250,7 +243,7 @@ pub async fn reactivate_relay(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
@@ -260,18 +253,19 @@ pub async fn reactivate_relay(
return Err(bad_request("relay-is-active", "relay is already active"));
}
api.command.activate_relay(&relay).await.map_err(internal)?;
command::activate_relay(&relay).await.map_err(internal)?;
ok(())
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
async fn fetch_relay_members(relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
api.infra.list_relay_members(&relay.id).await
infra::list_relay_members(&relay.id).await
}
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
@@ -279,19 +273,26 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
/// Validate and normalize a relay before persistence: enforce the subdomain
/// format and reserved names, require an existing plan that permits any enabled
/// premium features, and coerce the boolean columns to 0/1.
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str())
{
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = api
.query
.get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
let plan = query::get_plan(&relay.plan_id)
.map_err(|_| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
if (!plan.blossom && relay.blossom_enabled == 1)
|| (!plan.livekit && relay.livekit_enabled == 1)
{
return Err(unprocessable(
"premium-feature",
"feature requires a paid plan",
));
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
@@ -305,6 +306,7 @@ fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
Ok(relay)
}
/// Translate a duplicate-subdomain write into a 422; anything else is a 500.
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
unprocessable("subdomain-exists", "subdomain already exists")
-349
View File
@@ -1,349 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Path, Query as QueryParams, State},
http::HeaderMap,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
use crate::web::{ApiResult, bad_request, internal, ok};
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
pub async fn stripe_webhook(
State(api): State<Arc<Api>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult {
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let payload = std::str::from_utf8(&body)
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
handle_webhook(&api, payload, signature)
.await
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
ok(())
}
// --- Webhook event handlers ---
//
// These translate verified Stripe events into domain actions. The Stripe HTTP
// calls and Lightning/NWC payment orchestration they invoke live in
// [`crate::stripe`] and [`crate::billing`] respectively.
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
let event = api.stripe.get_webhook_event(payload, signature)?;
let obj = &event.data.object;
match event.event_type.as_str() {
"invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
}
"invoice.paid" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_paid(api, customer).await?;
}
"invoice.payment_failed" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_payment_failed(api, customer).await?;
}
"invoice.overdue" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_overdue(api, customer).await?;
}
"customer.subscription.updated" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let status = obj["status"].as_str().unwrap_or_default();
handle_subscription_updated(api, customer, status).await?;
}
"customer.subscription.deleted" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_subscription_deleted(api, customer).await?;
}
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_payment_method_attached(api, customer).await?;
}
_ => {}
}
Ok(())
}
async fn handle_invoice_created(
api: &Api,
stripe_customer_id: &str,
amount_due: i64,
currency: &str,
stripe_invoice_id: &str,
) -> Result<()> {
if amount_due == 0 {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoice = api
.billing
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
.await?;
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
Ok(()) => return Ok(()),
Err(e) => {
let error_msg = format!("{e}");
api.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
stripe_invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to card / manual payment
}
}
}
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
if api
.stripe
.has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
// 3. Manual payment: DM a link to the in-app payment page for this invoice
let url_base = &api.env.app_url;
let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}");
let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match nwc_error_for_dm {
Some(error) if !error.is_empty() => {
format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => base,
};
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
}
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_some() {
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
api.command.activate_relay(&relay).await?;
}
}
}
Ok(())
}
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_none() {
api.command.set_tenant_past_due(&tenant.pubkey).await?;
api.robot
.send_dm(
&tenant.pubkey,
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
)
.await?;
}
Ok(())
}
async fn handle_subscription_updated(
api: &Api,
stripe_customer_id: &str,
status: &str,
) -> Result<()> {
if status != "canceled" && status != "unpaid" {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
Ok(())
}
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
Ok(())
}
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
api.robot
.send_dm(
&tenant.pubkey,
"Your paid relays have been deactivated due to non-payment.",
)
.await?;
Ok(())
}
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
return Some(normalized);
}
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
+87 -30
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -10,16 +10,21 @@ use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
#[derive(Serialize)]
pub struct TenantResponse {
pub pubkey: String,
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub stripe_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
pub stripe_payment_method_id: Option<String>,
/// Set when billing has churned the tenant; the UI uses it to warn that the
/// account is delinquent until billing is re-activated.
pub churned_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
@@ -28,40 +33,52 @@ impl From<Tenant> for TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
stripe_error: t.stripe_error,
created_at: t.created_at,
billing_anchor: t.billing_anchor,
stripe_customer_id: t.stripe_customer_id,
stripe_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
stripe_payment_method_id: t.stripe_payment_method_id,
churned_at: t.churned_at,
}
}
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let tenants = api.query.list_tenants().await.map_err(internal)?;
let tenants = query::list_tenants().await.map_err(internal)?;
ok(tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>())
}
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
/// already exists (including a unique-constraint race) we return the existing
/// row.
/// Fetch a tenant by pubkey. Automatically refreshes the tenant's stripe payment data.
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing.sync_stripe_customer(&mut tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant))
}
/// Create the tenant row for the calling pubkey and provision its Stripe
/// customer. Idempotent: an existing tenant (including one created by a
/// concurrent unique-constraint race) is returned as-is.
pub async fn create_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t));
}
@@ -84,10 +101,10 @@ pub async fn create_tenant(
..Default::default()
};
match api.command.create_tenant(&tenant).await {
match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match api.query.get_tenant(&pubkey).await {
match query::get_tenant(&pubkey).await {
Ok(Some(t)) => ok(TenantResponse::from(t)),
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)),
@@ -97,14 +114,9 @@ pub async fn create_tenant(
}
}
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant(
@@ -121,14 +133,15 @@ pub async fn update_tenant(
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
}
}
api.command.update_tenant(&tenant).await.map_err(internal)?;
command::update_tenant(&tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant))
}
/// List a tenant's relays.
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -136,10 +149,54 @@ pub async fn list_tenant_relays(
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = api
.query
.list_relays_for_tenant(&pubkey)
let relays = query::list_relays_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(relays)
}
/// List a tenant's invoices after reconciling the tenant's billing state.
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing
.reconcile_subscription(&tenant, false)
.await
.map_err(internal)?;
let invoices = query::list_invoices(&pubkey).await.map_err(internal)?;
ok(invoices)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
/// Create a Stripe billing-portal session for the tenant to manage their saved
/// payment methods, returning the portal URL.
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Query(params): Query<StripeSessionParams>,
) -> ApiResult {
api.require_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
+78 -267
View File
@@ -7,91 +7,25 @@
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
use crate::env::Env;
use crate::env;
const STRIPE_API: &str = "https://api.stripe.com/v1";
// Webhooks
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(serde::Deserialize)]
pub struct StripeWebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeWebhookEventData,
}
#[derive(serde::Deserialize)]
pub struct StripeWebhookEventData {
pub object: serde_json::Value,
}
// API return types
#[derive(serde::Deserialize)]
pub struct StripeSubscription {
pub id: String,
pub status: String,
#[serde(deserialize_with = "deserialize_list")]
pub items: Vec<StripeSubscriptionItem>,
}
#[derive(serde::Deserialize)]
pub struct StripeSubscriptionItem {
pub id: String,
pub price: StripePrice,
#[serde(default = "default_quantity")]
pub quantity: i64,
}
#[derive(serde::Deserialize)]
pub struct StripePrice {
pub id: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct StripeInvoice {
pub id: String,
pub customer: String,
pub status: String,
pub amount_due: i64,
pub currency: String,
pub period_start: i64,
pub period_end: i64,
}
#[derive(serde::Deserialize)]
struct StripeList<T> {
data: Vec<T>,
}
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
}
fn default_quantity() -> i64 {
1
}
// Stripe struct and impl
#[derive(Clone)]
pub struct Stripe {
env: Env,
http: reqwest::Client,
}
impl Default for Stripe {
fn default() -> Self {
Self::new()
}
}
impl Stripe {
pub fn new(env: &Env) -> Self {
pub fn new() -> Self {
Self {
env: env.clone(),
http: reqwest::Client::new(),
}
}
@@ -101,23 +35,17 @@ impl Stripe {
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.get(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
.bearer_auth(&env::get().stripe_secret_key)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.delete(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
.bearer_auth(&env::get().stripe_secret_key)
}
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes())
let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
@@ -130,6 +58,8 @@ impl Stripe {
// --- Customers ---
/// Create a Stripe customer for a tenant and return its id. Idempotent on
/// `tenant_pubkey` so retrying a tenant's creation reuses the same customer.
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
let body = self
.post("/customers")
@@ -146,157 +76,80 @@ impl Stripe {
Ok(customer_id.to_string())
}
// --- Subscriptions ---
pub async fn get_subscription(
&self,
subscription_id: &str,
) -> Result<Option<StripeSubscription>> {
let body = self
.get(&format!("/subscriptions/{subscription_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
/// Stripe requires at least one item to create a subscription, so the desired
/// items are sent inline here; [`crate::billing`] reconciles from there.
pub async fn create_subscription(
&self,
customer_id: &str,
items: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
let mut form: Vec<(String, String)> = vec![
("customer".to_string(), customer_id.to_string()),
(
"collection_method".to_string(),
"charge_automatically".to_string(),
),
];
let mut key_parts: Vec<String> =
vec!["create_subscription".to_string(), customer_id.to_string()];
for (index, (price_id, quantity)) in items.iter().enumerate() {
form.push((format!("items[{index}][price]"), price_id.clone()));
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
key_parts.push(format!("{price_id}={quantity}"));
}
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
Ok(self
.post("/subscriptions")
.header("Idempotency-Key", self.idempotency_key(&key_refs))
.form(&form)
.send_ok()
.await?
.json()
.await?)
}
pub async fn create_subscription_item(
&self,
subscription_id: &str,
price_id: &str,
quantity: i64,
) -> Result<()> {
let quantity = quantity.to_string();
self.post("/subscription_items")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
)
.form(&[
("subscription", subscription_id),
("price", price_id),
("quantity", quantity.as_str()),
])
.send_ok()
.await?;
Ok(())
}
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
self.post(&format!("/subscription_items/{item_id}"))
.form(&[("quantity", quantity.to_string())])
.send_ok()
.await?;
Ok(())
}
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
self.delete(&format!("/subscription_items/{item_id}"))
.send_ok()
.await?;
Ok(())
}
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
self.delete(&format!("/subscriptions/{subscription_id}"))
.send_ok()
.await?;
Ok(())
}
// --- Invoices ---
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
let list: StripeList<StripeInvoice> = self
.get("/invoices")
.query(&[("customer", customer_id)])
.send_ok()
.await?
.json()
.await?;
Ok(list.data)
}
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
let body = self
.get(&format!("/invoices/{invoice_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice", invoice_id]),
)
.send_ok()
.await?;
Ok(())
}
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
)
.form(&[("paid_out_of_band", "true")])
.send_ok()
.await?;
Ok(())
}
// --- Payment methods ---
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
/// Return the id of the customer's first saved payment method, or `None` if
/// they have none. The returned `pm_…` id can be charged off-session via
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
/// method, so the first one Stripe lists is the one we'll charge.
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
let body = self
.get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")])
.send_json()
.await?;
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
Ok(body["data"]
.as_array()
.and_then(|methods| methods.first())
.and_then(|method| method["id"].as_str())
.map(str::to_string))
}
// --- Intents ---
/// Create and immediately confirm an off-session PaymentIntent charging a
/// saved payment method. `amount` is in the currency's minor units (cents for
/// `usd`). Returns the PaymentIntent id on success.
///
/// A decline or an issuer authentication demand (`authentication_required`,
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
/// error, so the caller naturally falls through to another payment method.
/// The charge is made idempotent on `invoice_id`, so a retried collection
/// reuses the same charge instead of billing the payment method twice.
pub async fn create_payment_intent(
&self,
customer_id: &str,
payment_method_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
) -> Result<String> {
let amount = amount.to_string();
let body = self
.post("/payment_intents")
.header(
"Idempotency-Key",
self.idempotency_key(&["payment_intent", invoice_id]),
)
.form(&[
("amount", amount.as_str()),
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("off_session", "true"),
("confirm", "true"),
])
.send_json()
.await?;
// A successful off-session charge settles synchronously. Anything
// else (e.g. `requires_action`) can't be completed without the customer,
// so treat it as a failure and let the caller fall back.
let status = body["status"].as_str().unwrap_or_default();
if status != "succeeded" {
return Err(anyhow!("payment intent not succeeded (status: {status})"));
}
body["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing payment intent id"))
}
// --- Portal ---
/// Open a Stripe billing-portal session for the customer, returning the URL
/// where they can manage their saved payment methods.
pub async fn create_portal_session(
&self,
customer_id: &str,
@@ -316,47 +169,13 @@ impl Stripe {
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
// --- Webhooks ---
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
let mut timestamp = None;
let mut sig = None;
for part in signature.split(',') {
if let Some(t) = part.strip_prefix("t=") {
timestamp = Some(t);
} else if let Some(v) = part.strip_prefix("v1=") {
sig = Some(v);
}
}
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
mac.update(signed_payload.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if expected != signature {
return Err(anyhow!("webhook signature mismatch"));
}
let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance"));
}
Ok(serde_json::from_str(payload)?)
}
}
// Stripe request util
trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>;
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
}
impl StripeRequest for reqwest::RequestBuilder {
@@ -367,14 +186,6 @@ impl StripeRequest for reqwest::RequestBuilder {
async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?)
}
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
let resp = self.send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(Some(error_for_status(resp).await?.json().await?))
}
}
/// Give callers an actionable message instead of a bare "400 Bad Request"
+6 -1
View File
@@ -4,6 +4,9 @@ use nwc::prelude::{
TransactionState,
};
/// A Nostr Wallet Connect wallet, used both as the service's receiving wallet
/// and as a tenant's paying wallet. Each call spins up and shuts down its own
/// short-lived NWC client; nothing is pooled across calls.
#[derive(Clone)]
pub struct Wallet {
url: NostrWalletConnectURI,
@@ -11,7 +14,9 @@ pub struct Wallet {
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self { url: url.parse::<NostrWalletConnectURI>()? })
Ok(Self {
url: url.parse::<NostrWalletConnectURI>()?,
})
}
pub async fn make_invoice(
+5 -1
View File
@@ -71,7 +71,11 @@ pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
}
pub fn unauthorized(reason: impl Display) -> ApiError {
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
err(
StatusCode::UNAUTHORIZED,
"unauthorized",
&reason.to_string(),
)
}
pub fn forbidden(message: &str) -> ApiError {
+6 -47
View File
@@ -1,12 +1,11 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import Fuse from "fuse.js"
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
import { listTenantInvoices, type Invoice } from "@/lib/api"
import { primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, eventStore, identity } from "@/lib/state"
import serverIcon from "@/assets/server.svg"
import Modal from "@/components/Modal"
import PaymentDialog from "@/components/PaymentDialog"
import BillingPrompts from "@/components/BillingPrompts"
type Profile = {
name?: string
@@ -35,28 +34,10 @@ function RelayIcon() {
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
const [tenant] = useTenant()
const [tenantRelays] = useTenantRelays()
const [profile, setProfile] = createSignal<Profile>({})
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [pastDueInvoice, setPastDueInvoice] = createSignal<Invoice | undefined>()
const [showPaymentDialog, setShowPaymentDialog] = createSignal(false)
createEffect(async () => {
const t = tenant()
if (!t?.past_due_at) {
setPastDueInvoice(undefined)
return
}
try {
const invoices = await listTenantInvoices(t.pubkey)
const openInvoice = invoices.find(inv => inv.status === "open")
setPastDueInvoice(openInvoice)
} catch {
// ignore
}
})
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
@@ -158,36 +139,14 @@ export default function AppShell(props: { children?: any }) {
</aside>
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
<Show when={tenant()?.past_due_at}>
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
<span>Your account has an overdue balance.</span>
<Show when={pastDueInvoice()}>
<button
type="button"
onClick={() => setShowPaymentDialog(true)}
class="font-medium underline hover:no-underline"
>
Pay now
</button>
</Show>
</div>
{/* Shared billing prompts on every dashboard page; the billing page
renders its own contextual (inline) variant instead. */}
<Show when={location.pathname !== "/account"}>
<BillingPrompts variant="banner" />
</Show>
<main>{props.children}</main>
</div>
<Show when={pastDueInvoice() && showPaymentDialog()}>
{(_) => {
const invoice = pastDueInvoice()!
return (
<PaymentDialog
invoice={invoice}
open={true}
onClose={() => setShowPaymentDialog(false)}
/>
)
}}
</Show>
<nav
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
+128
View File
@@ -0,0 +1,128 @@
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, type Invoice } from "@/lib/api"
import { activeBillingPrompt, billingFlowActive, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
type BillingPromptsProps = {
// "banner" sits in the dashboard shell (mounted on every page except the
// billing page); "inline" is shown contextually on the billing page itself.
// Only one is ever mounted at a time, so each can own its own modals + deep link.
variant?: "banner" | "inline"
}
export default function BillingPrompts(props: BillingPromptsProps) {
const status = useBillingStatus()
const [dismissed, setDismissed] = createSignal<Set<BillingPromptKind>>(new Set())
const [payInvoice, setPayInvoice] = createSignal<Invoice | undefined>()
const [setupOpen, setSetupOpen] = createSignal(false)
const [setupTab, setSetupTab] = createSignal<"nwc" | "card">("nwc")
// Deep link: /...?invoice=<id> (e.g. from the billing DM) opens the payment
// dialog on whatever dashboard page the link lands on.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinked] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
const prompt = createMemo(() =>
activeBillingPrompt(
{
tenant: status.tenant(),
openInvoice: status.openInvoice(),
hasPaidSubscription: status.hasPaidSubscription(),
},
{ suppressInline: billingFlowActive() },
),
)
const visiblePrompt = createMemo(() => {
const p = prompt()
if (!p || dismissed().has(p.kind)) return undefined
return p
})
function openSetup(tab: "nwc" | "card") {
setSetupTab(tab)
setSetupOpen(true)
}
const actions = createMemo<PromptBannerAction[]>(() => {
const p = visiblePrompt()
if (!p) return []
const open = status.openInvoice()
switch (p.kind) {
case "churned":
return open
? [
{ label: "Pay now", onClick: () => setPayInvoice(open) },
{ label: "Update payment method", onClick: () => openSetup("nwc") },
]
: [{ label: "Update payment method", onClick: () => openSetup("nwc") }]
case "pay_invoice":
return open ? [{ label: "Pay now", onClick: () => setPayInvoice(open) }] : []
case "update_method":
return [{ label: "Update payment method", onClick: () => openSetup(status.tenant()?.nwc_error ? "nwc" : "card") }]
case "setup_autopay":
return [{ label: "Set up autopay", onClick: () => openSetup("nwc") }]
}
return []
})
const dismissible = () => visiblePrompt()?.kind === "setup_autopay"
function dismiss() {
const p = visiblePrompt()
if (p) setDismissed((prev) => new Set(prev).add(p.kind))
}
function clearDeepLink() {
if (searchParams.invoice) setSearchParams({ invoice: undefined })
}
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
return (
<>
<Show when={visiblePrompt()}>
{(p) => (
<PromptBanner
severity={p().severity}
message={p().message}
actions={actions()}
onDismiss={dismissible() ? dismiss : undefined}
class={outerClass()}
/>
)}
</Show>
{/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={() => {
const wasDeepLink = !payInvoice()
setPayInvoice(undefined)
if (wasDeepLink) clearDeepLink()
status.refetch()
}}
/>
)}
</Show>
<PaymentSetup
open={setupOpen()}
initialTab={setupTab()}
onClose={() => {
setSetupOpen(false)
status.refetch()
}}
/>
</>
)
}
+33 -34
View File
@@ -1,15 +1,14 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createResource, createSignal, For, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
import { useTenantRelays } from "@/lib/hooks"
import { plans } from "@/lib/state"
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
import { billingTenant } from "@/lib/state"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = Pick<Invoice, "id" | "amount_due"> &
type PaymentInvoice = Pick<Invoice, "id" | "amount"> &
Partial<Pick<Invoice, "period_start" | "period_end">>
type PaymentDialogProps = {
@@ -27,14 +26,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [payError, setPayError] = createSignal("")
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [relays] = useTenantRelays()
const [items] = createResource(
() => (props.open ? props.invoice.id : undefined),
listInvoiceItems,
)
const billedRelays = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
.filter((entry) => entry.plan?.stripe_price_id)
})
const autopayConfigured = () => {
const t = billingTenant()
return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id)
}
async function loadBolt11() {
if (!props.invoice.id) return
@@ -44,9 +44,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setQrDataUrl("")
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
const { lnbc } = await getInvoiceBolt11(props.invoice.id)
setBolt11(lnbc)
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
@@ -68,7 +68,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setPayError("")
try {
const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") {
if (invoice.paid_at != null) {
setPayStatus("success")
} else {
setPayStatus("error")
@@ -90,7 +90,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
props.onClose()
}
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
const periodLabel = () => {
const { period_start, period_end } = props.invoice
@@ -137,19 +137,16 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-4">
{/* What's being paid for */}
<Show when={billedRelays().length > 0}>
{/* What's being paid for — the invoice's actual line items */}
<Show when={(items() ?? []).length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={billedRelays()}>
{({ relay, plan }) => (
<For each={items()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
<span class="flex-shrink-0 text-xs text-gray-500">
{plan?.name ?? relay.plan}
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
</span>
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">${(item.amount / 100).toFixed(2)}</span>
</li>
)}
</For>
@@ -221,13 +218,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
<Show when={!autopayConfigured()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</Show>
</div>
</Show>
</div>
+10 -2
View File
@@ -1,4 +1,4 @@
import { createSignal, Show } from "solid-js"
import { createEffect, createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { updateActiveTenant } from "@/lib/hooks"
import { createPortalSession } from "@/lib/api"
@@ -10,10 +10,18 @@ type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// Which method to show first. Lightning/NWC is the default; pass "card" to land
// a tenant on the card tab (e.g. when their card is the method that failed).
initialTab?: Tab
}
export default function PaymentSetup(props: PaymentSetupProps) {
const [tab, setTab] = createSignal<Tab>("nwc")
const [tab, setTab] = createSignal<Tab>(props.initialTab ?? "nwc")
// Reset to the requested tab each time the dialog opens.
createEffect(() => {
if (props.open) setTab(props.initialTab ?? "nwc")
})
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
+4 -4
View File
@@ -32,9 +32,9 @@ function memberLabel(members: number | null) {
type PricingTableProps = {
selectable?: boolean
selectedPlan?: PlanId
onSelect?: (plan: PlanId) => void
onCta?: (plan: PlanId) => void
selectedPlanId?: PlanId
onSelect?: (planId: PlanId) => void
onCta?: (planId: PlanId) => void
}
export default function PricingTable(props: PricingTableProps) {
@@ -43,7 +43,7 @@ export default function PricingTable(props: PricingTableProps) {
<For each={plans()}>
{(plan) => {
const isPopular = plan.id === "basic"
const isSelected = () => props.selectable && props.selectedPlan === plan.id
const isSelected = () => props.selectable && props.selectedPlanId === plan.id
const card = (
<>
+61
View File
@@ -0,0 +1,61 @@
import { For, Show } from "solid-js"
export type PromptBannerAction = {
label: string
onClick: () => void
}
type Severity = "error" | "warn" | "info"
type PromptBannerProps = {
severity: Severity
message: string
actions?: PromptBannerAction[]
onDismiss?: () => void
class?: string
}
const containerStyles: Record<Severity, string> = {
error: "border-red-200 bg-red-50 text-red-800",
warn: "border-amber-200 bg-amber-50 text-amber-800",
info: "border-blue-200 bg-blue-50 text-blue-800",
}
const actionStyles: Record<Severity, string> = {
error: "text-red-800 hover:text-red-900",
warn: "text-amber-800 hover:text-amber-900",
info: "text-blue-800 hover:text-blue-900",
}
export default function PromptBanner(props: PromptBannerProps) {
return (
<div class={`rounded-lg border p-4 flex items-start justify-between gap-4 ${containerStyles[props.severity]} ${props.class ?? ""}`.trim()}>
<p class="text-sm min-w-0">{props.message}</p>
<div class="flex items-center gap-3 shrink-0">
<For each={props.actions ?? []}>
{(action) => (
<button
type="button"
onClick={action.onClick}
class={`text-sm font-medium underline hover:no-underline whitespace-nowrap ${actionStyles[props.severity]}`}
>
{action.label}
</button>
)}
</For>
<Show when={props.onDismiss}>
<button
type="button"
onClick={() => props.onDismiss?.()}
aria-label="Dismiss"
class={`shrink-0 opacity-70 hover:opacity-100 ${actionStyles[props.severity]}`}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
)
}
+12 -12
View File
@@ -63,7 +63,7 @@ type RelayDetailCardProps = {
onToggleMediaStorage?: () => void
onToggleLivekitSupport?: () => void
onTogglePushNotifications?: () => void
onUpdatePlan?: (plan: PlanId) => Promise<void>
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
}
@@ -76,17 +76,17 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
return fallback
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined
const memberLimitLabel = () => {
const p = plans().find(p => p.id === r().plan)
const p = plans().find(p => p.id === r().plan_id)
if (!p) return "?"
return p.members === null ? "∞" : String(p.members)
}
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan_id === "free"
const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
@@ -107,11 +107,11 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) {
setPlan(plan)
async function changePlanId(planId: PlanId) {
setPlanId(planId)
try {
await props.onUpdatePlan?.(plan)
setToastMessage(`Plan updated to ${plan}`, "success")
await props.onUpdatePlan?.(planId)
setToastMessage(`Plan updated to ${planId}`, "success")
} catch {
// error is handled by the caller
}
@@ -360,7 +360,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant}</span>
<span class="font-mono text-xs break-all">{r().tenant_pubkey}</span>
</Field>
</Show>
</MembershipSection>
@@ -373,15 +373,15 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
when={props.onUpdatePlan}
fallback={
<Field label="Current plan">
<span class="capitalize text-gray-900">{r().plan}</span>
<span class="capitalize text-gray-900">{r().plan_id}</span>
</Field>
}
>
<div class="lg:col-span-2 space-y-4">
<PricingTable
selectable
selectedPlan={plan()}
onSelect={changePlan}
selectedPlanId={planId()}
onSelect={changePlanId}
/>
</div>
</Show>
+8 -8
View File
@@ -5,7 +5,7 @@ import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state"
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan">
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id">
type RelayFormProps = {
initialValues?: Partial<RelayFormValues>
@@ -16,8 +16,8 @@ type RelayFormProps = {
}
export default function RelayForm(props: RelayFormProps) {
const defaultPlanId = createMemo(() => props.initialValues?.plan ?? plans()[0]?.id ?? "free")
const [plan, setPlan] = createSignal(defaultPlanId())
const defaultPlanId = createMemo(() => props.initialValues?.plan_id ?? plans()[0]?.id ?? "free")
const [planId, setPlanId] = createSignal(defaultPlanId())
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
const [subdomain, setSubdomain] = createSignal(props.initialValues?.subdomain ?? "")
const [icon, setIcon] = createSignal(props.initialValues?.info_icon ?? "")
@@ -27,7 +27,7 @@ export default function RelayForm(props: RelayFormProps) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!plan()) {
if (!planId()) {
setToastMessage("Please select a plan")
return
}
@@ -43,7 +43,7 @@ export default function RelayForm(props: RelayFormProps) {
try {
await props.onSubmit({
plan: plan(),
plan_id: planId(),
info_name: name(),
subdomain: subdomain(),
info_icon: icon(),
@@ -56,7 +56,7 @@ export default function RelayForm(props: RelayFormProps) {
}
}
createEffect(() => setPlan(defaultPlanId()))
createEffect(() => setPlanId(defaultPlanId()))
createEffect(() => {
if (props.syncSubdomainWithName) {
@@ -112,8 +112,8 @@ export default function RelayForm(props: RelayFormProps) {
{(p) => (
<button
type="button"
onClick={() => setPlan(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${plan() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
+1 -1
View File
@@ -17,7 +17,7 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
{props.showTenant && (
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant_pubkey}</p>
)}
</div>
<Show
+52 -16
View File
@@ -35,7 +35,6 @@ export type Plan = {
id: string
name: string
amount: number
stripe_price_id: string | null
members: number | null
blossom: boolean
livekit: boolean
@@ -45,9 +44,9 @@ export type PlanId = string
export type Relay = {
id: string
tenant: string
tenant_pubkey: string
subdomain: string
plan: PlanId
plan_id: PlanId
status: string
sync_error: string
synced: number
@@ -64,9 +63,9 @@ export type Relay = {
}
export type CreateRelayInput = {
tenant?: string
tenant_pubkey?: string
subdomain: string
plan: string
plan_id: string
info_name?: string
info_icon?: string
info_description?: string
@@ -81,7 +80,7 @@ export type CreateRelayInput = {
export type UpdateRelayInput = {
subdomain?: string
plan?: string
plan_id?: string
info_name?: string
info_icon?: string
info_description?: string
@@ -98,25 +97,60 @@ export type Tenant = {
pubkey: string
nwc_is_set: boolean
created_at: number
billing_anchor: number | null
stripe_customer_id: string
stripe_subscription_id: string | null
past_due_at: number | null
stripe_payment_method_id: string | null
nwc_error: string | null
stripe_error: string | null
churned_at: number | null
}
export type Invoice = {
id: string
customer: string
status: string
amount_due: number
currency: string
tenant_pubkey: string
amount: number
period_start: number
period_end: number
created_at: number
paid_at: number | null
voided_at: number | null
method: "nwc" | "stripe" | "oob" | null
}
export type InvoiceItem = {
id: string
invoice_id: string | null
activity_id: string | null
tenant_pubkey: string
relay_id: string
plan_id: string
amount: number
description: string
created_at: number
}
export type Bolt11 = {
id: string
invoice_id: string
lnbc: string
msats: number
created_at: number
expires_at: number
settled_at: number | null
}
// The backend models an invoice's lifecycle as timestamps rather than a status
// field, so derive the display status from them: paid once paid_at is set, void
// once voided_at is set, otherwise still open.
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): "open" | "paid" | "void" {
if (invoice.paid_at != null) return "paid"
if (invoice.voided_at != null) return "void"
return "open"
}
export type Activity = {
id: string
tenant: string
tenant_pubkey: string
created_at: number
activity_type: string
resource_type: string
@@ -143,8 +177,6 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235,
content: "",
created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]],
})
@@ -257,7 +289,11 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
}
export function getInvoiceBolt11(invoiceId: string) {
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
}
export function listInvoiceItems(invoiceId: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
}
export function createRelay(input: CreateRelayInput) {
+106
View File
@@ -0,0 +1,106 @@
import { createMemo, createSignal } from "solid-js"
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
import { billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
// Set while the create/upgrade flow drives its own payment/setup modals, so the
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
// every close path of that flow.
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
export type BillingPrompt = {
kind: BillingPromptKind
severity: "error" | "warn" | "info"
message: string
}
export type BillingStatusSnapshot = {
tenant: Tenant | undefined
openInvoice: Invoice | undefined
hasPaidSubscription: boolean
}
// The single billing read shared by the dashboard shell and the billing page.
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
// dunning order so the UI pays the same one collection targets.
export function useBillingStatus() {
const tenant = () => billingTenant()
const invoices = () => billingInvoices() ?? []
const openInvoices = createMemo(() =>
invoices()
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => a.created_at - b.created_at),
)
const openInvoice = () => openInvoices()[0]
// Amount due: the total of all open invoices.
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
const hasPaidSubscription = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (billingRelays() ?? []).some((relay) => {
const plan = planById.get(relay.plan_id)
return Boolean(plan && plan.amount > 0 && relay.status === "active")
})
})
const loading = () => billingTenant.loading || billingInvoices.loading
return { tenant, invoices, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
}
// Pure priority selector: returns the single highest-priority billing prompt to
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
// and method errors.
export function activeBillingPrompt(
s: BillingStatusSnapshot,
opts?: { suppressInline?: boolean },
): BillingPrompt | null {
const tenant = s.tenant
if (!tenant) return null
if (tenant.churned_at) {
return {
kind: "churned",
severity: "error",
message:
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
}
}
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
const methodError = tenant.nwc_error ?? tenant.stripe_error
const suppressInline = opts?.suppressInline ?? false
if (s.openInvoice && !suppressInline && (!autopayConfigured || methodError)) {
return {
kind: "pay_invoice",
severity: "warn",
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
}
}
if (methodError) {
return {
kind: "update_method",
severity: "warn",
message: tenant.nwc_error
? "Your Lightning wallet couldn't be charged. Update your payment method."
: "Your card couldn't be charged. Update your payment method.",
}
}
if (s.hasPaidSubscription && !autopayConfigured && !s.openInvoice && !suppressInline) {
return {
kind: "setup_autopay",
severity: "info",
message: "Set up automatic payments so your subscription renews without interruption.",
}
}
return null
}
+6 -5
View File
@@ -9,6 +9,7 @@ import {
reactivateRelay,
getRelay,
getTenant,
invoiceStatus,
listRelayActivity,
listRelays,
listTenantInvoices,
@@ -116,8 +117,8 @@ export const createRelayForActiveTenant = (input: CreateRelayInput) => {
const overrides = {
tenant: account()!.pubkey,
blossom_enabled: input.plan === "free" ? 0 : 1,
livekit_enabled: input.plan === "free" ? 0 : 1,
blossom_enabled: input.plan_id === "free" ? 0 : 1,
livekit_enabled: input.plan_id === "free" ? 0 : 1,
}
return createRelay({...defaults, ...input, ...overrides})
@@ -127,7 +128,7 @@ export const updateActiveTenant = (input: { nwc_url?: string }) => updateTenant(
export const updateRelayById = (id: string, input: UpdateRelayInput) => updateRelay(id, input)
export const updateRelayPlanById = (id: string, plan: string) => updateRelay(id, { plan })
export const updateRelayPlanById = (id: string, plan_id: string) => updateRelay(id, { plan_id })
export const deactivateRelayById = (id: string) => deactivateRelay(id)
@@ -135,13 +136,13 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
return !tenant.nwc_is_set && !tenant.stripe_payment_method_id
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0)
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
+17 -1
View File
@@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core"
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
import { RelayPool } from "applesauce-relay"
import { NostrConnectSigner } from "applesauce-signers"
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
import { getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
export type UnsignedEvent = {
kind: number
@@ -55,6 +55,22 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
}
)
// Shared billing reads, fetched once per session and consumed by the dashboard
// shell, the billing page, and the billing-prompt surface. Keyed on the active
// pubkey so they refetch on account switch; refetchBilling() refreshes them all
// after a mutation (payment, method update, plan change).
const billingKey = () => account()?.pubkey
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
export function refetchBilling() {
void refetchBillingTenant()
void refetchBillingInvoices()
void refetchBillingRelays()
}
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
queueMicrotask(() => {
try {
+139
View File
@@ -0,0 +1,139 @@
import { createSignal } from "solid-js"
import QRCode from "qrcode"
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
import { PLATFORM_NAME } from "@/lib/state"
const methodLabels: Record<string, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning (out of band)",
}
const fmtUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
function escapeHtml(value: string) {
const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
return value.replace(/[&<>"']/g, (c) => map[c])
}
// Generates a printable invoice and opens the browser's print/save-as-PDF dialog.
// No PDF dependency: the invoice is rendered as standalone HTML into an off-screen
// iframe so the current page is never disturbed. The bitcoin line is included only
// for Lightning-relevant invoices (never card-paid or void) to avoid spuriously
// minting a bolt11.
export function useInvoicePdf() {
const [printing, setPrinting] = createSignal(false)
async function printInvoice(invoice: Invoice) {
if (printing()) return
setPrinting(true)
try {
const items = await listInvoiceItems(invoice.id).catch(() => [] as InvoiceItem[])
let sats: number | undefined
let qrDataUrl: string | undefined
if (invoice.method !== "stripe" && invoice.voided_at == null) {
try {
const bolt11 = await getInvoiceBolt11(invoice.id)
sats = Math.round(bolt11.msats / 1000)
if (invoice.paid_at == null) {
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
}
} catch {
// no bolt11 available — omit the bitcoin line
}
}
printHtml(buildHtml({ invoice, items, sats, qrDataUrl }))
} finally {
setPrinting(false)
}
}
return { printInvoice, printing }
}
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
const { invoice, items, sats, qrDataUrl } = opts
const status = invoiceStatus(invoice)
const rows = items.length
? items
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${fmtUsd(i.amount)}</td></tr>`)
.join("")
: `<tr><td>Relay subscription</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr>`
const satsRow = sats != null ? `<tr><td>Bitcoin equivalent</td><td class="amt">${sats.toLocaleString()} sats</td></tr>` : ""
const methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabels[invoice.method] ?? invoice.method)}</div>` : ""
const qr = qrDataUrl
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
: ""
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
<style>
* { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111827; box-sizing: border-box; }
body { margin: 40px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.muted { color: #6b7280; font-size: 12px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.badge { text-transform: capitalize; border: 1px solid #d1d5db; border-radius: 9999px; padding: 2px 10px; font-size: 12px; }
.meta { font-size: 13px; color: #374151; line-height: 1.6; word-break: break-all; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
.amt { text-align: right; white-space: nowrap; }
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
.qr { margin-top: 28px; text-align: center; }
</style></head>
<body>
<div class="head">
<div><h1>${escapeHtml(PLATFORM_NAME)}</h1><div class="muted">Invoice</div></div>
<span class="badge">${status}</span>
</div>
<div class="meta">
<div>Invoice ID: ${escapeHtml(invoice.id)}</div>
<div>Billed to: ${escapeHtml(invoice.tenant_pubkey)}</div>
<div>Issued: ${fmtDate(invoice.created_at)}</div>
<div>Period: ${fmtDate(invoice.period_start)} &ndash; ${fmtDate(invoice.period_end)}</div>
${methodLine}
</div>
<table>
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
<tbody>${rows}${satsRow}</tbody>
<tfoot><tr><td>Total</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr></tfoot>
</table>
${qr}
</body></html>`
}
function printHtml(html: string) {
const iframe = document.createElement("iframe")
iframe.style.position = "fixed"
iframe.style.right = "0"
iframe.style.bottom = "0"
iframe.style.width = "0"
iframe.style.height = "0"
iframe.style.border = "0"
document.body.appendChild(iframe)
const win = iframe.contentWindow
const doc = win?.document
if (!win || !doc) {
iframe.remove()
return
}
doc.open()
doc.write(html)
doc.close()
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
win.onafterprint = cleanup
// Let the iframe lay out (and decode the QR image) before printing.
window.setTimeout(() => {
win.focus()
win.print()
window.setTimeout(cleanup, 60000)
}, 150)
}
+13 -8
View File
@@ -77,14 +77,14 @@ export default function useRelayToggles(
}
}
async function handleUpdatePlan(plan: PlanId) {
async function handleUpdatePlan(plan_id: PlanId) {
const current = relay()
if (!current) return
const previous = current
const next = { ...current, plan }
const update: Record<string, unknown> = { plan }
if (plan === "free") {
const next = { ...current, plan_id }
const update: Record<string, unknown> = { plan_id }
if (plan_id === "free") {
next.blossom_enabled = 0
next.livekit_enabled = 0
update.blossom_enabled = 0
@@ -101,11 +101,16 @@ export default function useRelayToggles(
throw e
}
if (plan !== "free") {
if (plan_id !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
// Materialize the invoice for this upgrade (no collection, no DM) so we
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
// first, so a just-created invoice is visible here.
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
if (invoice) {
setPendingInvoice(invoice)
}
setPendingPaymentSetup(true)
}
}
@@ -116,9 +121,9 @@ export default function useRelayToggles(
onToggleStripSignatures: () => toggle("policy_strip_signatures", false),
onToggleGroups: () => toggle("groups_enabled", true),
onToggleManagement: () => toggle("management_enabled", true),
onToggleMediaStorage: () => toggle("blossom_enabled", relay()?.plan !== "free"),
onToggleMediaStorage: () => toggle("blossom_enabled", relay()?.plan_id !== "free"),
onTogglePushNotifications: () => toggle("push_enabled", true),
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan_id !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
+77 -49
View File
@@ -1,48 +1,61 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import BillingPrompts from "@/components/BillingPrompts"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api"
import { useInvoicePdf } from "@/lib/useInvoicePdf"
import { updateActiveTenant } from "@/lib/hooks"
import { useBillingStatus } from "@/lib/billing"
import { createPortalSession, invoiceStatus, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
const methodLabels: Record<string, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
export default function Account() {
const [tenant, { refetch: refetchTenant }] = useTenant()
const [invoices, { refetch: refetchInvoices }] = createResource(() => listTenantInvoices(account()!.pubkey))
const billing = useBillingStatus()
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
const invoicesLoading = useMinLoading(() => billing.loading())
const { printInvoice, printing } = useInvoicePdf()
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
// invoice and opens the payment dialog. The fetched invoice takes precedence
// over a row the user clicked in the list.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinkedInvoice] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
// On landing here (the billing portal returns to this page), refresh billing so
// a card just added in the portal — which get_tenant syncs onto the tenant — and
// any cleared error show immediately rather than only on the next reconcile.
createEffect(() => {
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
if (account()?.pubkey) billing.refetch()
})
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
// The backend never returns the stored nwc_url (it's private), so the input is
// write-only: we can only act on a newly entered URL, not prefill the saved one.
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
// The amount to surface: the total of any open invoices, else nothing owed.
const balance = createMemo(() => {
const due = billing.balance()
return due > 0 ? { kind: "due" as const, amount: due } : { kind: "clear" as const, amount: 0 }
})
async function saveBilling() {
setError("")
setSaving(true)
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
await updateActiveTenant({ nwc_url: nwcUrl().trim() })
setNwcUrl("")
await refetchTenant()
billing.refetch()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
} finally {
@@ -50,13 +63,6 @@ export default function Account() {
}
}
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
// Clearing the query param drops the deep-linked invoice and closes the dialog.
if (searchParams.invoice) setSearchParams({ invoice: undefined })
void refetchInvoices()
}
async function openPortal() {
setPortalLoading(true)
try {
@@ -74,14 +80,6 @@ export default function Account() {
window.location.href = "/"
}
const invoiceStatusStyles: Record<string, string> = {
draft: "bg-gray-100 text-gray-500 border-gray-200",
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
uncollectible: "bg-red-50 text-red-700 border-red-200",
}
return (
<PageContainer>
<div class="mb-6 py-2 flex items-center justify-between gap-3">
@@ -96,10 +94,13 @@ export default function Account() {
</div>
<div class="space-y-6">
{/* Billing prompts, emphasized contextually on the billing page. */}
<BillingPrompts variant="inline" />
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<Show when={tenant()}>
<Show when={billing.tenant()}>
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
tenant
</span>
@@ -119,10 +120,22 @@ export default function Account() {
{portalLoading() ? "Loading..." : "Manage Billing"}
</button>
</div>
{/* Current balance */}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current balance</p>
<Show
when={balance().kind === "due"}
fallback={<p class="text-sm text-gray-600 mt-1">You're all paid up.</p>}
>
<p class="text-2xl font-bold text-gray-900 mt-0.5">${(balance().amount / 100).toFixed(2)} <span class="text-sm font-normal text-gray-500">due</span></p>
</Show>
</div>
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing.
</p>
<Show when={tenant()?.nwc_is_set}>
<Show when={billing.tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
@@ -142,9 +155,6 @@ export default function Account() {
{saving() ? "Saving..." : "Save"}
</button>
</div>
<Show when={tenant()?.nwc_error}>
<p class="mt-3 text-sm text-red-600">{tenant()!.nwc_error}</p>
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
@@ -156,12 +166,13 @@ export default function Account() {
<LoadingState message="Loading invoices..." paddingClass="py-8" />
</Show>
<Show when={!invoicesLoading()}>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<Show when={billing.invoices().length > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<ul class="space-y-3">
<For each={invoices()}>
<For each={billing.invoices()}>
{(invoice) => {
const isOpen = () => invoice.status === "open"
const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const status = () => invoiceStatus(invoice)
const isOpen = () => status() === "open"
const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"
const periodLabel = () => {
const start = new Date(invoice.period_start * 1000)
const end = new Date(invoice.period_end * 1000)
@@ -177,8 +188,11 @@ export default function Account() {
<div class="flex items-center justify-between gap-3">
<div>
<span class="font-medium text-gray-900">
${(invoice.amount_due / 100).toFixed(2)}
${(invoice.amount / 100).toFixed(2)}
</span>
<Show when={invoice.method}>
<span class="text-xs text-gray-500"> · paid via {methodLabels[invoice.method!] ?? invoice.method}</span>
</Show>
<Show when={invoice.period_start && invoice.period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
@@ -188,8 +202,19 @@ export default function Account() {
<span class="text-xs text-blue-600 font-medium">Pay now</span>
</Show>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{invoice.status}
{status()}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void printInvoice(invoice)
}}
disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
@@ -202,12 +227,15 @@ export default function Account() {
</section>
</div>
<Show when={activeInvoice()}>
<Show when={selectedInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={handleInvoiceDialogClose}
onClose={() => {
setSelectedInvoice(undefined)
billing.refetch()
}}
/>
)}
</Show>
+4 -4
View File
@@ -17,10 +17,10 @@ export default function Home() {
const [showRelayModal, setShowRelayModal] = createSignal(false)
const [showLoginModal, setShowLoginModal] = createSignal(false)
const [draftRelay, setDraftRelay] = createSignal<RelayFormValues>()
const [initialPlan, setInitialPlan] = createSignal<RelayFormValues["plan"]>("free")
const [initialPlanId, setInitialPlanId] = createSignal<RelayFormValues["plan_id"]>("free")
function openRelayModal(plan: RelayFormValues["plan"] = "free") {
setInitialPlan(plan)
function openRelayModal(planId: RelayFormValues["plan_id"] = "free") {
setInitialPlanId(planId)
setShowRelayModal(true)
}
@@ -404,7 +404,7 @@ export default function Home() {
<RelayForm
syncSubdomainWithName
initialValues={{ plan: initialPlan() }}
initialValues={{ plan_id: initialPlanId() }}
onSubmit={onRelayFormSubmit}
submitLabel="Continue"
submittingLabel="Creating..."
+12 -6
View File
@@ -14,8 +14,8 @@ export default function AdminTenantDetail() {
const [relays] = useAdminTenantRelays(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading)
const pastDueLabel = () => {
const ts = tenant()?.past_due_at
const churnedLabel = () => {
const ts = tenant()?.churned_at
if (!ts) return null
return new Date(ts * 1000).toLocaleString()
}
@@ -34,7 +34,7 @@ export default function AdminTenantDetail() {
<dl class="grid gap-y-3 text-sm">
<div class="flex gap-2">
<dt class="text-gray-500">Status:</dt>
<dd class="font-medium uppercase tracking-wide">{t().past_due_at ? "past due" : "active"}</dd>
<dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
</div>
<Show when={t().stripe_customer_id}>
<div class="flex gap-2">
@@ -42,10 +42,10 @@ export default function AdminTenantDetail() {
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
</div>
</Show>
<Show when={pastDueLabel()}>
<Show when={churnedLabel()}>
<div class="flex gap-2">
<dt class="text-gray-500">Past Due Since:</dt>
<dd class="text-red-600 font-medium">{pastDueLabel()}</dd>
<dt class="text-gray-500">Delinquent Since:</dt>
<dd class="text-red-600 font-medium">{churnedLabel()}</dd>
</div>
</Show>
<Show when={t().nwc_error}>
@@ -54,6 +54,12 @@ export default function AdminTenantDetail() {
<dd class="text-red-600">{t().nwc_error}</dd>
</div>
</Show>
<Show when={t().stripe_error}>
<div class="flex gap-2">
<dt class="text-gray-500">Stripe Error:</dt>
<dd class="text-red-600">{t().stripe_error}</dd>
</div>
</Show>
</dl>
)}
</Show>
+10 -78
View File
@@ -1,5 +1,5 @@
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
@@ -9,9 +9,10 @@ import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
@@ -30,10 +31,7 @@ export default function RelayDetail() {
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
@@ -42,25 +40,10 @@ export default function RelayDetail() {
}
})
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_is_set
})
// Suppress the shared banner's redundant pay/setup prompts while this page's
// own inline plan-change modals are open.
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false))
return (
<PageContainer>
@@ -69,44 +52,6 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members()?.length}
@@ -129,20 +74,7 @@ export default function RelayDetail() {
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
void refetchBilling()
}}
/>
)}
@@ -151,7 +83,7 @@ export default function RelayDetail() {
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
void refetchBilling()
}}
/>
</PageContainer>
+14 -2
View File
@@ -1,4 +1,4 @@
import { createSignal, Show } from "solid-js"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
@@ -7,6 +7,8 @@ import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
export default function RelayNew() {
const navigate = useNavigate()
@@ -14,13 +16,22 @@ export default function RelayNew() {
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = ""
// While this flow's inline modals are open, suppress the shared banner's
// overlapping pay/setup prompts (it still surfaces churn / method errors).
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false))
async function handleSubmit(values: RelayFormValues) {
const relay = await createRelayForActiveTenant(values)
createdRelayId = relay.id
void refetchBilling()
if (values.plan !== "free") {
if (values.plan_id !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
// Materialize the invoice for this change (no collection, no DM) so we
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
// first, so a just-created invoice is visible here.
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
@@ -41,6 +52,7 @@ export default function RelayNew() {
function handleSetupClose() {
setPaymentSetupOpen(false)
void refetchBilling()
navigate(`/relays/${createdRelayId}`)
}