forked from coracle/caravel
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd0678cfec | |||
| c0aff5f7cf | |||
| c9c1dd2c4c | |||
| 679a56edc3 | |||
| e7efd9d08b | |||
| 0151762362 | |||
| a79c43e17e | |||
| dbe25c372f | |||
| 80a86452d0 | |||
| b1e3747ddb | |||
| 29f657635c | |||
| 9556a34b19 | |||
| 3ecd285290 | |||
| 9f8fe7261f | |||
| 1aeb15971d | |||
| 48f20dc1a5 | |||
| c261d8a146 | |||
| 21b36272b8 | |||
| a26bc1127d | |||
| bc79da34cf | |||
| 38e3a64312 | |||
| d209353abd | |||
| 08c9a2920b |
@@ -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 }}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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<()>`
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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, ¤cy).await?;
|
||||||
|
fiat_minor_to_msats_from_quote(amount_due_minor, ¤cy, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(¶ms)
|
||||||
|
.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}"
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user