Compare commits

...

23 Commits

Author SHA1 Message Date
userAdityaa fd0678cfec feat(infra): pass Blossom S3 config to Zooid with schema key prefix 2026-05-13 20:22:11 +05:45
Jon Staab c0aff5f7cf Refactor billing module 2026-05-12 16:32:05 -07:00
Jon Staab c9c1dd2c4c Group subscription items by price 2026-05-12 15:53:17 -07:00
Jon Staab 679a56edc3 Add docker publish workflow 2026-05-12 14:48:50 -07:00
userAdityaa e7efd9d08b fix: stripe portal dead-end with callback return flow (#67)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-11 20:48:55 +00:00
userAdityaa 0151762362 chore: improve billing customer name using Nostr kind 0 with pubkey fallback (#66)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-08 22:52:13 +00:00
userAdityaa a79c43e17e feat: open payment modal immediately on relay plan upgrade (#64)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-07 18:35:24 +00:00
Jon Staab dbe25c372f Conflate id and schema 2026-05-05 17:47:13 -07:00
userAdityaa 80a86452d0 chore: encrypt tenant NWC URL at rest and stop secret exposure in tenant APIs (#58)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-05 20:42:12 +00:00
userAdityaa b1e3747ddb fix: manual Lightning payment reconciliation with Stripe invoice state (#54)
Reviewed-on: coracle/caravel#54
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 23:38:57 +00:00
userAdityaa 29f657635c fix: relay sync create/update classification to prevent false create mode on updates (#56)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 14:21:37 +00:00
userAdityaa 9556a34b19 fix: silent relay state drift when activity bus drops events (#53)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-29 18:36:36 +00:00
userAdityaa 3ecd285290 chore: prevent duplicate Lightning charges by adding durable invoice-level NWC payment guard (#51)
Reviewed-on: coracle/caravel#51
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 13:02:56 +00:00
userAdityaa 9f8fe7261f fix: add idempotency keys to all Stripe mutation calls (#49)
Reviewed-on: coracle/caravel#49
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 12:34:35 +00:00
userAdityaa 1aeb15971d fix: silent NWC auto-payment failure messaging in invoice.created fallback (#46)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 16:09:50 +00:00
userAdityaa 48f20dc1a5 fix: relay sync failures with delayed bounded retries (#45)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 15:32:09 +00:00
userAdityaa c261d8a146 fix: enforce relay member capacity limits from plan definitions (#43)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 20:56:03 +00:00
userAdityaa 21b36272b8 feat: add missing SQLite indexes for billing and API hot-path queries (#44)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 16:40:09 +00:00
userAdityaa a26bc1127d chore: strict Subdomain Validation with Detailed Error Messages (#42)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:09:37 +00:00
userAdityaa bc79da34cf feat: encourage payment setup for paid relays without making it required (#40)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:07:27 +00:00
userAdityaa 38e3a64312 feat: add confirmation dialog for relay deactivate/reactivate with explicit warnings (#41)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 21:25:23 +00:00
userAdityaa d209353abd docs: document delinquent relay status across spec (#35)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 18:14:14 +00:00
userAdityaa 08c9a2920b feat: display relay provisioning errors in UI (#39)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 18:08:47 +00:00
43 changed files with 3003 additions and 801 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Docker
on:
push:
branches: [master]
env:
REGISTRY: gitea.coracle.social
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- component: frontend
image: coracle/caravel-frontend
context: frontend
- component: backend
image: coracle/caravel-backend
context: backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.image }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -26,7 +26,7 @@ docker run -it \
-v ./config:/app/config \ -v ./config:/app/config \
-v ./media:/app/media \ -v ./media:/app/media \
-v ./data:/app/data \ -v ./data:/app/data \
ghcr.io/coracle-social/zooid gitea.coracle.social/coracle/zooid
``` ```
### 2. Configure the backend ### 2. Configure the backend
+10
View File
@@ -26,7 +26,17 @@ LIVEKIT_URL=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
# Blossom S3 (optional; when region, bucket, access key, and secret are all set, relays with blossom enabled sync with adapter s3 and key_prefix = relay schema)
BLOSSOM_S3_ENDPOINT=
BLOSSOM_S3_REGION=
BLOSSOM_S3_BUCKET=
BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing # Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
ENCRYPTION_SECRET= # Nostr secret key (hex or nsec) used to encrypt tenant NWC URLs at rest
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...) STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production) STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans
STRIPE_PRICE_GROWTH= # Stripe price ID (price_...) for the Growth plan; required for paid plans
+18 -3
View File
@@ -43,9 +43,17 @@ Environment variables:
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | | `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | | `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | | `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `BLOSSOM_S3_ENDPOINT` | S3-compatible endpoint URL for Blossom; omit for AWS S3 | _optional_ |
| `BLOSSOM_S3_REGION` | S3 region; with bucket, access key, and secret enables S3 for Blossom | _optional_ |
| `BLOSSOM_S3_BUCKET` | S3 bucket name | _optional_ |
| `BLOSSOM_S3_ACCESS_KEY` | S3 access key ID | _optional_ |
| `BLOSSOM_S3_SECRET_KEY` | S3 secret access key | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | | `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ENCRYPTION_SECRET` | Nostr secret key (hex or nsec) used to encrypt tenant NWC URLs at rest | _required_ |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ | | `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan | _required for paid plans_ |
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan | _required for paid plans_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | | `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | | `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | | `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
@@ -74,13 +82,20 @@ Public exceptions:
- `GET /tenants` — list tenants (admin) - `GET /tenants` — list tenants (admin)
- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise) - `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) - `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only) - `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant)
- `GET /relays` — list relays (admin)
- `POST /relays` — create relay (admin or relay tenant) - `POST /relays` — create relay (admin or relay tenant)
- `GET /relays/:id` — get relay (admin or relay tenant) - `GET /relays/:id` — get relay (admin or relay tenant)
- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant)
- `PUT /relays/:id` — update relay (admin or relay tenant) - `PUT /relays/:id` — update relay (admin or relay tenant)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only) - `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
- `GET /invoices/:id` — get invoice (admin or same tenant)
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
## API Auth Model ## API Auth Model
@@ -0,0 +1,11 @@
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
ON relay (tenant, id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
ON relay (tenant, status, plan);
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);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS invoice_nwc_payment (
invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'paid')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey
ON invoice_nwc_payment (tenant_pubkey);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment (
invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
bolt11 TEXT 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_invoice_manual_lightning_payment_tenant_pubkey
ON invoice_manual_lightning_payment (tenant_pubkey);
+16 -5
View File
@@ -9,6 +9,7 @@ Members:
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
- `billing: Billing` - `billing: Billing`
- `infra: Infra`
Notes: Notes:
@@ -56,7 +57,7 @@ Notes:
- Serves `GET /tenants` - Serves `GET /tenants`
- Authorizes admin only - Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants` - Return `data` is a list of `TenantResponse` structs (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn create_tenant(...) -> Response` ## `async fn create_tenant(...) -> Response`
@@ -68,20 +69,21 @@ Notes:
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant - On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- If Stripe customer creation fails, return `code=stripe-customer-create-failed` - If Stripe customer creation fails, return `code=stripe-customer-create-failed`
- Always returns `200` (create-or-get is uniform) - Always returns `200` (create-or-get is uniform)
- Return `data` is a single `Tenant` struct - Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn get_tenant(...) -> Response` ## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant - Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `query.get_tenant` - Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn update_tenant(...) -> Response` ## `async fn update_tenant(...) -> Response`
- Serves `PUT /tenants/:pubkey` - Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant - Authorizes admin or matching tenant
- Accepts `nwc_url` in the request body; encrypts it before storage using `cipher::encrypt`
- Updates tenant using `command.update_tenant` - Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct - Return `data` is the updated `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn list_tenant_relays(...) -> Response` ## `async fn list_tenant_relays(...) -> Response`
@@ -103,6 +105,14 @@ Notes:
- Authorizes admin or relay owner - Authorizes admin or relay owner
- Return `data` is a single relay struct from `query.get_relay` - Return `data` is a single relay struct from `query.get_relay`
## `async fn list_relay_members(...) -> Response`
- Serves `GET /relays/:id/members`
- Authorizes admin or relay owner
- For unsynced relays, returns an empty member list without calling zooid
- For synced relays, proxies the member list from zooid via `infra`
- Return `data` is `{ members }`
## `async fn create_relay(...) -> Response` ## `async fn create_relay(...) -> Response`
- Serves `POST /relays` - Serves `POST /relays`
@@ -117,6 +127,7 @@ Notes:
- Serves `PUT /relays/:id` - Serves `PUT /relays/:id`
- Authorizes admin or relay owner - Authorizes admin or relay owner
- Validates/prepares the relay data to be saved using `prepare_relay` - Validates/prepares the relay data to be saved using `prepare_relay`
- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded`
- Updates the given relay using `command.update_relay` - Updates the given relay using `command.update_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists` - If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct. - Return `data` is a single relay struct.
@@ -132,7 +143,7 @@ Notes:
- Serves `POST /relays/:id/deactivate` - Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner - Authorizes admin or relay owner
- If relay is already inactive, return a `400` with `code=relay-is-inactive` - If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
- Call `command.deactivate_relay` - Call `command.deactivate_relay`
- Return `data` is empty - Return `data` is empty
+83 -38
View File
@@ -2,80 +2,120 @@
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle. Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
It owns the domain logic only: every raw Stripe REST call goes through the `Stripe` wrapper (see `spec/stripe.md`), and every Lightning (NWC) wallet operation and the fiat-minor-units → millisatoshi conversion go through the helpers in `spec/bitcoin.md`.
Members: Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL` - `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY` - `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`)
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
- `robot: Robot` - `robot: Robot`
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members - Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()`
- Panics if `STRIPE_SECRET_KEY` is missing/empty - Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional)
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
## `pub fn start(&self)` ## `pub fn start(&self)`
- Subscribes to `command.notify.subscribe()` - Subscribes to `command.notify.subscribe()`
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`. - On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`: resolve the relay named by the activity (skip if it no longer exists) and reconcile its tenant via `sync_tenant_subscription`.
- The startup/lagged reconcile loop calls `sync_tenant_subscription` for every tenant.
## `pub fn sync_relay_subscription(&self, activity: &Activity)` ## `fn sync_tenant_subscription(&self, tenant_pubkey: &str)`
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent. Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
- Fetch the relay and tenant associated with the `activity` Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. Every such relay's `stripe_subscription_item_id` points at the shared item for its plan; relays that aren't billed (free, inactive, delinquent) have it cleared.
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment.
- **If relay is `active` and on a paid plan**:
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early. - Fetch the tenant and its relays. Build the desired state: for each `active` relay on a paid plan with a non-empty `stripe_price_id`, count relays per price.
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`. - **Resolve the live subscription**: if the tenant has a `stripe_subscription_id`, fetch it. If Stripe no longer knows about it, or its status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and treat the tenant as having no subscription.
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`. - **No relays to bill** (desired state empty): if the tenant still has a `stripe_subscription_id`, cancel the Stripe subscription and call `command.clear_tenant_subscription`. Clear `stripe_subscription_item_id` on every relay that has one. Return.
- **No subscription yet**: create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and one item per `(price, quantity)`. Save the subscription ID via `command.set_tenant_subscription`.
- **Existing subscription**: fetch its current items. For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item. Delete any item whose price no longer appears in the desired state.
- **Point relays at items**: for each relay, set `stripe_subscription_item_id` (via `command.set_relay_subscription_item`) to the shared item for its plan, or clear it (via `command.delete_relay_subscription_item`) if the relay is not billed.
- **Downgrade validation**: if any quantity decreased or any item was removed, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
- Verify the webhook signature using `self.stripe_webhook_secret` - Verify and parse the event via `self.stripe.construct_event(payload, signature)` (checks the `Stripe-Signature` HMAC and timestamp tolerance — see `spec/stripe.md`)
- Parse the event and dispatch by type: - Dispatch by type:
- `invoice.created` -> `self.handle_invoice_created` - `invoice.created` -> `self.handle_invoice_created`
- `invoice.paid` -> `self.handle_invoice_paid` - `invoice.paid` -> `self.handle_invoice_paid`
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed` - `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
- `invoice.overdue` -> `self.handle_invoice_overdue` - `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated` - `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted` - `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- `payment_method.attached` -> `self.handle_payment_method_attached`
- Unknown event types are ignored (return Ok) - Unknown event types are ignored (return Ok)
## `pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String>`
- Resolves a display name via `robot.fetch_nostr_name(tenant_pubkey)`, falling back to the first 8 chars of the pubkey
- Creates the Stripe customer via `stripe.create_customer(display_name, tenant_pubkey)` and returns its id
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>` ## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
- Fetches invoices from Stripe API for the given customer - Delegates to `stripe.list_invoices` — returns the `data` array of the customer's invoices
- Returns the `data` array from the Stripe response
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>` ## `pub async fn stripe_create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
- Fetches a single invoice from Stripe API by ID - Delegates to `stripe.create_portal_session` — returns the Customer Portal session URL
- Returns the full Stripe invoice object
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>` ## `pub async fn get_invoice_with_tenant(&self, invoice_id: &str) -> Result<(Value, Tenant), InvoiceLookupError>`
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`) - Fetches the invoice via `stripe.get_invoice` (a Stripe 4xx surfaces as `InvoiceLookupError::StripeClient`)
- Looks up the tenant by the invoice's `customer` field; errors if the invoice has no customer or no tenant matches
## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result<Value, InvoiceLookupError>`
If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`bitcoin.system_wallet().invoice_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged.
## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result<String>`
- Returns the existing bolt11 if one is already recorded for the invoice
- Otherwise creates one via `create_bolt11`, records it with `command.insert_manual_lightning_invoice_payment`, and returns it (re-reading the stored row if the insert lost a race)
## `pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String>`
- Converts the fiat amount to msats via `bitcoin.fiat_minor_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`)
- Issues a bolt11 invoice for that amount on the system NWC wallet (`bitcoin.system_wallet().make_invoice(...)`)
- Returns the bolt11 invoice string - Returns the bolt11 invoice string
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>` ## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
- Creates a Stripe Customer Portal session for the given customer Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
- Returns the portal session URL
- If `tenant.nwc_url` is empty, return early.
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Attempt NWC payment via `nwc_pay_invoice`.
- On success: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and call `command.clear_tenant_nwc_error`.
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
- If tenant has no card payment method (`stripe.has_payment_method`), return early.
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe.list_invoices`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Call `stripe.pay_invoice` to retry collection using the card on file.
- Log and continue on failures.
## `fn handle_invoice_created(&self, invoice: &Invoice)` ## `fn handle_invoice_created(&self, invoice: &Invoice)`
Attempts to pay a new subscription invoice. Payment priority: Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`: 1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first):
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet) - The system wallet (`bitcoin.system_wallet()`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::parse` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries.
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet) - If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done.
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`. - If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM).
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option. - If it fails after a charge may have gone out (needs reconciliation): set `nwc_error` and return the error without falling through — a human must reconcile before any retry.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically. 2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt.
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment. 3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with `amount_due` of 0. Skip invoices with `amount_due` of 0.
@@ -85,7 +125,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set: - If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due` - Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive) - Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
- Reactivate each one via `command.activate_relay` - Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)` ## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
@@ -98,7 +138,7 @@ Skip invoices with `amount_due` of 0.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)` ## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Deactivate all active relays on paid plans via `command.deactivate_relay` - Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment - Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)` ## `fn handle_subscription_updated(&self, subscription: &Subscription)`
@@ -106,9 +146,14 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`: - If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Deactivate all active paid relays for the tenant via `command.deactivate_relay` - Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)` ## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)`
- Look up tenant by `stripe_customer_id`
- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately
+62
View File
@@ -0,0 +1,62 @@
# `bitcoin` — Bitcoin / Lightning helpers
Small wrappers around the Bitcoin-facing services the app talks to: Nostr Wallet Connect (NWC) wallets for Lightning invoices/payments, and a fiat↔BTC spot price feed, plus the fiat-minor-units → millisatoshi conversion that ties them together. The billing-specific orchestration (which wallet pays which invoice, double-charge guards, DMs, etc.) lives in `spec/billing.md`.
## `pub struct Bitcoin`
Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the wallet used to *receive* payments — issue and look up bolt11 invoices) and the HTTP client used for the fiat↔BTC spot price feed.
Members:
- `system_nwc_url: String` - from `NWC_URL`
- `http: reqwest::Client`
### `pub fn from_env() -> Self`
Reads `NWC_URL` (the system / receiving wallet) and constructs a fresh `reqwest::Client`. Unlike the Stripe keys, `NWC_URL` is **optional**: if it's unset, Lightning operations fail at use time with a clear error rather than panicking at startup. This is what `Billing::new` calls.
### `pub fn system_wallet(&self) -> Result<Wallet>`
Returns the system `Wallet` (parsed from `system_nwc_url` with label `"system"`). Errors if `NWC_URL` is unset or malformed.
### `pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64>`
Fetches the live BTC spot price (via `btc_spot_price_from_base` against Coinbase) for `currency` and converts `amount_due_minor` to millisatoshis via `fiat_minor_to_msats_from_quote`. `currency` is upper-cased before use.
## `pub struct Wallet`
A handle to a single NWC wallet. Each operation opens a fresh NWC connection and tears it down (`shutdown`) afterwards.
Member:
- `uri: NostrWalletConnectURI` - the parsed `nostr+walletconnect://…` URI
### `pub fn parse(uri: &str, label: &str) -> Result<Self>`
Parses an `nostr+walletconnect://` URI. `label` (e.g. `"system"` / `"tenant"`) only flavours the error message — `invalid {label} NWC URL` — so callers can tell which wallet was misconfigured.
### `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String>`
Issues a bolt11 invoice for `amount_msats` with the given description. Returns the bolt11 string.
### `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
Pays a bolt11 invoice.
### `pub async fn invoice_settled(&self, bolt11: &str) -> Result<bool>`
Looks up a bolt11 invoice (previously issued by this wallet) and returns whether it has settled — true if the transaction state is `Settled` or `settled_at` is present.
## `pub async fn btc_spot_price_from_base(http: &reqwest::Client, api_base: &str, currency: &str) -> Result<f64>`
Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a Coinbase-shaped API at `api_base` (`{api_base}/BTC-{currency}/spot`); production callers reach it via `Bitcoin::fiat_minor_to_msats` with the real Coinbase base, while tests can point it at a stub. Errors if the quote is missing, unparseable, or non-positive.
## `pub fn fiat_minor_to_msats_from_quote(amount_due_minor: i64, currency: &str, btc_price_in_fiat: f64) -> Result<u64>`
Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, given a BTC price quote in that currency.
- Errors if `amount_due_minor <= 0` or `btc_price_in_fiat <= 0`
- Converts minor units to a major-unit amount using the currency's decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3 — following Stripe's currency conventions; an unrecognized or malformed code is an error)
- `msats = (amount_fiat / btc_price_in_fiat) * 100_000_000_000`
- Rounds **up** so we never under-charge, but snaps to the nearest integer when within `1e-6` of one to avoid floating-point artifacts at integer boundaries
- Errors if the result is non-finite, non-positive, or exceeds `u64::MAX`
+8
View File
@@ -44,6 +44,14 @@ Notes:
- Sets relay status to `inactive` - Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)` - Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent`
- Logs activity as `(deactivate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
+7 -2
View File
@@ -5,6 +5,7 @@ Infra is a service which listens for activity and synchronizes relay updates to
Members: Members:
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL` - `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
- `blossom_s3: Option<BlossomS3Sync>` - shared Blossom S3 settings from `BLOSSOM_S3_*` when region, bucket, access key, and secret are all non-empty after trim
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
@@ -15,12 +16,15 @@ Members:
## `pub async fn start(self)` ## `pub async fn start(self)`
- Subscribes to `command.notify` - Subscribes to `command.notify`
- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty.
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`. - Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
## `async fn handle_activity(&self, activity: &Activity)` ## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`. - For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`). - For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay.
- Retry scheduling stops after the configured max attempts to avoid infinite retry loops.
- Other activity types are ignored (e.g. `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)` ## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
@@ -33,3 +37,4 @@ Members:
- Otherwise, sends `PATCH /relay/:id` to update it. - Otherwise, sends `PATCH /relay/:id` to update it.
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity. - Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles. - Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
- When `blossom_s3` is configured and the relay has blossom enabled, the blossom section includes `adapter: "s3"`, S3 fields from the environment, and `s3.key_prefix` set to the relay's `schema`. Otherwise blossom omits S3 (zooid defaults to local storage) or sends `{ "enabled": false }` when blossom is disabled.
+4 -4
View File
@@ -52,7 +52,7 @@ There are three plans available:
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information. 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 - `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 - `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 via `ENCRYPTION_SECRET`; never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment. - `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 - `created_at` unix timestamp identifying tenant creation time
- `stripe_customer_id` a string identifying the associated stripe customer - `stripe_customer_id` a string identifying the associated stripe customer
@@ -63,13 +63,13 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique. A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - a random ID identifying the relay - `id` - calculated based on `subdomain` + 8 random hex chars
- `tenant` - the tenant's pubkey - `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`) - `schema` - the relay's db schema (read only, same as `id`)
- `subdomain` - the relay's subdomain - `subdomain` - the relay's subdomain
- `plan` - the relay's plan - `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans. - `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - `active|inactive`. Only `active` relays count toward billing. - `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. - `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing. - `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name - `info_name` - the relay's name
-4
View File
@@ -39,10 +39,6 @@ Members:
- Returns the tenant matching the given `stripe_customer_id` - Returns the tenant matching the given `stripe_customer_id`
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>` ## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id` - Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
+113
View File
@@ -0,0 +1,113 @@
# `pub struct Stripe`
A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns `serde_json::Value` (or small typed results). The domain logic that drives it lives in `spec/billing.md`.
Members:
- `secret_key: String` - Stripe API key, used as the bearer token and as the HMAC key for idempotency keys
- `webhook_secret: String` - secret for verifying Stripe webhook signatures
- `http: reqwest::Client`
All requests authenticate with `secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with `secret_key`. Reconcile-to-desired-state writes (e.g. setting an item quantity) intentionally omit the idempotency key, since re-applying the same target is a no-op.
On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line.
## `pub fn from_env() -> Self`
Reads `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` (both required) and constructs the client. Panics if either is missing or blank. This is what `Billing::new` calls.
## `pub fn new(secret_key: String, webhook_secret: String) -> Self`
Constructs the client with a fresh `reqwest::Client` from explicit keys (does not touch the environment).
## `pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result<String>`
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
- Idempotent on `tenant_pubkey`
- Returns the new customer id; errors if it isn't a `cus_…` id
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<Value>>`
- `GET /v1/subscriptions/:id`
- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the subscription object
## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap<String, i64>) -> Result<(String, BTreeMap<String, String>)>`
- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry
- Idempotent on the customer and the `(price, quantity)` set
- Returns the subscription id and a map from price id to the created subscription item id
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<String>`
- `POST /v1/subscription_items`
- Idempotent on `(subscription_id, price_id)`
- Returns the new subscription item 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<Value>`
- `GET /v1/invoices?customer=…`
- Returns the `data` array
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Value, InvoiceLookupError>`
- `GET /v1/invoices/:id`
- On a 4xx response, returns `InvoiceLookupError::StripeClient { status }` (callers usually surface this as a client error, e.g. `404` "no such invoice"); other failures are `InvoiceLookupError::Internal`
- Returns the full invoice object
## `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`
## `pub async fn preview_upcoming_invoice(&self, customer_id: &str, subscription_id: Option<&str>) -> Result<Value>`
- `GET /v1/invoices/upcoming?customer=…[&subscription=…]`
- Used to validate proration when a subscription is downgraded
## `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 portal session URL
## `pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result<Event>`
Verifies the `Stripe-Signature` header against `webhook_secret` and parses the body.
- Parse `t=` (timestamp) and `v1=` (signature) from the header
- Compute `HMAC-SHA256(webhook_secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch
- Error if the timestamp is more than 300 seconds from now
- Returns the deserialized `Event` (`{ event_type, data: { object } }`)
# `pub enum InvoiceLookupError`
- `StripeClient { status: reqwest::StatusCode }` - Stripe returned a 4xx for an invoice lookup
- `Internal(anyhow::Error)` - any other failure
Implements `Display`/`Error` and `From<anyhow::Error>` / `From<reqwest::Error>` (both mapping to `Internal`).
# `pub struct Event` / `pub struct EventData`
The verified, parsed webhook event: `Event { event_type: String, data: EventData }`, `EventData { object: serde_json::Value }`.
+288 -69
View File
@@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::{Result, anyhow};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, Query as QueryParams, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
@@ -12,12 +12,14 @@ use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind}; use nostr_sdk::{Event, JsonUtil, Kind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::billing::{Billing, InvoiceLookupError}; use crate::billing::Billing;
use crate::command::Command; use crate::command::Command;
use crate::infra::Infra;
use crate::models::{ use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
use crate::query::Query; use crate::query::Query;
use crate::stripe::InvoiceLookupError;
use axum::body::Bytes; use axum::body::Bytes;
#[derive(Clone)] #[derive(Clone)]
@@ -27,6 +29,7 @@ pub struct Api {
query: Query, query: Query,
command: Command, command: Command,
billing: Billing, billing: Billing,
infra: Infra,
} }
async fn stripe_webhook( async fn stripe_webhook(
@@ -117,7 +120,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
} }
impl Api { impl Api {
pub fn new(query: Query, command: Command, billing: Billing) -> Self { pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS") let admins = std::env::var("ADMINS")
.unwrap_or_default() .unwrap_or_default()
@@ -131,6 +134,7 @@ impl Api {
query, query,
command, command,
billing, billing,
infra,
} }
} }
@@ -148,6 +152,7 @@ impl Api {
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_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/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay)) .route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay)) .route("/relays/:id/reactivate", post(reactivate_relay))
@@ -251,27 +256,26 @@ impl Api {
} }
} }
fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> { async fn fetch_relay_members(&self, relay: &Relay) -> Result<Vec<String>> {
if !relay if relay.synced == 0 {
.subdomain return Ok(Vec::new());
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(anyhow!("invalid-subdomain"));
} }
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?; self.infra.list_relay_members(&relay.id).await
}
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
validate_subdomain_label(&relay.subdomain)?;
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?;
if !plan.blossom && relay.blossom_enabled == 1 { if !plan.blossom && relay.blossom_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(RelayValidationError::PremiumFeature);
} }
if !plan.livekit && relay.livekit_enabled == 1 { if !plan.livekit && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(RelayValidationError::PremiumFeature);
} }
if relay.schema.is_empty() {
relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id);
}
if relay.status.is_empty() { if relay.status.is_empty() {
relay.status = RELAY_STATUS_ACTIVE.to_string(); relay.status = RELAY_STATUS_ACTIVE.to_string();
} }
@@ -289,6 +293,96 @@ impl Api {
} }
} }
const SUBDOMAIN_LABEL_MAX_LEN: usize = 63;
const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubdomainValidationError {
Empty,
TooLong,
Reserved,
EdgeHyphen,
InvalidCharacters,
}
impl SubdomainValidationError {
fn code(self) -> &'static str {
match self {
Self::Empty => "subdomain-empty",
Self::TooLong => "subdomain-too-long",
Self::Reserved => "subdomain-reserved",
Self::EdgeHyphen => "subdomain-invalid-hyphen",
Self::InvalidCharacters => "subdomain-invalid-characters",
}
}
fn message(self) -> &'static str {
match self {
Self::Empty => "subdomain is required",
Self::TooLong => "subdomain must be 63 characters or fewer",
Self::Reserved => "subdomain is reserved",
Self::EdgeHyphen => "subdomain cannot start or end with a hyphen",
Self::InvalidCharacters => {
"subdomain may only contain lowercase letters, numbers, and hyphens"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelayValidationError {
InvalidPlan,
PremiumFeature,
Subdomain(SubdomainValidationError),
}
impl RelayValidationError {
fn code(self) -> &'static str {
match self {
Self::InvalidPlan => "invalid-plan",
Self::PremiumFeature => "premium-feature",
Self::Subdomain(reason) => reason.code(),
}
}
fn message(self) -> &'static str {
match self {
Self::InvalidPlan => "plan not found",
Self::PremiumFeature => "feature requires a paid plan",
Self::Subdomain(reason) => reason.message(),
}
}
}
impl From<SubdomainValidationError> for RelayValidationError {
fn from(value: SubdomainValidationError) -> Self {
Self::Subdomain(value)
}
}
fn validate_subdomain_label(subdomain: &str) -> std::result::Result<(), SubdomainValidationError> {
if subdomain.is_empty() {
return Err(SubdomainValidationError::Empty);
}
if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN {
return Err(SubdomainValidationError::TooLong);
}
if subdomain.starts_with('-') || subdomain.ends_with('-') {
return Err(SubdomainValidationError::EdgeHyphen);
}
if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) {
return Err(SubdomainValidationError::Reserved);
}
if !subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SubdomainValidationError::InvalidCharacters);
}
Ok(())
}
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response { fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response() (status, Json(OkResponse { data, code: "ok" })).into_response()
} }
@@ -316,6 +410,14 @@ fn parse_bool_default(value: i64, default: i64) -> i64 {
} }
} }
fn relay_validation_error_response(error: RelayValidationError) -> Response {
err(
StatusCode::UNPROCESSABLE_ENTITY,
error.code(),
error.message(),
)
}
fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> { fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?; let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else { let sqlx::Error::Database(db_err) = sqlx_err else {
@@ -341,6 +443,31 @@ struct IdentityResponse {
is_admin: bool, is_admin: bool,
} }
#[derive(Serialize)]
struct TenantResponse {
pubkey: String,
nwc_is_set: bool,
nwc_error: Option<String>,
created_at: i64,
stripe_customer_id: String,
stripe_subscription_id: Option<String>,
past_due_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
fn from(t: Tenant) -> Self {
TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
created_at: t.created_at,
stripe_customer_id: t.stripe_customer_id,
stripe_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateRelayRequest { struct CreateRelayRequest {
tenant: String, tenant: String,
@@ -382,7 +509,13 @@ async fn list_tenants(
state.api.require_admin(&pubkey)?; state.api.require_admin(&pubkey)?;
match state.api.query.list_tenants().await { match state.api.query.list_tenants().await {
Ok(tenants) => Ok(ok(StatusCode::OK, tenants)), Ok(tenants) => Ok(ok(
StatusCode::OK,
tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>(),
)),
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
@@ -411,7 +544,7 @@ async fn create_tenant(
let pubkey = state.api.extract_auth_pubkey(&headers)?; let pubkey = state.api.extract_auth_pubkey(&headers)?;
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)), Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => { Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id, Ok(id) => id,
@@ -435,10 +568,10 @@ async fn create_tenant(
}; };
match state.api.command.create_tenant(&tenant).await { match state.api.command.create_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, tenant)), Ok(()) => Ok(ok(StatusCode::OK, TenantResponse::from(tenant))),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)), Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => Ok(err( Ok(None) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
@@ -481,7 +614,7 @@ async fn get_tenant(
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = state.api.get_tenant_or_404(&pubkey).await?; let tenant = state.api.get_tenant_or_404(&pubkey).await?;
Ok(ok(StatusCode::OK, tenant)) Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
} }
async fn list_relays( async fn list_relays(
@@ -577,6 +710,40 @@ async fn list_relay_activity(
} }
} }
async fn list_relay_members(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
match state.api.fetch_relay_members(&relay).await {
Ok(members) => Ok(ok(
StatusCode::OK,
serde_json::json!({ "members": members }),
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
async fn create_relay( async fn create_relay(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -585,10 +752,16 @@ async fn create_relay(
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &payload.tenant)?; state.api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let mut relay = Relay { let mut relay = Relay {
id: uuid::Uuid::new_v4().to_string(), id: relay_id.clone(),
tenant: payload.tenant, tenant: payload.tenant,
schema: String::new(), schema: relay_id.clone(),
subdomain: payload.subdomain, subdomain: payload.subdomain,
plan: payload.plan, plan: payload.plan,
stripe_subscription_item_id: None, stripe_subscription_item_id: None,
@@ -608,27 +781,9 @@ async fn create_relay(
}; };
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) => {
return Ok(err( return Ok(relay_validation_error_response(e));
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
@@ -674,10 +829,13 @@ async fn update_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?; state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
if let Some(v) = payload.subdomain { if let Some(v) = payload.subdomain {
relay.subdomain = v; relay.subdomain = v;
} }
if let Some(v) = payload.plan { if let Some(v) = requested_plan.clone() {
relay.plan = v; relay.plan = v;
} }
if let Some(v) = payload.info_name { if let Some(v) = payload.info_name {
@@ -712,30 +870,44 @@ async fn update_relay(
} }
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) => {
return Ok(err( return Ok(relay_validation_error_response(e));
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = match state.api.fetch_relay_members(&relay).await {
Ok(members) => members.len() as i64,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"member-limit-exceeded",
&message,
));
}
}
}
match state.api.command.update_relay(&relay).await { match state.api.command.update_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, relay)), Ok(()) => Ok(ok(StatusCode::OK, relay)),
Err(e) => { Err(e) => {
@@ -872,6 +1044,13 @@ async fn get_invoice(
.map_err(map_invoice_lookup_error)?; .map_err(map_invoice_lookup_error)?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = state
.api
.billing
.reconcile_manual_lightning_invoice(&id, &invoice)
.await
.map_err(map_invoice_lookup_error)?;
Ok(ok(StatusCode::OK, invoice)) Ok(ok(StatusCode::OK, invoice))
} }
@@ -889,6 +1068,13 @@ async fn get_invoice_bolt11(
.map_err(map_invoice_lookup_error)?; .map_err(map_invoice_lookup_error)?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = state
.api
.billing
.reconcile_manual_lightning_invoice(&id, &invoice)
.await
.map_err(map_invoice_lookup_error)?;
let status = invoice["status"].as_str().unwrap_or_default(); let status = invoice["status"].as_str().unwrap_or_default();
if status != "open" { if status != "open" {
return Ok(err( return Ok(err(
@@ -901,7 +1087,12 @@ async fn get_invoice_bolt11(
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let currency = invoice["currency"].as_str().unwrap_or("usd"); let currency = invoice["currency"].as_str().unwrap_or("usd");
match state.api.billing.create_bolt11(amount_due, currency).await { match state
.api
.billing
.get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency)
.await
{
Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))), Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))),
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -911,10 +1102,16 @@ async fn get_invoice_bolt11(
} }
} }
#[derive(serde::Deserialize)]
struct StripeSessionParams {
return_url: Option<String>,
}
async fn create_stripe_session( async fn create_stripe_session(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey): Path<String>, Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> std::result::Result<Response, ApiError> { ) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
@@ -923,7 +1120,7 @@ async fn create_stripe_session(
match state match state
.api .api
.billing .billing
.stripe_create_portal_session(&tenant.stripe_customer_id) .stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await .await
{ {
Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))), Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))),
@@ -945,12 +1142,34 @@ async fn update_tenant(
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?; let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
let nwc_previously_empty = tenant.nwc_url.is_empty();
if let Some(nwc_url) = payload.nwc_url { if let Some(nwc_url) = payload.nwc_url {
tenant.nwc_url = nwc_url; if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url =
crate::cipher::encrypt(&nwc_url).map_err(|e| ApiError::Internal(e.to_string()))?;
}
} }
match state.api.command.update_tenant(&tenant).await { match state.api.command.update_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, tenant)), Ok(()) => {
// When NWC is first connected, attempt to pay any outstanding open invoices.
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
let billing = state.api.billing.clone();
let tenant_clone = tenant.clone();
tokio::spawn(async move {
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
tracing::error!(
error = %e,
pubkey = %tenant_clone.pubkey,
"pay_outstanding_nwc_invoices failed after NWC setup"
);
}
});
}
Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
}
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
+651 -549
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
//! Small wrappers around the Bitcoin-facing services this app talks to: Nostr
//! Wallet Connect wallets (for Lightning invoices/payments) and a fiat↔BTC spot
//! price feed, plus the fiat-minor-units → millisatoshi conversion that ties them
//! together. The billing-specific orchestration lives in [`crate::billing`].
use anyhow::{Result, anyhow};
use nwc::prelude::{
LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI,
PayInvoiceRequest, TransactionState,
};
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
/// Millisatoshis per bitcoin.
const MSATS_PER_BTC: f64 = 100_000_000_000.0;
/// Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the
/// wallet used to *receive* payments — issue and look up bolt11 invoices) and the
/// HTTP client used for the fiat↔BTC spot price feed.
#[derive(Clone)]
pub struct Bitcoin {
system_nwc_url: String,
http: reqwest::Client,
}
impl Bitcoin {
/// Reads `NWC_URL` (the system / receiving wallet). Unlike the Stripe keys this
/// is optional: if it's unset, Lightning operations fail at use time with a
/// clear error rather than at startup.
pub fn from_env() -> Self {
Self {
system_nwc_url: std::env::var("NWC_URL").unwrap_or_default(),
http: reqwest::Client::new(),
}
}
/// The system wallet — issues and looks up the bolt11 invoices we want paid to
/// us. Errors if `NWC_URL` is unset or malformed.
pub fn system_wallet(&self) -> Result<Wallet> {
Wallet::parse(&self.system_nwc_url, "system")
}
/// Fetches the live BTC spot price and converts a fiat amount in minor units
/// (cents, etc.) to millisatoshis.
pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
let currency = currency.to_uppercase();
let btc_price = btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, &currency).await?;
fiat_minor_to_msats_from_quote(amount_due_minor, &currency, btc_price)
}
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceResponse {
data: CoinbaseSpotPriceData,
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceData {
amount: String,
}
/// A handle to a single Nostr Wallet Connect wallet. Each operation opens a fresh
/// connection and tears it down afterwards.
pub struct Wallet {
uri: NostrWalletConnectURI,
}
impl Wallet {
/// Parses an `nostr+walletconnect://` URI. `label` only flavours the error
/// message so callers can tell which wallet was misconfigured.
pub fn parse(uri: &str, label: &str) -> Result<Self> {
let uri = uri
.parse::<NostrWalletConnectURI>()
.map_err(|_| anyhow!("invalid {label} NWC URL"))?;
Ok(Self { uri })
}
/// Issues a bolt11 invoice for `amount_msats`.
pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String> {
let nwc = NWC::new(self.uri.clone());
let result = nwc
.make_invoice(MakeInvoiceRequest {
amount: amount_msats,
description: Some(description.to_string()),
description_hash: None,
expiry: None,
})
.await;
nwc.shutdown().await;
Ok(result
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
.invoice)
}
/// Pays a bolt11 invoice.
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
let nwc = NWC::new(self.uri.clone());
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
nwc.shutdown().await;
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
}
/// Returns whether a bolt11 invoice (previously issued by this wallet) has been
/// settled.
pub async fn invoice_settled(&self, bolt11: &str) -> Result<bool> {
let nwc = NWC::new(self.uri.clone());
let result = nwc
.lookup_invoice(LookupInvoiceRequest {
payment_hash: None,
invoice: Some(bolt11.to_string()),
})
.await;
nwc.shutdown().await;
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
Ok(lookup_invoice_response_is_settled(&response))
}
}
fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool {
response.state == Some(TransactionState::Settled) || response.settled_at.is_some()
}
/// Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a
/// Coinbase-shaped API at `api_base`. Exposed so tests can stub the price feed;
/// production callers go through [`Bitcoin::fiat_minor_to_msats`].
pub async fn btc_spot_price_from_base(
http: &reqwest::Client,
api_base: &str,
currency: &str,
) -> Result<f64> {
let pair = format!("BTC-{currency}");
let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/'));
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
let amount = body
.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?;
if amount <= 0.0 {
return Err(anyhow!(
"invalid non-positive BTC spot quote for {currency}"
));
}
Ok(amount)
}
/// Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis,
/// given a BTC price quote in that currency. Rounds up so we never under-charge,
/// but snaps to the nearest integer when within a hair of one to avoid floating
/// point artifacts at integer boundaries.
pub fn fiat_minor_to_msats_from_quote(
amount_due_minor: i64,
currency: &str,
btc_price_in_fiat: f64,
) -> Result<u64> {
if amount_due_minor <= 0 {
return Err(anyhow!("amount_due must be positive"));
}
if btc_price_in_fiat <= 0.0 {
return Err(anyhow!("btc_price_in_fiat must be positive"));
}
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
let amount_fiat = (amount_due_minor as f64) / divisor;
let amount_btc = amount_fiat / btc_price_in_fiat;
let raw_msats = amount_btc * MSATS_PER_BTC;
let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 {
raw_msats.round()
} else {
raw_msats.ceil()
};
if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 {
return Err(anyhow!("calculated msat amount is out of bounds"));
}
Ok(amount_msats as u64)
}
/// Number of decimal places in `currency`'s minor unit, following Stripe's
/// currency conventions (most are 2, JPY/KRW/… are 0, BHD/KWD/… are 3).
fn currency_minor_exponent(currency: &str) -> Result<u8> {
let normalized = currency.to_uppercase();
let exponent = match normalized.as_str() {
// Zero-decimal currencies in Stripe.
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF" | "UGX"
| "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
// Three-decimal currencies in Stripe.
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
_ => return Err(anyhow!("invalid currency code: {currency}")),
};
Ok(exponent)
}
#[cfg(test)]
mod tests {
use super::fiat_minor_to_msats_from_quote;
#[test]
fn converts_usd_minor_units_with_quote() {
let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0)
.expect("conversion should succeed");
assert_eq!(msats, 1_000_000);
}
#[test]
fn converts_zero_decimal_currency_with_quote() {
let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0)
.expect("conversion should succeed");
assert_eq!(msats, 1_000_000);
}
#[test]
fn rejects_malformed_currency_code() {
// Not three ASCII letters: rejected outright.
assert!(fiat_minor_to_msats_from_quote(100, "usdd", 100_000.0).is_err());
assert!(fiat_minor_to_msats_from_quote(100, "us1", 100_000.0).is_err());
}
}
+28
View File
@@ -0,0 +1,28 @@
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
pub fn encrypt(plaintext: &str) -> Result<String> {
let keys = load_key()?;
nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(ciphertext: &str) -> Result<String> {
let keys = load_key()?;
nip44::decrypt(keys.secret_key(), &keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
fn load_key() -> Result<Keys> {
let secret = std::env::var("ENCRYPTION_SECRET")
.map_err(|_| anyhow!("missing ENCRYPTION_SECRET environment variable"))?;
if secret.trim().is_empty() {
return Err(anyhow!("ENCRYPTION_SECRET is empty"));
}
Keys::parse(&secret).map_err(|e| anyhow!("invalid ENCRYPTION_SECRET: {e}"))
}
+69 -5
View File
@@ -113,12 +113,12 @@ impl Command {
sqlx::query( sqlx::query(
"INSERT INTO relay ( "INSERT INTO relay (
id, tenant, schema, subdomain, plan, status, sync_error, id, tenant, schema, subdomain, plan, status, synced, sync_error,
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled, groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&relay.id) .bind(&relay.id)
.bind(&relay.tenant) .bind(&relay.tenant)
@@ -151,7 +151,7 @@ impl Command {
sqlx::query( sqlx::query(
"UPDATE relay "UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?, info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?, policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?, groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
@@ -203,7 +203,7 @@ impl Command {
) -> Result<()> { ) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = ? WHERE id = ?") sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status) .bind(status)
.bind(relay_id) .bind(relay_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -224,7 +224,7 @@ impl Command {
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> { pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET sync_error = ? WHERE id = ?") sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error) .bind(&sync_error)
.bind(&relay.id) .bind(&relay.id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -313,6 +313,70 @@ impl Command {
Ok(()) Ok(())
} }
pub async fn insert_pending_invoice_nwc_payment(
&self,
invoice_id: &str,
tenant_pubkey: &str,
) -> Result<bool> {
let now = chrono::Utc::now().timestamp();
let result = sqlx::query(
"INSERT INTO invoice_nwc_payment (invoice_id, tenant_pubkey, state, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
ON CONFLICT(invoice_id) DO NOTHING",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn mark_invoice_nwc_payment_paid(&self, invoice_id: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
let result = sqlx::query(
"UPDATE invoice_nwc_payment
SET state = 'paid', updated_at = ?
WHERE invoice_id = ?",
)
.bind(now)
.bind(invoice_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
anyhow::bail!("invoice_nwc_payment row missing for invoice_id: {invoice_id}");
}
Ok(())
}
pub async fn insert_manual_lightning_invoice_payment(
&self,
invoice_id: &str,
tenant_pubkey: &str,
bolt11: &str,
) -> Result<bool> {
let now = chrono::Utc::now().timestamp();
let result = sqlx::query(
"INSERT INTO invoice_manual_lightning_payment
(invoice_id, tenant_pubkey, bolt11, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(invoice_id) DO NOTHING",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.bind(bolt11)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> { pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?") sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
+290 -13
View File
@@ -1,10 +1,56 @@
use anyhow::Result; use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command; use crate::command::Command;
use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE}; use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query; use crate::query::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;
/// Blossom S3 settings from env; relay sync sets `key_prefix` to the relay schema.
#[derive(Clone)]
struct BlossomS3Sync {
endpoint: String,
region: String,
bucket: String,
access_key: String,
secret_key: String,
}
impl BlossomS3Sync {
fn from_env() -> Option<Self> {
let region = std::env::var("BLOSSOM_S3_REGION").unwrap_or_default();
let bucket = std::env::var("BLOSSOM_S3_BUCKET").unwrap_or_default();
let access_key = std::env::var("BLOSSOM_S3_ACCESS_KEY").unwrap_or_default();
let secret_key = std::env::var("BLOSSOM_S3_SECRET_KEY").unwrap_or_default();
let region = region.trim().to_string();
let bucket = bucket.trim().to_string();
let access_key = access_key.trim().to_string();
let secret_key = secret_key.trim().to_string();
if region.is_empty() || bucket.is_empty() || access_key.is_empty() || secret_key.is_empty() {
return None;
}
let endpoint = std::env::var("BLOSSOM_S3_ENDPOINT")
.unwrap_or_default()
.trim()
.to_string();
Some(Self {
endpoint,
region,
bucket,
access_key,
secret_key,
})
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct Infra { pub struct Infra {
api_url: String, api_url: String,
@@ -13,33 +59,48 @@ pub struct Infra {
livekit_api_key: String, livekit_api_key: String,
livekit_api_secret: String, livekit_api_secret: String,
api_secret: String, api_secret: String,
blossom_s3: Option<BlossomS3Sync>,
query: Query, query: Query,
command: Command, command: Command,
} }
impl Infra { impl Infra {
pub fn new(query: Query, command: Command) -> Self { pub fn new(query: Query, command: Command) -> Result<Self> {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default(); let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default(); let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default(); let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default(); let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default(); let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default(); let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
Self { let blossom_s3 = BlossomS3Sync::from_env();
if api_url.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_URL");
}
if api_secret.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_SECRET");
}
Ok(Self {
api_url, api_url,
relay_domain, relay_domain,
livekit_url, livekit_url,
livekit_api_key, livekit_api_key,
livekit_api_secret, livekit_api_secret,
api_secret, api_secret,
blossom_s3,
query, query,
command, command,
} })
} }
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(activity) => { Ok(activity) => {
@@ -49,6 +110,10 @@ impl Infra {
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged"); tracing::warn!(missed = n, "infra lagged");
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, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
} }
@@ -58,18 +123,107 @@ impl Infra {
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str()); let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
if needs_sync { if !needs_sync || activity.resource_type != "relay" {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else { return Ok(());
return Ok(()); }
};
let is_new = relay.synced == 0; if activity.activity_type == "fail_relay_sync" {
self.sync_and_report(&relay, is_new).await; 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(());
};
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).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() {
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).await;
} else {
self.schedule_relay_sync_retry(&relay.id, source).await?;
}
} }
Ok(()) Ok(())
} }
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
let activities = self.query.list_activity_for_relay(relay_id).await?;
let consecutive_failures = consecutive_sync_failures(&activities);
let Some(delay) = relay_sync_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!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let relay_id = relay_id.to_string();
let infra = self.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
});
Ok(())
}
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
let Some(relay) = self.query.get_relay(relay_id).await? else {
return Ok(());
};
if relay.sync_error.trim().is_empty() {
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
return Ok(());
}
let is_new = self.relay_sync_is_new(&relay).await?;
self.sync_and_report(&relay, is_new).await;
Ok(())
}
async fn relay_sync_is_new(&self, relay: &Relay) -> Result<bool> {
if relay.synced == 1 {
return Ok(false);
}
let has_completed_sync = self.query.relay_has_completed_sync(&relay.id).await?;
Ok(!has_completed_sync)
}
async fn sync_and_report(&self, relay: &Relay, is_new: bool) { async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await { match self.sync_relay(relay, is_new).await {
Ok(()) => { Ok(()) => {
@@ -96,6 +250,28 @@ impl Infra {
Ok(auth) Ok(auth)
} }
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let url = format!("{base}/relay/{relay_id}/members");
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
let response = client
.get(&url)
.header("Authorization", auth)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid members returned {status}: {body}");
}
let body = response.text().await?;
parse_relay_members_response(&body)
}
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> { async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/'); let base = self.api_url.trim_end_matches('/');
@@ -122,10 +298,13 @@ impl Infra {
host, host,
livekit, livekit,
is_new.then(|| Keys::generate().secret_key().to_secret_hex()), is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
self.blossom_s3.as_ref(),
); );
let url = format!("{}/relay/{}", base, relay.id); let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?; let auth = self
.nip98_auth(&url, zooid_sync_http_method(is_new))
.await?;
let request = if is_new { let request = if is_new {
client.post(&url) client.post(&url)
@@ -156,12 +335,43 @@ fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
} }
} }
fn parse_relay_members_response(body: &str) -> Result<Vec<String>> {
let value: serde_json::Value = serde_json::from_str(body)?;
if let Some(members) = members_from_value(&value) {
return Ok(members);
}
if let Some(members) = value.get("members").and_then(members_from_value) {
return Ok(members);
}
if let Some(members) = value
.get("data")
.and_then(|data| data.get("members"))
.and_then(members_from_value)
{
return Ok(members);
}
anyhow::bail!("zooid members response missing members array")
}
fn members_from_value(value: &serde_json::Value) -> Option<Vec<String>> {
let values = value.as_array()?;
values
.iter()
.map(|value| value.as_str().map(ToString::to_string))
.collect()
}
fn relay_sync_body( fn relay_sync_body(
relay: &Relay, relay: &Relay,
host: String, host: String,
livekit: serde_json::Value, livekit: serde_json::Value,
secret: Option<String>, secret: Option<String>,
blossom_s3: Option<&BlossomS3Sync>,
) -> serde_json::Value { ) -> serde_json::Value {
let blossom = blossom_sync_json(relay, blossom_s3);
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"host": host, "host": host,
"schema": relay.schema, "schema": relay.schema,
@@ -179,7 +389,7 @@ fn relay_sync_body(
}, },
"groups": { "enabled": relay.groups_enabled == 1 }, "groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 }, "management": { "enabled": relay.management_enabled == 1 },
"blossom": { "enabled": relay.blossom_enabled == 1 }, "blossom": blossom,
"livekit": livekit, "livekit": livekit,
"push": { "enabled": relay.push_enabled == 1 }, "push": { "enabled": relay.push_enabled == 1 },
"roles": { "roles": {
@@ -195,9 +405,76 @@ fn relay_sync_body(
body body
} }
fn blossom_sync_json(relay: &Relay, blossom_s3: Option<&BlossomS3Sync>) -> serde_json::Value {
let enabled = relay.blossom_enabled == 1;
if !enabled {
return serde_json::json!({ "enabled": false });
}
let Some(s3) = blossom_s3 else {
return serde_json::json!({ "enabled": true });
};
let mut s3_obj = serde_json::Map::new();
if !s3.endpoint.trim().is_empty() {
s3_obj.insert(
"endpoint".to_string(),
serde_json::Value::String(s3.endpoint.clone()),
);
}
s3_obj.insert(
"region".to_string(),
serde_json::Value::String(s3.region.clone()),
);
s3_obj.insert(
"bucket".to_string(),
serde_json::Value::String(s3.bucket.clone()),
);
s3_obj.insert(
"access_key".to_string(),
serde_json::Value::String(s3.access_key.clone()),
);
s3_obj.insert(
"secret_key".to_string(),
serde_json::Value::String(s3.secret_key.clone()),
);
s3_obj.insert(
"key_prefix".to_string(),
serde_json::Value::String(relay.schema.clone()),
);
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": serde_json::Value::Object(s3_obj),
})
}
fn should_sync_relay_activity(activity_type: &str) -> bool { fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!( matches!(
activity_type, activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" "create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
) )
} }
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count()
}
fn relay_sync_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))
}
+3
View File
@@ -1,8 +1,11 @@
pub mod api; pub mod api;
pub mod billing; pub mod billing;
pub mod bitcoin;
pub mod cipher;
pub mod command; pub mod command;
pub mod infra; pub mod infra;
pub mod models; pub mod models;
pub mod pool; pub mod pool;
pub mod query; pub mod query;
pub mod robot; pub mod robot;
pub mod stripe;
+5 -2
View File
@@ -1,11 +1,14 @@
mod api; mod api;
mod billing; mod billing;
mod bitcoin;
mod cipher;
mod command; mod command;
mod infra; mod infra;
mod models; mod models;
mod pool; mod pool;
mod query; mod query;
mod robot; mod robot;
mod stripe;
use anyhow::Result; use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -33,8 +36,8 @@ async fn main() -> Result<()> {
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
let command = Command::new(pool); let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone()); let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone()); let infra = Infra::new(query.clone(), command.clone())?;
let api = Api::new(query, command, billing.clone()); let api = Api::new(query, command, billing.clone(), infra.clone());
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT") let port: u16 = std::env::var("PORT")
+51 -6
View File
@@ -94,6 +94,23 @@ impl Query {
Ok(rows) Ok(rows)
} }
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE synced = 0 OR TRIM(sync_error) != ''
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> { pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>( let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
@@ -144,15 +161,27 @@ impl Query {
Ok(row) Ok(row)
} }
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> { pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> {
let plans = sqlx::query_scalar::<_, String>( let state = sqlx::query_scalar::<_, String>(
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'", "SELECT state FROM invoice_nwc_payment WHERE invoice_id = ?",
) )
.bind(tenant_id) .bind(invoice_id)
.fetch_all(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(state)
}
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan))) pub async fn get_invoice_manual_lightning_bolt11(
&self,
invoice_id: &str,
) -> Result<Option<String>> {
let bolt11 = sqlx::query_scalar::<_, String>(
"SELECT bolt11 FROM invoice_manual_lightning_payment WHERE invoice_id = ?",
)
.bind(invoice_id)
.fetch_optional(&self.pool)
.await?;
Ok(bolt11)
} }
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> { pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
@@ -167,4 +196,20 @@ impl Query {
.await?; .await?;
Ok(rows) Ok(rows)
} }
pub async fn relay_has_completed_sync(&self, relay_id: &str) -> Result<bool> {
let found = sqlx::query_scalar::<_, i64>(
"SELECT 1
FROM activity
WHERE resource_type = 'relay'
AND resource_id = ?
AND activity_type = 'complete_relay_sync'
LIMIT 1",
)
.bind(relay_id)
.fetch_optional(&self.pool)
.await?;
Ok(found.is_some())
}
} }
+19
View File
@@ -160,6 +160,25 @@ impl Robot {
Ok(relays) Ok(relays)
} }
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 events = self
.indexer_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
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
Some(name)
}
async fn fetch_messaging_relays_from_outbox( async fn fetch_messaging_relays_from_outbox(
&self, &self,
recipient: &str, recipient: &str,
+482
View File
@@ -0,0 +1,482 @@
//! A thin async wrapper around the subset of the Stripe REST API this service uses.
//!
//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP
//! to Stripe and hands back `serde_json::Value` (or small typed results). The
//! domain logic lives in [`crate::billing`].
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
type HmacSha256 = Hmac<Sha256>;
const STRIPE_API: &str = "https://api.stripe.com/v1";
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
/// Error returned by invoice lookups, distinguishing a Stripe 4xx (e.g. "no such
/// invoice") — which callers usually want to surface as a client error — from an
/// internal failure.
#[derive(Debug)]
pub enum InvoiceLookupError {
StripeClient { status: reqwest::StatusCode },
Internal(anyhow::Error),
}
impl std::fmt::Display for InvoiceLookupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StripeClient { status } => {
write!(
f,
"stripe invoice lookup failed with status {}",
status.as_u16()
)
}
Self::Internal(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for InvoiceLookupError {}
impl From<anyhow::Error> for InvoiceLookupError {
fn from(value: anyhow::Error) -> Self {
Self::Internal(value)
}
}
impl From<reqwest::Error> for InvoiceLookupError {
fn from(value: reqwest::Error) -> Self {
Self::Internal(value.into())
}
}
/// A Stripe webhook event with its signature already verified.
#[derive(serde::Deserialize)]
pub struct Event {
#[serde(rename = "type")]
pub event_type: String,
pub data: EventData,
}
#[derive(serde::Deserialize)]
pub struct EventData {
pub object: serde_json::Value,
}
#[derive(Clone)]
pub struct Stripe {
pub(crate) secret_key: String,
pub(crate) webhook_secret: String,
http: reqwest::Client,
}
impl Stripe {
/// Builds the client from the environment: `STRIPE_SECRET_KEY` and
/// `STRIPE_WEBHOOK_SECRET`, both required. Panics if either is missing or blank.
pub fn from_env() -> Self {
let secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default();
if secret_key.trim().is_empty() {
panic!("missing STRIPE_SECRET_KEY environment variable");
}
let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
if webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
}
Self::new(secret_key, webhook_secret)
}
pub fn new(secret_key: String, webhook_secret: String) -> Self {
Self {
secret_key,
webhook_secret,
http: reqwest::Client::new(),
}
}
// --- Customers ---
/// Creates a customer with the given display name, tagging it with the tenant
/// pubkey in metadata. Idempotent on the tenant pubkey.
pub async fn create_customer(&self, name: &str, tenant_pubkey: &str) -> Result<String> {
let resp = self
.http
.post(format!("{STRIPE_API}/customers"))
.bearer_auth(&self.secret_key)
.header(
"Idempotency-Key",
self.idempotency_key(&["create_customer", tenant_pubkey]),
)
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
.send()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
if !customer_id.starts_with("cus_") {
return Err(anyhow!("unexpected customer id format"));
}
Ok(customer_id.to_string())
}
// --- Subscriptions ---
/// Fetches a subscription, returning `None` if Stripe no longer knows about it
/// (so callers can recover from a stale subscription id).
pub async fn get_subscription(
&self,
subscription_id: &str,
) -> Result<Option<serde_json::Value>> {
let resp = self
.http
.get(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
.bearer_auth(&self.secret_key)
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
Ok(Some(body))
}
/// Creates a subscription with one item per `(price_id, quantity)` entry, billed
/// automatically. Returns the subscription id and a map from price id to the
/// created subscription item id. Idempotent on the customer and the item set.
pub async fn create_subscription(
&self,
customer_id: &str,
items: &BTreeMap<String, i64>,
) -> Result<(String, BTreeMap<String, String>)> {
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();
let resp = self
.http
.post(format!("{STRIPE_API}/subscriptions"))
.bearer_auth(&self.secret_key)
.header("Idempotency-Key", self.idempotency_key(&key_refs))
.form(&form)
.send()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
let subscription_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing subscription id"))?
.to_string();
let mut price_to_item = BTreeMap::new();
for item in body["items"]["data"]
.as_array()
.ok_or_else(|| anyhow!("missing subscription items"))?
{
let item_id = item["id"]
.as_str()
.ok_or_else(|| anyhow!("missing subscription item id"))?;
let price_id = item["price"]["id"]
.as_str()
.ok_or_else(|| anyhow!("missing subscription item price id"))?;
price_to_item.insert(price_id.to_string(), item_id.to_string());
}
Ok((subscription_id, price_to_item))
}
pub async fn create_subscription_item(
&self,
subscription_id: &str,
price_id: &str,
quantity: i64,
) -> Result<String> {
let quantity = quantity.to_string();
let resp = self
.http
.post(format!("{STRIPE_API}/subscription_items"))
.bearer_auth(&self.secret_key)
.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()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
body["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing subscription item id"))
}
/// Sets a subscription item's quantity. No idempotency key: this is a
/// reconcile-to-desired-state write, and re-applying the same target is a no-op.
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
let resp = self
.http
.post(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.secret_key)
.form(&[("quantity", quantity.to_string())])
.send()
.await?;
error_for_status(resp).await?;
Ok(())
}
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
let resp = self
.http
.delete(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.secret_key)
.send()
.await?;
error_for_status(resp).await?;
Ok(())
}
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
let resp = self
.http
.delete(format!("{STRIPE_API}/subscriptions/{subscription_id}"))
.bearer_auth(&self.secret_key)
.send()
.await?;
error_for_status(resp).await?;
Ok(())
}
// --- Invoices ---
/// Returns the `data` array of the customer's invoices.
pub async fn list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
let resp = self
.http
.get(format!("{STRIPE_API}/invoices"))
.bearer_auth(&self.secret_key)
.query(&[("customer", customer_id)])
.send()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
Ok(body["data"].clone())
}
pub async fn get_invoice(
&self,
invoice_id: &str,
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
let resp = self
.http
.get(format!("{STRIPE_API}/invoices/{invoice_id}"))
.bearer_auth(&self.secret_key)
.send()
.await?;
if resp.status().is_client_error() {
return Err(InvoiceLookupError::StripeClient {
status: resp.status(),
});
}
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
Ok(body)
}
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
let resp = self
.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.secret_key)
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice", invoice_id]),
)
.send()
.await?;
error_for_status(resp).await?;
Ok(())
}
/// Marks an invoice paid out of band — used when we've collected payment over
/// Lightning rather than through Stripe.
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
let resp = self
.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.secret_key)
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
)
.form(&[("paid_out_of_band", "true")])
.send()
.await?;
error_for_status(resp).await?;
Ok(())
}
pub async fn preview_upcoming_invoice(
&self,
customer_id: &str,
subscription_id: Option<&str>,
) -> Result<serde_json::Value> {
let mut req = self
.http
.get(format!("{STRIPE_API}/invoices/upcoming"))
.bearer_auth(&self.secret_key)
.query(&[("customer", customer_id)]);
if let Some(subscription_id) = subscription_id {
req = req.query(&[("subscription", subscription_id)]);
}
let body: serde_json::Value = error_for_status(req.send().await?).await?.json().await?;
Ok(body)
}
// --- Payment methods ---
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
let resp = self
.http
.get(format!("{STRIPE_API}/payment_methods"))
.bearer_auth(&self.secret_key)
.query(&[("customer", customer_id), ("type", "card")])
.send()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
}
// --- Billing portal ---
pub async fn create_portal_session(
&self,
customer_id: &str,
return_url: Option<&str>,
) -> Result<String> {
let mut params = vec![("customer", customer_id.to_string())];
if let Some(url) = return_url {
params.push(("return_url", url.to_string()));
}
let resp = self
.http
.post(format!("{STRIPE_API}/billing_portal/sessions"))
.bearer_auth(&self.secret_key)
.form(&params)
.send()
.await?;
let body: serde_json::Value = error_for_status(resp).await?.json().await?;
body["url"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
// --- Webhooks ---
/// Verifies the `Stripe-Signature` header against the configured webhook secret
/// (including the timestamp tolerance check) and parses the event body.
pub fn construct_event(&self, payload: &str, sig_header: &str) -> Result<Event> {
self.verify_webhook_signature(payload, sig_header)?;
Ok(serde_json::from_str(payload)?)
}
fn verify_webhook_signature(&self, payload: &str, sig_header: &str) -> Result<()> {
let mut timestamp = None;
let mut signature = None;
for part in sig_header.split(',') {
if let Some(t) = part.strip_prefix("t=") {
timestamp = Some(t);
} else if let Some(v) = part.strip_prefix("v1=") {
signature = Some(v);
}
}
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
let signature = signature.ok_or_else(|| anyhow!("missing webhook signature"))?;
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = HmacSha256::new_from_slice(self.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(())
}
// --- Internals ---
/// Derives a stable idempotency key by HMAC-ing `parts` with the secret key.
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = HmacSha256::new_from_slice(self.secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
mac.update(b":");
}
mac.update(part.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
}
/// Like [`reqwest::Response::error_for_status`], but on a 4xx/5xx response it reads
/// the body and folds Stripe's JSON error payload (`error.message`/`code`/`param`)
/// into the returned error, so callers get an actionable message instead of a bare
/// "400 Bad Request" with only the URL.
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if !status.is_client_error() && !status.is_server_error() {
return Ok(resp);
}
let url = resp.url().clone();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|json| {
let error = &json["error"];
let message = error["message"].as_str()?.to_string();
let mut detail = message;
if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) {
detail.push_str(&format!(" [{code}]"));
}
if let Some(param) = error["param"].as_str() {
detail.push_str(&format!(" (param: {param})"));
}
Some(detail)
})
.unwrap_or_else(|| {
if body.trim().is_empty() {
"<empty response body>".to_string()
} else {
body
}
});
Err(anyhow!(
"Stripe API request to {url} failed with status {status}: {detail}"
))
}
-31
View File
@@ -1,31 +0,0 @@
use axum::{Json, Router, routing::get};
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
#[tokio::test]
async fn quote_endpoint_can_be_stubbed_deterministically() {
async fn spot() -> Json<serde_json::Value> {
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
}
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("get local addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve quote stub");
});
let client = reqwest::Client::new();
let base = format!("http://{addr}/v2/prices");
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
.await
.expect("fetch stubbed quote");
assert_eq!(btc_price, 50_000.0);
let msats =
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+151
View File
@@ -0,0 +1,151 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close"
>
<svg class="w-5 h-5" 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>
<div class="px-6 py-4">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
+25 -14
View File
@@ -3,7 +3,6 @@ import QRCode from "qrcode"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api" import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error" type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -26,8 +25,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Error, setBolt11Error] = createSignal("") const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle") const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("") const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
async function loadBolt11() { async function loadBolt11() {
if (!props.invoice.id) return if (!props.invoice.id) return
@@ -63,7 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id) const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") { if (invoice.status === "paid") {
setPayStatus("success") setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else { } else {
setPayStatus("error") setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.") setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -81,7 +79,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11Error("") setBolt11Error("")
setBolt11("") setBolt11("")
setQrDataUrl("") setQrDataUrl("")
setShowSetup(false)
props.onClose() props.onClose()
} }
@@ -160,6 +157,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button> </button>
</div> </div>
</Show> </Show>
<div class="text-center pt-1">
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-700"
>
Set up payment method instead
</button>
</div>
</Show> </Show>
</div> </div>
} }
@@ -172,15 +178,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div> </div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p> <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> <p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<Show when={showSetup()}> <button
<button type="button"
type="button" onClick={() => setShowPaymentSetup(true)}
onClick={() => setShowPaymentSetup(true)} class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700" >
> Set up automatic payments
Set up automatic payments </button>
</button>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@@ -228,7 +232,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal> </Modal>
<PaymentSetup <PaymentSetup
open={showPaymentSetup()} open={showPaymentSetup()}
onClose={() => setShowPaymentSetup(false)} onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/> />
</> </>
) )
+5 -3
View File
@@ -9,6 +9,7 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = { type PaymentSetupProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSaved?: () => void
} }
export default function PaymentSetup(props: PaymentSetupProps) { export default function PaymentSetup(props: PaymentSetupProps) {
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try { try {
await updateActiveTenant({ nwc_url: url }) await updateActiveTenant({ nwc_url: url })
setSaved(true) setSaved(true)
props.onSaved?.()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection") setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally { } finally {
@@ -38,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
setRedirecting(true) setRedirecting(true)
setError("") setError("")
try { try {
const { url } = await createPortalSession(account()!.pubkey) const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") setError(e instanceof Error ? e.message : "Failed to open billing portal")
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2> <h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p> <p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
</div> </div>
<button <button
type="button" type="button"
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
</div> </div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p> <p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p>
<button <button
type="button" type="button"
onClick={openPortal} onClick={openPortal}
+69 -8
View File
@@ -2,6 +2,7 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js" import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api" import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg" import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field" import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable" import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton" import ToggleButton from "@/components/ToggleButton"
@@ -51,8 +52,8 @@ type RelayDetailCardProps = {
currentMembers?: number currentMembers?: number
showTenant?: boolean showTenant?: boolean
editHref?: string editHref?: string
onDeactivate?: () => void onDeactivate?: () => void | Promise<void>
onReactivate?: () => void onReactivate?: () => void | Promise<void>
deactivating?: boolean deactivating?: boolean
reactivating?: boolean reactivating?: boolean
onTogglePublicJoin?: () => void onTogglePublicJoin?: () => void
@@ -76,6 +77,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const [menuOpen, setMenuOpen] = createSignal(false) const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan) const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined let menuContainerRef: HTMLDivElement | undefined
@@ -86,6 +88,24 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free" const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) { async function changePlan(plan: PlanId) {
setPlan(plan) setPlan(plan)
@@ -97,6 +117,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
} }
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
createEffect(() => { createEffect(() => {
if (!menuOpen()) return if (!menuOpen()) return
@@ -128,7 +171,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0"> <div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}> <Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" /> <img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show> </Show>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
@@ -148,7 +191,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</div> </div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}> <Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative flex-shrink-0" ref={menuContainerRef}> <div class="relative shrink-0" ref={menuContainerRef}>
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50" class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
@@ -177,8 +220,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => { onClick={() => {
setMenuOpen(false) openActionDialog("deactivate")
props.onDeactivate?.()
}} }}
disabled={props.deactivating} disabled={props.deactivating}
> >
@@ -190,8 +232,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => { onClick={() => {
setMenuOpen(false) openActionDialog("reactivate")
props.onReactivate?.()
}} }}
disabled={props.reactivating} disabled={props.reactivating}
> >
@@ -203,6 +244,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</div> </div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<hr class="border-gray-200" /> <hr class="border-gray-200" />
<DetailSection title="Policy"> <DetailSection title="Policy">
@@ -339,6 +387,19 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</DetailSection> </DetailSection>
</Show> </Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div> </div>
) )
} }
+7
View File
@@ -1,6 +1,7 @@
import { createEffect, createMemo, createSignal, For } from "solid-js" import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks" import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify" import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state" import { plans } from "@/lib/state"
@@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) {
return return
} }
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("") setToastMessage("")
setSubmitting(true) setSubmitting(true)
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api" import type { Relay } from "@/lib/api"
type RelayListItemProps = { type RelayListItemProps = {
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
<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}</p>
)} )}
</div> </div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p> <Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
</span>
</Show>
</div> </div>
</A> </A>
</li> </li>
+7 -2
View File
@@ -241,6 +241,10 @@ export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`) return callApi<undefined, Relay>("GET", `/relays/${id}`)
} }
export function listRelayMembers(id: string) {
return callApi<undefined, { members: string[] }>("GET", `/relays/${id}/members`)
}
export function listRelayActivity(id: string) { export function listRelayActivity(id: string) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`) return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
} }
@@ -249,8 +253,9 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`) return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
} }
export function createPortalSession(pubkey: string) { export function createPortalSession(pubkey: string, returnUrl?: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`) const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
} }
export function getInvoiceBolt11(invoiceId: string) { export function getInvoiceBolt11(invoiceId: string) {
+9 -10
View File
@@ -2,7 +2,6 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile" import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection" import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable" import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs" import { map, of } from "rxjs"
import { import {
createRelay, createRelay,
@@ -12,12 +11,14 @@ import {
getTenant, getTenant,
listRelayActivity, listRelayActivity,
listRelays, listRelays,
listTenantInvoices,
listTenantRelays, listTenantRelays,
listTenants, listTenants,
updateRelay, updateRelay,
updateTenant, updateTenant,
type Activity, type Activity,
type CreateRelayInput, type CreateRelayInput,
type Invoice,
type Relay, type Relay,
type Tenant, type Tenant,
type UpdateRelayInput, type UpdateRelayInput,
@@ -137,14 +138,12 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id return !tenant.nwc_url && !tenant.stripe_subscription_id
} }
export async function getRelayMembers(url: string) { export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const management = new RelayManagement(new NostrRelay(url), account()!.signer) const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
try { .filter(inv => inv.status === "open" && inv.amount_due > 0)
return await management.listAllowedPubkeys() .sort((a, b) => b.period_start - a.period_start)
} catch { return open[0] ?? null
return []
}
} }
export type { Activity, Relay, Tenant } export type { Activity, Invoice, Relay, Tenant }
+22
View File
@@ -0,0 +1,22 @@
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
export function validateSubdomainLabel(subdomain: string): string | null {
if (subdomain.length === 0) {
return "subdomain is required"
}
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
return "subdomain must be 63 characters or fewer"
}
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
return "subdomain cannot start or end with a hyphen"
}
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
return "subdomain is reserved"
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return "subdomain may only contain lowercase letters, numbers, and hyphens"
}
return null
}
+11 -6
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks" import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import type { PlanId } from "@/lib/api" import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean { function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false if (value === 0) return false
@@ -30,7 +30,8 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions, { refetch, mutate }: RelayActions,
) { ) {
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false) const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) { async function updateRelay(next: Relay, previous: Relay) {
mutate(next) mutate(next)
@@ -101,8 +102,12 @@ export default function useRelayToggles(
} }
if (plan !== "free") { if (plan !== "free") {
const needs = await tenantNeedsPaymentSetup() const needsSetup = await tenantNeedsPaymentSetup()
if (needs) setNeedsPaymentSetup(true) if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
setPendingPaymentSetup(true)
}
} }
} }
@@ -116,5 +121,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"), onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
} }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles } return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Account() {
async function openPortal() { async function openPortal() {
setPortalLoading(true) setPortalLoading(true)
try { try {
const { url } = await createPortalSession(account()!.pubkey) const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") setError(e instanceof Error ? e.message : "Failed to open billing portal")
+12 -1
View File
@@ -1,11 +1,12 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Show } from "solid-js" import { createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks" import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
@@ -13,6 +14,15 @@ export default function AdminRelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) const [relay, { refetch, mutate }] = useRelay(relayId)
const [members] = createResource(relayId, async (id) => {
if (!id) return []
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
})
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
@@ -26,6 +36,7 @@ export default function AdminRelayDetail() {
<div class="space-y-6 mb-6"> <div class="space-y-6 mb-6">
<RelayDetailCard <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members()?.length}
showTenant showTenant
editHref={`/admin/relays/${params.id}/edit`} editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
+115 -10
View File
@@ -1,27 +1,66 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js" import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks" import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() { export default function RelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) const [relay, { refetch, mutate }] = useRelay(relayId)
const relayUrl = createMemo(() => { const [members] = createResource(relayId, async (id) => {
const subdomain = relay()?.subdomain if (!id) return []
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
}) })
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) 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()) {
setPaymentSetupOpen(true)
clearPendingPaymentSetup()
}
})
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_url
})
return ( return (
<PageContainer> <PageContainer>
@@ -30,9 +69,47 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}> <Show when={!loading() && relay()}>
{(r) => ( {(r) => (
<div class="space-y-6 mb-6"> <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 <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members.length} currentMembers={members()?.length}
editHref={`/relays/${params.id}/edit`} editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
onReactivate={handleReactivate} onReactivate={handleReactivate}
@@ -45,9 +122,37 @@ export default function RelayDetail() {
</div> </div>
)} )}
</Show> </Show>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
}}
/>
)}
</Show>
<PaymentSetup <PaymentSetup
open={needsPaymentSetup()} open={paymentSetupOpen()}
onClose={clearNeedsPaymentSetup} onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
}}
/> />
</PageContainer> </PageContainer>
) )
+1 -2
View File
@@ -1,7 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js" import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
@@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), { await updateRelayById(relayId(), {
subdomain: slugify(values.subdomain), subdomain: values.subdomain,
info_name: values.info_name.trim(), info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(), info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(), info_description: values.info_description.trim(),
+32 -10
View File
@@ -1,14 +1,17 @@
import { createSignal } from "solid-js" import { createSignal, Show } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks" import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() { export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
@@ -16,9 +19,14 @@ export default function RelayNew() {
createdRelayId = relay.id createdRelayId = relay.id
if (values.plan !== "free") { if (values.plan !== "free") {
const needs = await tenantNeedsPaymentSetup() const needsSetup = await tenantNeedsPaymentSetup()
if (needs) { if (needsSetup) {
setShowPaymentSetup(true) const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return return
} }
} }
@@ -26,8 +34,13 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`) navigate(`/relays/${relay.id}`)
} }
function handleDialogClose() { function handleInvoiceClose() {
setShowPaymentSetup(false) setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`) navigate(`/relays/${createdRelayId}`)
} }
@@ -41,9 +54,18 @@ export default function RelayNew() {
submitLabel="Create Relay" submitLabel="Create Relay"
submittingLabel="Creating..." submittingLabel="Creating..."
/> />
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup <PaymentSetup
open={showPaymentSetup()} open={paymentSetupOpen()}
onClose={handleDialogClose} onClose={handleSetupClose}
/> />
</PageContainer> </PageContainer>
) )
+4 -1
View File
@@ -5,6 +5,9 @@ dev:
cd frontend && bun dev & cd frontend && bun dev &
wait wait
dev-backend:
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
dev-frontend: dev-frontend:
cd frontend && bun run dev cd frontend && bun run dev
@@ -27,7 +30,7 @@ build-backend:
cd backend && cargo build cd backend && cargo build
build-frontend: build-frontend:
cd frontend && bun run build cd frontend && bun i && bun run build
fmt: fmt-backend fmt: fmt-backend