Compare commits
4 Commits
9d9192f681
...
cd70ca6654
| Author | SHA1 | Date | |
|---|---|---|---|
| cd70ca6654 | |||
| f37bb55286 | |||
| 7a2baf6f82 | |||
| 28cd7b0a9a |
@@ -34,6 +34,3 @@ BLOSSOM_S3_SECRET_KEY=
|
||||
|
||||
# Billing
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000
|
||||
STRIPE_PRICE_BASIC=
|
||||
STRIPE_PRICE_GROWTH=
|
||||
|
||||
@@ -4,7 +4,9 @@ CREATE TABLE IF NOT EXISTS activity (
|
||||
created_at INTEGER NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL
|
||||
resource_id TEXT NOT NULL,
|
||||
billed_at INTEGER,
|
||||
plan_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant (
|
||||
@@ -12,9 +14,9 @@ CREATE TABLE IF NOT EXISTS tenant (
|
||||
nwc_url TEXT NOT NULL DEFAULT '',
|
||||
nwc_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
billing_anchor INTEGER,
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
stripe_subscription_id TEXT,
|
||||
past_due_at INTEGER
|
||||
renewed_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay (
|
||||
@@ -38,29 +40,64 @@ CREATE TABLE IF NOT EXISTS relay (
|
||||
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lightning_invoice (
|
||||
stripe_invoice_id TEXT PRIMARY KEY,
|
||||
CREATE TABLE IF NOT EXISTS invoice (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
bolt11 TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
|
||||
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
|
||||
expires_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
|
||||
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
|
||||
ON tenant (stripe_customer_id);
|
||||
CREATE TABLE IF NOT EXISTS invoice_item (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT,
|
||||
activity_id TEXT,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
relay_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
|
||||
ON relay (tenant, id);
|
||||
CREATE TABLE IF NOT EXISTS bolt11 (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
lnbc TEXT NOT NULL,
|
||||
msats INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
settled_at INTEGER,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
|
||||
ON relay (tenant, status, plan);
|
||||
CREATE TABLE IF NOT EXISTS intent (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
||||
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant ON relay (tenant);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
|
||||
ON lightning_invoice (tenant_pubkey);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant, created_at) WHERE billed_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# `pub struct Api`
|
||||
|
||||
Api owns the HTTP interface: the shared application state, the router, NIP-98 authentication, and the authorization helpers. The route handlers themselves live in `crate::routes` (`routes/identity.rs`, `plans.rs`, `tenants.rs`, `relays.rs`, `invoices.rs`, `stripe.rs`) and use the response helpers in `spec/web.md`.
|
||||
|
||||
Members:
|
||||
|
||||
- `env: Env` - configuration (see `spec/env.md`); supplies the NIP-98 host check, admin pubkeys, encryption, etc.
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `billing: Billing`
|
||||
- `stripe: Stripe`
|
||||
- `robot: Robot`
|
||||
- `infra: Infra`
|
||||
|
||||
Notes:
|
||||
|
||||
- Authentication is done using NIP-98, comparing the event's `u` tag to `env.server_host`, not the incoming request URL.
|
||||
- The shared `Api` is wrapped in an `Arc` and handed to every handler as `State<Arc<Api>>`.
|
||||
- A handler that requires an authenticated caller takes an `AuthedPubkey` extractor; handlers that omit it are anonymous.
|
||||
- Each handler is responsible for authorization using `require_admin` or `require_admin_or_tenant`.
|
||||
- Successful responses are `{ data, code: "ok" }`; error responses are `{ error, code }`, both with an appropriate HTTP status (see `spec/web.md`).
|
||||
|
||||
## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self`
|
||||
|
||||
- Stores the services and a clone of `env`
|
||||
|
||||
## `pub fn router(self) -> Router`
|
||||
|
||||
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes
|
||||
|
||||
## `pub fn is_admin(&self, pubkey: &str) -> bool`
|
||||
|
||||
- Whether `pubkey` is in `env.server_admin_pubkeys`
|
||||
|
||||
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
|
||||
|
||||
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
|
||||
|
||||
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
|
||||
|
||||
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
|
||||
|
||||
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
|
||||
|
||||
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
|
||||
|
||||
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
|
||||
|
||||
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
|
||||
|
||||
# Authentication
|
||||
|
||||
## `pub struct AuthedPubkey(pub String)`
|
||||
|
||||
An axum extractor (`FromRequestParts`) that authenticates a request via NIP-98 and yields the signer's pubkey. Adding it to a handler signature is what enforces "must be authenticated"; on failure the request is rejected with a `401`.
|
||||
|
||||
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
|
||||
|
||||
- Parses the `Authorization` header, which must use the `Nostr ` scheme followed by a base64-encoded NIP-98 event
|
||||
- Decodes and parses the event, requires kind `27235` (`HttpAuth`), and verifies its signature
|
||||
- Requires the event's `u` tag to contain `env.server_host` (skipped when `server_host` is empty)
|
||||
- Intentionally does **not** enforce exact request URL/method/query matching, and does **not** validate the `payload` tag/hash, `created_at` freshness window, or a replay nonce/cache
|
||||
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
|
||||
- Returns the signer pubkey (hex) when all checks pass; any failure surfaces as a `401`
|
||||
|
||||
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible.
|
||||
|
||||
# Routes
|
||||
|
||||
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
|
||||
|
||||
--- Identity
|
||||
|
||||
## `get_identity` — `GET /identity`
|
||||
|
||||
- Authenticated (any signer)
|
||||
- Side-effect-free: returns `{ pubkey, is_admin }`
|
||||
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||
|
||||
--- Plans
|
||||
|
||||
## `list_plans` — `GET /plans`
|
||||
|
||||
- No authentication required
|
||||
- `data` is the list of plans from `query.list_plans`
|
||||
|
||||
## `get_plan` — `GET /plans/:id`
|
||||
|
||||
- No authentication required
|
||||
- `data` is the plan matching `id`; `404` `not-found` if it doesn't exist
|
||||
|
||||
--- Tenants
|
||||
|
||||
## `list_tenants` — `GET /tenants`
|
||||
|
||||
- Admin only
|
||||
- `data` is a list of `TenantResponse` (exposes `nwc_is_set: bool` instead of `nwc_url`)
|
||||
|
||||
## `create_tenant` — `POST /tenants`
|
||||
|
||||
- Authenticated (any signer); the target pubkey is the auth pubkey, no request body
|
||||
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
|
||||
- Otherwise resolve a display name via `robot.fetch_nostr_name` (falling back to the first 8 chars of the pubkey), create a Stripe customer via `stripe.create_customer`, and create the tenant. No subscription is created yet — that happens when the first paid relay is added.
|
||||
- On a unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
|
||||
- Always returns `200`; `data` is a `TenantResponse`
|
||||
|
||||
## `get_tenant` — `GET /tenants/:pubkey`
|
||||
|
||||
- Admin or matching tenant
|
||||
- `data` is a `TenantResponse`
|
||||
|
||||
## `update_tenant` — `PUT /tenants/:pubkey`
|
||||
|
||||
- Admin or matching tenant
|
||||
- Accepts an optional `nwc_url`: an empty string clears it, otherwise it is encrypted at rest via `env.encrypt`
|
||||
- Updates the tenant via `command.update_tenant`
|
||||
- `data` is the updated `TenantResponse`
|
||||
|
||||
## `list_tenant_relays` — `GET /tenants/:pubkey/relays`
|
||||
|
||||
- Admin or matching tenant
|
||||
- `data` is the tenant's relays from `query.list_relays_for_tenant`
|
||||
|
||||
--- Relays
|
||||
|
||||
## `list_relays` — `GET /relays`
|
||||
|
||||
- Admin only
|
||||
- `data` is all relays from `query.list_relays`
|
||||
|
||||
## `get_relay` — `GET /relays/:id`
|
||||
|
||||
- `404` `not-found` if the relay doesn't exist; then admin or relay owner
|
||||
- `data` is the relay
|
||||
|
||||
## `list_relay_members` — `GET /relays/:id/members`
|
||||
|
||||
- Admin or relay owner
|
||||
- For unsynced relays (`synced = 0`), returns an empty member list without calling zooid
|
||||
- For synced relays, proxies the member list from zooid via `infra.list_relay_members`
|
||||
- `data` is `{ members }`
|
||||
|
||||
## `create_relay` — `POST /relays`
|
||||
|
||||
- Admin or the `tenant` pubkey in the request body
|
||||
- Generates the relay `id`, validates and normalizes the relay via `prepare_relay`, and creates it via `command.create_relay`
|
||||
- Duplicate subdomain → `422` `subdomain-exists`
|
||||
- `data` is the relay; HTTP `201`
|
||||
|
||||
## `update_relay` — `PUT /relays/:id`
|
||||
|
||||
- `404` if missing; then admin or relay owner
|
||||
- Applies the provided optional fields, then validates/normalizes via `prepare_relay`
|
||||
- If the plan changes to one with a finite member limit and the current member count exceeds it, return `422` `member-limit-exceeded`
|
||||
- Updates via `command.update_relay`; duplicate subdomain → `422` `subdomain-exists`
|
||||
- `data` is the relay
|
||||
|
||||
## `list_relay_activity` — `GET /relays/:id/activity`
|
||||
|
||||
- `404` if missing; then admin or relay owner
|
||||
- `data` is `{ activity }` from `query.list_activity_for_resource`
|
||||
|
||||
## `deactivate_relay` — `POST /relays/:id/deactivate`
|
||||
|
||||
- `404` if missing; then admin or relay owner
|
||||
- If status is `delinquent`, return `400` `relay-is-delinquent`; if already `inactive`, return `400` `relay-is-inactive`
|
||||
- Otherwise `command.deactivate_relay`; `data` is empty
|
||||
|
||||
## `reactivate_relay` — `POST /relays/:id/reactivate`
|
||||
|
||||
- `404` if missing; then admin or relay owner
|
||||
- If status is `delinquent`, return `400` `relay-is-delinquent` (a delinquent relay must be resolved through payment, not reactivated by the user); if already `active`, return `400` `relay-is-active`
|
||||
- Otherwise `command.activate_relay`; `data` is empty
|
||||
|
||||
--- Invoices
|
||||
|
||||
## `list_tenant_invoices` — `GET /tenants/:pubkey/invoices`
|
||||
|
||||
- Admin or matching tenant
|
||||
- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id`
|
||||
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency, period_start, period_end }`
|
||||
|
||||
## `get_invoice` — `GET /invoices/:id`
|
||||
|
||||
- Fetches the invoice from Stripe (`404` `not-found` if it doesn't exist)
|
||||
- Looks up the tenant by the invoice's `customer` (`404` if none), then authorizes admin or matching tenant
|
||||
- Runs `billing.reconcile_invoice` (marks it paid if its bolt11 already settled out of band)
|
||||
- `data` is the (possibly refreshed) `StripeInvoice`
|
||||
|
||||
## `get_lightning_invoice` — `GET /invoices/:id/bolt11`
|
||||
|
||||
- Fetches the invoice from Stripe (`404` if it doesn't exist) and the tenant by `customer` (`404` if none), then authorizes admin or matching tenant
|
||||
- Runs `billing.reconcile_invoice`, then `billing.ensure_lightning_invoice` to get or (re)issue the bolt11 for the invoice's `amount_due`/`currency`
|
||||
- `data` is the `LightningInvoice` (including its `bolt11`)
|
||||
|
||||
--- Stripe portal
|
||||
|
||||
## `create_stripe_session` — `GET /tenants/:pubkey/stripe/session`
|
||||
|
||||
- Admin or matching tenant; accepts an optional `return_url` query parameter
|
||||
- Looks up the tenant and creates a Stripe Customer Portal session for its `stripe_customer_id`
|
||||
- `data` is `{ url }` — the portal session URL
|
||||
|
||||
--- Stripe webhook
|
||||
|
||||
## `stripe_webhook` — `POST /stripe/webhook`
|
||||
|
||||
- No NIP-98 authentication — verified via the `Stripe-Signature` header over the raw body
|
||||
- Reads the raw body and signature, verifies/parses the event via `stripe.get_webhook_event`, and dispatches to the handlers below
|
||||
- Returns `200` on success, `400` (`webhook-error`) on verification/parse failure
|
||||
|
||||
# Webhook event handlers
|
||||
|
||||
Implemented in `routes/stripe.rs`. They translate verified Stripe events into domain actions, looking the tenant up by `stripe_customer_id` and ignoring events whose customer doesn't map to a tenant. Unknown event types are ignored.
|
||||
|
||||
## `invoice.created`
|
||||
|
||||
Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fires immediately when a paid relay is added or a plan is upgraded). Skips `amount_due` of 0. Ensures a `LightningInvoice` exists, then in priority order:
|
||||
|
||||
1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through.
|
||||
2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt.
|
||||
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any). The DM includes a link to the dashboard payment view for this invoice — `{env.app_url}/account?invoice={stripe_invoice_id}` — where the tenant can review the invoice and pay by Lightning or card.
|
||||
|
||||
## `invoice.paid`
|
||||
|
||||
- If the tenant has `past_due_at` set, clear it (`command.clear_tenant_past_due`) and reactivate each `delinquent` relay on a paid plan via `command.activate_relay`
|
||||
|
||||
## `invoice.payment_failed`
|
||||
|
||||
- If the tenant doesn't already have `past_due_at` set, set it (`command.set_tenant_past_due`) and DM the tenant that payment failed and their relays may be deactivated if unresolved
|
||||
|
||||
## `invoice.overdue`
|
||||
|
||||
- Mark every `active` relay on a paid plan `delinquent` (`command.mark_relay_delinquent`) and DM the tenant that their paid relays were deactivated for non-payment
|
||||
|
||||
## `customer.subscription.updated`
|
||||
|
||||
- If the subscription status is `canceled` or `unpaid`, clear `stripe_subscription_id` (`command.clear_tenant_subscription`) and mark every `active` paid relay `delinquent`
|
||||
|
||||
## `customer.subscription.deleted`
|
||||
|
||||
- Clear `stripe_subscription_id` (`command.clear_tenant_subscription`)
|
||||
|
||||
## `payment_method.attached`
|
||||
|
||||
- Retry Stripe collection (`stripe.pay_invoice`) for every `open` invoice with `amount_due > 0`, so invoices that were due before the card was added are charged immediately
|
||||
|
||||
# Helpers
|
||||
|
||||
## `prepare_relay(api: &Api, relay: Relay) -> Result<Relay, ApiError>`
|
||||
|
||||
- Validates `subdomain` against the allowed pattern and a reserved list (`api`, `admin`, `internal`) → `422` `invalid-subdomain`
|
||||
- Validates that `plan` matches a known plan → `422` `invalid-plan`
|
||||
- If the relay enables `blossom`/`livekit` but the selected plan doesn't include it → `422` `premium-feature`
|
||||
- Normalizes the boolean relay flags to sane defaults
|
||||
|
||||
# `TenantResponse`
|
||||
|
||||
The tenant shape returned by tenant endpoints. Same as `Tenant` but replaces `nwc_url` with `nwc_is_set: bool` (true when a `nwc_url` is stored) and never exposes the stored URL: `{ pubkey, nwc_is_set, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at }`.
|
||||
@@ -1,96 +0,0 @@
|
||||
# `pub struct Billing`
|
||||
|
||||
Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment).
|
||||
|
||||
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`.
|
||||
|
||||
Members:
|
||||
|
||||
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
|
||||
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `env: Env` - used to decrypt a tenant's stored `nwc_url`
|
||||
|
||||
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
|
||||
|
||||
- Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)`
|
||||
- Panics if `ROBOT_WALLET` is not a valid NWC URL
|
||||
|
||||
## `pub async fn start(self)`
|
||||
|
||||
- Subscribes to `command.notify.subscribe()`
|
||||
- Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop
|
||||
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
|
||||
- On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits
|
||||
|
||||
## `async fn reconcile_subscriptions(&self, source: &str)`
|
||||
|
||||
- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors
|
||||
|
||||
## `async fn handle_activity(&self, activity: &Activity)`
|
||||
|
||||
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription`
|
||||
|
||||
## `async fn reconcile_subscription(&self, tenant: &Tenant)`
|
||||
|
||||
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe; free-only tenants have no subscription. Idempotent.
|
||||
|
||||
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay.
|
||||
|
||||
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and the webhook handler attempts payment (see `spec/api.md`).
|
||||
|
||||
- Build the desired state via `get_quantity_by_price_id`.
|
||||
- **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return.
|
||||
- Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items.
|
||||
|
||||
## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>>`
|
||||
|
||||
- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map.
|
||||
|
||||
## `async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>>`
|
||||
|
||||
- If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s)
|
||||
- If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None`
|
||||
|
||||
## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result<StripeSubscription>`
|
||||
|
||||
- Returns the existing subscription if there is one
|
||||
- Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription`
|
||||
|
||||
## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)`
|
||||
|
||||
- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription`
|
||||
|
||||
## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)`
|
||||
|
||||
- For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item
|
||||
- Delete any existing item whose price no longer appears in the desired state
|
||||
|
||||
## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result<LightningInvoice>`
|
||||
|
||||
- Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired
|
||||
- Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid)
|
||||
|
||||
## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>`
|
||||
|
||||
Pays a Lightning invoice from the tenant's own wallet.
|
||||
|
||||
- Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet`
|
||||
- Pay `invoice.bolt11` from the tenant wallet
|
||||
- On success, `settle_invoice(..., "nwc")`
|
||||
- On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error
|
||||
|
||||
## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice>`
|
||||
|
||||
Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid.
|
||||
|
||||
- If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged
|
||||
- If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s
|
||||
- On any settlement-lookup failure, log and return the invoice unchanged
|
||||
|
||||
## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)`
|
||||
|
||||
- `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins)
|
||||
- `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent)
|
||||
- `command.clear_tenant_nwc_error(tenant_pubkey)`
|
||||
@@ -1,11 +0,0 @@
|
||||
# `bitcoin` — fiat ↔ Bitcoin conversion
|
||||
|
||||
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
|
||||
|
||||
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
|
||||
|
||||
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
|
||||
|
||||
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
|
||||
|
||||
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
|
||||
@@ -1,109 +0,0 @@
|
||||
# `pub struct Command`
|
||||
|
||||
Command writes to the database.
|
||||
|
||||
Members:
|
||||
|
||||
- `pool: SqlitePool` - a sqlite connection pool
|
||||
- `pub notify: broadcast::Sender<Activity>` - callers can subscribe via `command.notify.subscribe()`
|
||||
|
||||
Notes:
|
||||
|
||||
- Write methods that mutate tenants/relays are atomic and accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`, run inside a single transaction via the `with_activity` helper.
|
||||
- `insert_activity` builds and returns the `Activity` struct (resolving `tenant` from the resource — directly for `tenant` resources, by looking up `relay.tenant` for `relay` resources — and using `chrono::Utc::now()` for `created_at`).
|
||||
- After each successful commit, the `Activity` is sent on the broadcast channel.
|
||||
- The subscription/error/past-due setters and the lightning-invoice writes below intentionally do **not** log activity and write directly to the pool.
|
||||
|
||||
## `pub fn new(pool: SqlitePool) -> Self`
|
||||
|
||||
- Stores the pool and creates the broadcast channel
|
||||
|
||||
## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
- Creates tenant (writes `pubkey`, `nwc_url`, `created_at`, `stripe_customer_id`), may throw sqlite uniqueness error on pubkey
|
||||
- Logs activity as `(create_tenant, tenant, pubkey)`
|
||||
|
||||
## `pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
- Updates the tenant's `nwc_url`
|
||||
- Logs activity as `(update_tenant, tenant, pubkey)`
|
||||
|
||||
## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain
|
||||
- Logs activity as `(create_relay, relay, id)`
|
||||
|
||||
## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Updates relay (all mutable fields), resets `synced = 0` so it re-syncs to zooid; may throw sqlite uniqueness error on subdomain
|
||||
- Logs activity as `(update_relay, relay, id)`
|
||||
|
||||
## `pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Sets relay status to `inactive` and `synced = 0`
|
||||
- Logs activity as `(deactivate_relay, relay, id)`
|
||||
- Used for user/admin-initiated deactivation only
|
||||
|
||||
## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Sets relay status to `delinquent` and `synced = 0`
|
||||
- Logs activity as `(mark_relay_delinquent, relay, id)`
|
||||
- Used exclusively by the billing system when a relay's subscription becomes past due
|
||||
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
|
||||
|
||||
## `pub async fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Sets relay status to `active` and `synced = 0`
|
||||
- Logs activity as `(activate_relay, relay, id)`
|
||||
|
||||
## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>`
|
||||
|
||||
- Sets `synced = 0` and `sync_error` on the relay
|
||||
- Logs activity as `(fail_relay_sync, relay, id)`
|
||||
|
||||
## `pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `synced = 1`, clears `sync_error`
|
||||
- Logs activity as `(complete_relay_sync, relay, id)`
|
||||
|
||||
## `pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at` to the current timestamp
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn insert_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64) -> Result<Option<LightningInvoice>>`
|
||||
|
||||
- Upserts the `pending` bolt11 row for a Stripe invoice, returning the resulting row
|
||||
- On conflict the stored `bolt11`/`expires_at` are replaced (this is how an expired invoice is regenerated), **except** once the invoice is `paid`: the `status = 'pending'` guard makes the update a no-op and `None` is returned so the caller can fall back to reading the settled row
|
||||
- Does not log activity
|
||||
|
||||
## `pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()>`
|
||||
|
||||
- Marks a `pending` invoice `paid`, recording `paid_method = method` (`nwc` or `manual`)
|
||||
- The `status = 'pending'` guard makes this idempotent and first-writer-wins: a later reconcile won't clobber the method recorded by whoever settled it first
|
||||
- Does not log activity
|
||||
@@ -1,48 +0,0 @@
|
||||
# `pub struct Env`
|
||||
|
||||
Env is the application's configuration, loaded once at startup and cloned into every service that needs it (`Api`, `Query`, `Command`, `Billing`, `Infra`, `Robot`, `Stripe`). It is the single source of truth for environment-derived settings, and it also owns the robot nostr key used for signing, NIP-44 encryption, and NIP-98 auth.
|
||||
|
||||
Members (all populated from environment variables):
|
||||
|
||||
- `server_host: String` - from `SERVER_HOST`; also used for the NIP-98 `u` host check
|
||||
- `server_port: u16` - from `SERVER_PORT`
|
||||
- `server_admin_pubkeys: Vec<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
|
||||
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
|
||||
- `app_url: String` - public base URL of the frontend app from `APP_URL`, with any trailing slash stripped; used to build tenant-facing links such as the invoice payment link in billing DMs
|
||||
- `database_url: String` - from `DATABASE_URL`
|
||||
- `robot_name: String` - from `ROBOT_NAME`
|
||||
- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices
|
||||
- `robot_picture: String` - from `ROBOT_PICTURE`
|
||||
- `robot_description: String` - from `ROBOT_DESCRIPTION`
|
||||
- `robot_outbox_relays: Vec<String>` - from `ROBOT_OUTBOX_RELAYS`
|
||||
- `robot_indexer_relays: Vec<String>` - from `ROBOT_INDEXER_RELAYS`
|
||||
- `robot_messaging_relays: Vec<String>` - from `ROBOT_MESSAGING_RELAYS`
|
||||
- `blossom_s3_region` / `blossom_s3_bucket` / `blossom_s3_endpoint` / `blossom_s3_access_key` / `blossom_s3_secret_key: String` - from the matching `BLOSSOM_S3_*` vars
|
||||
- `zooid_api_url: String` - from `ZOOID_API_URL`
|
||||
- `relay_domain: String` - from `RELAY_DOMAIN`
|
||||
- `livekit_url` / `livekit_api_key` / `livekit_api_secret: String` - from the matching `LIVEKIT_*` vars
|
||||
- `stripe_secret_key: String` - from `STRIPE_SECRET_KEY`
|
||||
- `stripe_webhook_secret: String` - from `STRIPE_WEBHOOK_SECRET`
|
||||
- `stripe_price_basic: String` - Stripe price id for the Basic plan, from `STRIPE_PRICE_BASIC`
|
||||
- `stripe_price_growth: String` - Stripe price id for the Growth plan, from `STRIPE_PRICE_GROWTH`
|
||||
- `keys: Keys` - parsed from `ROBOT_SECRET`; used for nostr signing, NIP-44 encryption, and NIP-98 auth
|
||||
|
||||
## `pub fn load() -> Self`
|
||||
|
||||
- Reads every variable above and panics if any is missing or malformed.
|
||||
- String vars must be present and non-blank (trimmed).
|
||||
- The port must parse as a `u16`.
|
||||
- CSV vars are split on commas, trimmed, and empties dropped; each must contain at least one entry.
|
||||
- `keys` is parsed from `ROBOT_SECRET` and panics if it is not a valid nostr secret key.
|
||||
|
||||
## `pub fn encrypt(&self, plaintext: &str) -> Result<String>`
|
||||
|
||||
- NIP-44 (v2) encrypts `plaintext` to the robot's own key. Used to encrypt a tenant's `nwc_url` at rest.
|
||||
|
||||
## `pub fn decrypt(&self, ciphertext: &str) -> Result<String>`
|
||||
|
||||
- NIP-44 decrypts a value previously produced by `encrypt`.
|
||||
|
||||
## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String>`
|
||||
|
||||
- Builds a NIP-98 `Authorization` header value for an outgoing request to `url` with `method`, signed with `keys`. Used by `Infra` to authenticate requests to zooid.
|
||||
@@ -1,59 +0,0 @@
|
||||
# `pub struct Infra`
|
||||
|
||||
Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance.
|
||||
|
||||
Members:
|
||||
|
||||
- `env: Env` - configuration; supplies `zooid_api_url`, `relay_domain`, the `BLOSSOM_S3_*` and `LIVEKIT_*` settings, and the robot key used to sign requests
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
|
||||
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
|
||||
|
||||
- Stores `query`, `command`, and a clone of `env`
|
||||
|
||||
## `pub async fn start(self)`
|
||||
|
||||
- Subscribes to `command.notify`
|
||||
- Runs `reconcile_relay_state("startup")` before entering the loop
|
||||
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
|
||||
- On `Lagged`, runs `reconcile_relay_state("lagged")`; on `Closed`, exits
|
||||
|
||||
## `async fn handle_activity(&self, activity: &Activity)`
|
||||
|
||||
- Ignores anything that isn't a `relay` resource with activity type `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, or `fail_relay_sync`
|
||||
- For `fail_relay_sync`, schedules a delayed retry via `schedule_relay_sync_retry`
|
||||
- Otherwise resolves the relay (skip if gone) and calls `sync_relay`
|
||||
|
||||
## `async fn reconcile_relay_state(&self, source: &str)`
|
||||
|
||||
- Lists relays still pending sync (`query.list_relays_pending_sync`)
|
||||
- For each: `sync_relay` immediately if its `sync_error` is empty, otherwise `schedule_relay_sync_retry`
|
||||
|
||||
## `async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str)`
|
||||
|
||||
- Counts the relay's consecutive trailing `fail_relay_sync` activities to derive the attempt number
|
||||
- Computes an exponential backoff (base 30s, doubling, capped at 15 minutes); gives up after `RELAY_SYNC_RETRY_MAX_ATTEMPTS` (6) to avoid infinite retry loops
|
||||
- Spawns a task that sleeps for the delay, then re-reads the relay and `sync_relay`s it (no-op if the relay is gone)
|
||||
|
||||
## `async fn sync_relay(&self, relay: &Relay)`
|
||||
|
||||
- Calls `try_sync_relay`; on success `command.complete_relay_sync`, on failure `command.fail_relay_sync` with the error
|
||||
|
||||
## `async fn try_sync_relay(&self, relay: &Relay)`
|
||||
|
||||
- A relay is "new" only if it has never completed a sync (`synced != 1` and no `complete_relay_sync` activity exists). New relays are created with `POST /relay/:id`; existing relays are updated with `PATCH /relay/:id`.
|
||||
- A freshly generated `secret` is included only for creation (`POST`), so updates don't rotate relay identity and we never store the secret.
|
||||
- The body carries relay configuration: `host` (= `subdomain.relay_domain`), `schema`, `inactive` (true when status is `inactive` or `delinquent`), `info` (name/icon/description/pubkey), `policy`, `groups`, `management`, `blossom`, `livekit`, `push`, and hard-coded `roles`.
|
||||
- When `blossom_enabled`, the blossom section uses `adapter: "s3"` with the `BLOSSOM_S3_*` settings and `s3.key_prefix` set to the relay's `id`; otherwise it sends `{ "enabled": false }`.
|
||||
- When `livekit_enabled`, the livekit section carries the `LIVEKIT_*` settings; otherwise `{ "enabled": false }`.
|
||||
|
||||
## `pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>>`
|
||||
|
||||
- `GET /relay/:id/members` from zooid; returns the `members` array
|
||||
|
||||
## `async fn request(&self, method, path, body)`
|
||||
|
||||
- Sends an authenticated request to the zooid API at `path` (relative to `env.zooid_api_url`), with a 5s timeout
|
||||
- Authenticates each request with a NIP-98 header via `env.make_auth`
|
||||
- Returns the response on 2xx; bails with the status and body text otherwise
|
||||
@@ -1,17 +0,0 @@
|
||||
# `async fn main() -> Result<()>`
|
||||
|
||||
- Loads `.env` (via `dotenvy`) and configures tracing/logging from the default env filter
|
||||
- Calls `Env::load()` to read and validate all configuration
|
||||
- Calls `create_pool(&env.database_url)` to get a `SqlitePool`
|
||||
- Constructs the services, passing `&env` where needed:
|
||||
- `Robot::new(&env).await` (publishes the robot's nostr identity)
|
||||
- `Stripe::new(&env)`
|
||||
- `Query::new(pool.clone(), &env)`
|
||||
- `Command::new(pool)`
|
||||
- `Billing::new(query.clone(), command.clone(), &env)`
|
||||
- `Infra::new(query.clone(), command.clone(), &env)`
|
||||
- `Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env)`
|
||||
- Builds a CORS layer restricted to the parsed `env.server_allow_origins`
|
||||
- Gets the axum router from `api.router()` and applies the CORS layer
|
||||
- Spawns `infra.start()` and `billing.start()` as background tasks
|
||||
- Binds a `TcpListener` on `env.server_host:env.server_port` and calls `axum::serve`
|
||||
@@ -1,103 +0,0 @@
|
||||
This file describes the domain model. This description should be translated into standard structs and sqlite schemas in a way that makes sense.
|
||||
|
||||
- Fields marked as private should use `#[serde(skip_serializing)]` in their definition.
|
||||
- Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition.
|
||||
|
||||
# Identity
|
||||
|
||||
Identity is a description of a user.
|
||||
|
||||
- `pubkey` - the user's nostr pubkey
|
||||
- `is_admin` - whether the user is an admin
|
||||
|
||||
# Activity
|
||||
|
||||
Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior.
|
||||
|
||||
- `id` - a random activity ID
|
||||
- `tenant` - a tenant ID
|
||||
- `created_at` - unix timestamp when the activity was created
|
||||
- `activity_type` is one of:
|
||||
- `create_tenant`
|
||||
- `update_tenant`
|
||||
- `create_relay`
|
||||
- `update_relay`
|
||||
- `activate_relay`
|
||||
- `deactivate_relay`
|
||||
- `mark_relay_delinquent`
|
||||
- `fail_relay_sync`
|
||||
- `complete_relay_sync`
|
||||
- `resource_type` is a string identifying the resource type being modified.
|
||||
- `resource_id` is a string identifying the resource id being modified.
|
||||
|
||||
# Plan
|
||||
|
||||
A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth.
|
||||
|
||||
- `id` - the plan slug
|
||||
- `name` - the plan name
|
||||
- `amount` - the plan's monthly cost in USD minor units (cents); e.g. `500` for $5/mo
|
||||
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
|
||||
- `blossom` - whether blossom media hosting is available on this plan
|
||||
- `livekit` - whether livekit audio/video calls are available on this plan
|
||||
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
|
||||
|
||||
There are three plans available:
|
||||
|
||||
- `free` - $0/mo, up to 10 members, no blossom/livekit
|
||||
- `basic` - $5/mo, up to 100 members, includes blossom/livekit
|
||||
- `growth` - $25/mo, unlimited members, includes blossom/livekit
|
||||
|
||||
# Tenant
|
||||
|
||||
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
|
||||
|
||||
- `pubkey` is the nostr public key identifying the tenant
|
||||
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 with the robot key (`ROBOT_SECRET`, via `Env::encrypt`); never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead
|
||||
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
|
||||
- `created_at` unix timestamp identifying tenant creation time
|
||||
- `stripe_customer_id` a string identifying the associated stripe customer
|
||||
- `stripe_subscription_id` (nullable) a string identifying the associated stripe subscription. Created when the first paid (non-free) relay is activated, deleted when the last paid relay is removed or deactivated. Free-only tenants have no subscription.
|
||||
- `past_due_at` (nullable) unix timestamp when the tenant's subscription became past due. Set on payment failure, cleared on payment success.
|
||||
|
||||
# Relay
|
||||
|
||||
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
|
||||
|
||||
- `id` - calculated based on `subdomain` (with `-` replaced by `_`) + `_` + 8 random hex chars
|
||||
- `tenant` - the tenant's pubkey
|
||||
- `subdomain` - the relay's subdomain
|
||||
- `plan` - the relay's plan
|
||||
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
|
||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||
- `info_name` - the relay's name
|
||||
- `info_icon` - the relay's icon image URL
|
||||
- `info_description` - the relay's description
|
||||
- `policy_public_join` - whether to allow non-members to join the relay without an invite code
|
||||
- `policy_strip_signatures` - whether to remove signatures when serving events to non-admins
|
||||
- `groups_enabled` - whether NIP 29 groups are enabled
|
||||
- `management_enabled` - whether NIP 86 management API is enabled
|
||||
- `blossom_enabled` - whether blossom file storage is enabled
|
||||
- `livekit_enabled` - whether livekit calls are enabled
|
||||
- `push_enabled` - whether relay push is enabled
|
||||
|
||||
Some attributes persisted to zooid via API have special handling:
|
||||
|
||||
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
|
||||
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
|
||||
- The value of `inactive` is calculated based on `status`
|
||||
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
|
||||
- The relay's `roles` are hard-coded for now.
|
||||
|
||||
# LightningInvoice
|
||||
|
||||
Tracks the bolt11 invoice issued on the system wallet to collect a single Stripe invoice over Lightning (either NWC auto-pay or a manual payment from the app). One row per Stripe invoice.
|
||||
|
||||
- `stripe_invoice_id` - the Stripe invoice this bolt11 collects (primary key)
|
||||
- `tenant_pubkey` - the owning tenant
|
||||
- `bolt11` - the bolt11 invoice string issued on the system wallet
|
||||
- `status` - one of `pending|paid`
|
||||
- `paid_method` (nullable) - how it settled, one of `nwc|manual`; set when `status` becomes `paid`
|
||||
- `expires_at` - unix timestamp after which the `pending` bolt11 is considered expired and may be regenerated
|
||||
- `created_at` / `updated_at` - unix timestamps
|
||||
@@ -1,15 +0,0 @@
|
||||
# `pub async fn create_pool(database_url: &str) -> Result<SqlitePool>`
|
||||
|
||||
Creates and returns a sqlite connection pool.
|
||||
|
||||
Notes:
|
||||
|
||||
- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice`
|
||||
|
||||
Steps:
|
||||
|
||||
- Normalizes `database_url`: a relative `sqlite://` path is resolved under the crate manifest directory (`CARGO_MANIFEST_DIR`); absolute paths and `:memory:` are left as-is
|
||||
- Ensures any directory referred to in the (normalized) URL exists
|
||||
- Opens the pool with `create_if_missing` enabled
|
||||
- Enables WAL journaling (`PRAGMA journal_mode = WAL`)
|
||||
- Runs migrations found in the `migrations` directory
|
||||
@@ -1,69 +0,0 @@
|
||||
# `pub struct Query`
|
||||
|
||||
Query reads from the database.
|
||||
|
||||
Members:
|
||||
|
||||
- `pool: SqlitePool` - a sqlite connection pool
|
||||
- `env: Env` - configuration; used to fill in plan `stripe_price_id`s from `STRIPE_PRICE_*`
|
||||
|
||||
## `pub fn new(pool: SqlitePool, env: &Env) -> Self`
|
||||
|
||||
- Stores the pool and a clone of `env`
|
||||
|
||||
## `pub fn list_plans(&self) -> Vec<Plan>`
|
||||
|
||||
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
|
||||
- The `basic`/`growth` `stripe_price_id`s come from `env` (`stripe_price_basic` / `stripe_price_growth`); `free` has none
|
||||
- This is the source of truth for plan metadata exposed via API
|
||||
|
||||
## `pub fn get_plan(&self, plan_id: &str) -> Option<Plan>`
|
||||
|
||||
- Returns the plan matching `plan_id`, if any
|
||||
|
||||
## `pub fn is_paid_plan(&self, plan_id: &str) -> bool`
|
||||
|
||||
- Returns whether `plan_id` is a known plan with `amount > 0`
|
||||
|
||||
## `pub async fn list_tenants(&self) -> Result<Vec<Tenant>>`
|
||||
|
||||
- Returns all tenants
|
||||
|
||||
## `pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>>`
|
||||
|
||||
- Returns the matching tenant, or `None` if not found
|
||||
|
||||
## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>>`
|
||||
|
||||
- Returns the tenant matching the given `stripe_customer_id`, or `None`
|
||||
|
||||
## `pub async fn list_relays(&self) -> Result<Vec<Relay>>`
|
||||
|
||||
- Returns all relays
|
||||
|
||||
## `pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>>`
|
||||
|
||||
- Returns all relays where `synced = 0` or `sync_error` is non-empty
|
||||
- Used by `Infra` to reconcile relays that still need to be pushed to zooid
|
||||
|
||||
## `pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
|
||||
|
||||
- Returns all relays belonging to the given tenant
|
||||
|
||||
## `pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>>`
|
||||
|
||||
- Returns the matching relay, or `None` if not found
|
||||
|
||||
## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result<Option<LightningInvoice>>`
|
||||
|
||||
- Returns the `lightning_invoice` row for the given Stripe invoice, or `None`
|
||||
|
||||
## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>>`
|
||||
|
||||
- Returns all activity where `resource_id = resource_id`
|
||||
- Ordered newest-first
|
||||
|
||||
## `pub async fn get_latest_activity_for_resource_and_type(&self, resource_id: &str, activity_type: &str) -> Result<Option<Activity>>`
|
||||
|
||||
- Returns the most recent activity for `resource_id` with the given `activity_type`, or `None`
|
||||
- Used by `Infra` to decide whether a relay has ever completed a sync
|
||||
@@ -1,32 +0,0 @@
|
||||
# `pub struct Robot`
|
||||
|
||||
Robot is the nostr identity that acts on behalf of the application — it publishes the app's profile/relay lists and sends DMs to tenants. It signs with the robot key in `env.keys` and builds nostr clients on demand from the relay lists in `env`.
|
||||
|
||||
Members:
|
||||
|
||||
- `env: Env` - configuration; supplies the robot key and the outbox/indexer/messaging relay lists and profile metadata
|
||||
- `outbox_cache` / `dm_cache` - per-recipient caches (5 minute TTL) of discovered outbox and messaging relays
|
||||
|
||||
## `pub async fn new(env: &Env) -> Result<Self>`
|
||||
|
||||
- Stores a clone of `env` and initializes the caches
|
||||
- Calls `publish_identity`, which publishes a `kind 0` profile and a `kind 10002` relay list (the `ROBOT_OUTBOX_RELAYS`, as `r` tags) to the outbox relays, and a `kind 10050` DM relay list (the `ROBOT_MESSAGING_RELAYS`, as `relay` tags) via the indexer relays
|
||||
|
||||
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
|
||||
|
||||
- Resolves the recipient's outbox relays (`fetch_outbox_relays`), then their messaging relays from those outbox relays (`fetch_messaging_relays_from_outbox`)
|
||||
- Sends a NIP-17 private message to the recipient via their messaging relays
|
||||
- Errors if no outbox or messaging relays are found
|
||||
|
||||
## `pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String>`
|
||||
|
||||
- Fetches the recipient's `kind 0` metadata from the indexer relays and returns its `display_name` (falling back to `name`), trimmed and non-empty
|
||||
- Returns `None` on any failure — used to derive a Stripe customer display name
|
||||
|
||||
## `async fn fetch_outbox_relays(&self, recipient: &str) -> Result<Vec<String>>`
|
||||
|
||||
- Returns the `r` tags from the recipient's latest `kind 10002` event, fetched from the indexer relays; cached for 5 minutes
|
||||
|
||||
## `async fn fetch_messaging_relays_from_outbox(&self, recipient: &str, outbox_relays: &[String]) -> Result<Vec<String>>`
|
||||
|
||||
- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes
|
||||
@@ -1,98 +0,0 @@
|
||||
# `pub struct Stripe`
|
||||
|
||||
A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.md`.
|
||||
|
||||
Members:
|
||||
|
||||
- `env: Env` - configuration; supplies the Stripe secret key (bearer token + idempotency HMAC key) and the webhook signing secret
|
||||
- `http: reqwest::Client`
|
||||
|
||||
All requests authenticate with `env.stripe_secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with the secret key. Reconcile-to-desired-state writes (e.g. setting an item quantity, deleting/canceling) intentionally omit the idempotency key, since re-applying the same target is a no-op.
|
||||
|
||||
On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line.
|
||||
|
||||
## `pub fn new(env: &Env) -> Self`
|
||||
|
||||
Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call.
|
||||
|
||||
## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String>`
|
||||
|
||||
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
|
||||
- Idempotent on `tenant_pubkey`
|
||||
- Returns the new customer id
|
||||
|
||||
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<StripeSubscription>>`
|
||||
|
||||
- `GET /v1/subscriptions/:id`
|
||||
- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the parsed `StripeSubscription`
|
||||
|
||||
## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap<String, i64>) -> Result<StripeSubscription>`
|
||||
|
||||
- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry
|
||||
- Idempotent on the customer and the `(price, quantity)` set
|
||||
- Returns the created `StripeSubscription` (including its items)
|
||||
|
||||
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
|
||||
|
||||
- `POST /v1/subscription_items`
|
||||
- Idempotent on `(subscription_id, price_id)`
|
||||
|
||||
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
|
||||
|
||||
- `POST /v1/subscription_items/:id` with `quantity`
|
||||
- No idempotency key (reconcile-to-target write)
|
||||
|
||||
## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>`
|
||||
|
||||
- `DELETE /v1/subscription_items/:id`
|
||||
|
||||
## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>`
|
||||
|
||||
- `DELETE /v1/subscriptions/:id`
|
||||
|
||||
## `pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>>`
|
||||
|
||||
- `GET /v1/invoices?customer=…`
|
||||
- Returns the parsed `data` array
|
||||
|
||||
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>>`
|
||||
|
||||
- `GET /v1/invoices/:id`
|
||||
- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice`
|
||||
|
||||
## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>`
|
||||
|
||||
- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method)
|
||||
- Idempotent on `invoice_id`
|
||||
|
||||
## `pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()>`
|
||||
|
||||
- `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe
|
||||
- Idempotent on `invoice_id` (under a distinct key from `pay_invoice`)
|
||||
|
||||
## `pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool>`
|
||||
|
||||
- `GET /v1/payment_methods?customer=…&type=card`
|
||||
- Returns whether the customer has at least one card on file
|
||||
|
||||
## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
|
||||
|
||||
- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url`
|
||||
- Returns the Customer Portal session URL
|
||||
|
||||
## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent>`
|
||||
|
||||
Verifies the `Stripe-Signature` header against `env.stripe_webhook_secret` and parses the body.
|
||||
|
||||
- Parse `t=` (timestamp) and `v1=` (signature) from the header
|
||||
- Compute `HMAC-SHA256(secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch
|
||||
- Error if the timestamp is more than 300 seconds from now
|
||||
- Returns the deserialized `StripeWebhookEvent`
|
||||
|
||||
# Typed results
|
||||
|
||||
- `StripeWebhookEvent { event_type: String, data: StripeWebhookEventData }`, `StripeWebhookEventData { object: serde_json::Value }` — the verified, parsed webhook event (`event_type` deserializes from the JSON `type` field)
|
||||
- `StripeSubscription { id, status, items: Vec<StripeSubscriptionItem> }` (`items` flattened from Stripe's `{ data: [...] }` list)
|
||||
- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent)
|
||||
- `StripePrice { id }`
|
||||
- `StripeInvoice { id, customer, status, amount_due, currency, period_start, period_end }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
|
||||
@@ -1,23 +0,0 @@
|
||||
# `pub struct Wallet`
|
||||
|
||||
A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. Each operation opens a fresh NWC connection and shuts it down afterwards.
|
||||
|
||||
Member:
|
||||
|
||||
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
|
||||
|
||||
## `pub fn from_url(url: &str) -> Result<Self>`
|
||||
|
||||
Parses an `nostr+walletconnect://` URI.
|
||||
|
||||
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result<String>`
|
||||
|
||||
Issues a bolt11 invoice for `amount_msats` with the given `description` and expiry, and returns the bolt11 string.
|
||||
|
||||
## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
|
||||
|
||||
Pays a bolt11 invoice.
|
||||
|
||||
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
|
||||
|
||||
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
|
||||
@@ -1,39 +0,0 @@
|
||||
# `web` — HTTP response helpers
|
||||
|
||||
General-purpose helpers shared across the route handlers in `spec/api.md` (implemented under `src/routes/`). They standardize the success/error envelope and a couple of small utilities.
|
||||
|
||||
Successful responses are `{ data, code: "ok" }` with an appropriate HTTP status. Error responses are `{ error, code }` with an appropriate HTTP status, where `code` is a short machine-readable string (e.g. `subdomain-exists`) and `error` is a human-readable message.
|
||||
|
||||
## `pub struct ApiError(pub Box<Response>)`
|
||||
|
||||
A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From<Response>`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`.
|
||||
|
||||
## `pub type ApiResult = Result<Response, ApiError>`
|
||||
|
||||
The return type of every route handler. Success builders return `ApiResult` so they sit at the end of a handler without an `Ok(..)` wrap; error builders return `ApiError`.
|
||||
|
||||
## Response bodies
|
||||
|
||||
- `DataResponse<T> { data: T, code: "ok" }` - the success envelope
|
||||
- `ErrorResponse { error: String, code: String }` - the error envelope
|
||||
|
||||
## Success builders (return `ApiResult`)
|
||||
|
||||
- `res<T>(status, data)` - `{ data, code: "ok" }` with `status`
|
||||
- `ok<T>(data)` - `res(200, data)`
|
||||
- `created<T>(data)` - `res(201, data)`
|
||||
|
||||
## Error builders (return `ApiError`)
|
||||
|
||||
- `err(status, code, message)` - the base `{ error, code }` builder
|
||||
- `unauthorized(reason)` - `401`, `code = "unauthorized"`
|
||||
- `forbidden(message)` - `403`, `code = "forbidden"`
|
||||
- `not_found(message)` - `404`, `code = "not-found"`
|
||||
- `bad_request(code, message)` - `400` with the given `code`
|
||||
- `unprocessable(code, message)` - `422` with the given `code`
|
||||
- `internal(reason)` - `500`, `code = "internal"`
|
||||
|
||||
## Utilities
|
||||
|
||||
- `parse_bool_default(value: i64, default: i64) -> i64` - returns `value` if it is `0` or `1`, otherwise `default`. Used to normalize boolean-ish relay flags.
|
||||
- `map_unique_error(err: &anyhow::Error) -> Option<&'static str>` - recognizes sqlite UNIQUE constraint violations so callers can translate them into `422`s instead of `500`s. Returns `pubkey-exists` or `subdomain-exists` when the violated column message matches, else `None`.
|
||||
+29
-32
@@ -28,31 +28,27 @@ use base64::Engine;
|
||||
use nostr_sdk::{Event, JsonUtil, Kind};
|
||||
|
||||
use crate::billing::Billing;
|
||||
use crate::command::Command;
|
||||
use crate::env::Env;
|
||||
use crate::env;
|
||||
use crate::infra::Infra;
|
||||
use crate::models::{Relay, Tenant};
|
||||
use crate::query::Query;
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::invoices::{get_invoice, get_lightning_invoice, list_tenant_invoices};
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, get_tenant_latest_invoice};
|
||||
use crate::routes::plans::{get_plan, list_plans};
|
||||
use crate::routes::relays::{
|
||||
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
||||
list_relays, reactivate_relay, update_relay,
|
||||
};
|
||||
use crate::routes::stripe::{create_stripe_session, stripe_webhook};
|
||||
use crate::routes::tenants::{
|
||||
create_tenant, get_tenant, list_tenant_relays, list_tenants, update_tenant,
|
||||
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
|
||||
list_tenants, update_tenant,
|
||||
};
|
||||
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Api {
|
||||
pub env: Env,
|
||||
pub query: Query,
|
||||
pub command: Command,
|
||||
pub billing: Billing,
|
||||
pub stripe: Stripe,
|
||||
pub robot: Robot,
|
||||
@@ -60,19 +56,8 @@ pub struct Api {
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(
|
||||
query: Query,
|
||||
command: Command,
|
||||
billing: Billing,
|
||||
stripe: Stripe,
|
||||
robot: Robot,
|
||||
infra: Infra,
|
||||
env: &Env,
|
||||
) -> Self {
|
||||
pub fn new(billing: Billing, stripe: Stripe, robot: Robot, infra: Infra) -> Self {
|
||||
Self {
|
||||
env: env.clone(),
|
||||
query,
|
||||
command,
|
||||
billing,
|
||||
stripe,
|
||||
robot,
|
||||
@@ -90,24 +75,27 @@ impl Api {
|
||||
.route("/tenants", get(list_tenants).post(create_tenant))
|
||||
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
|
||||
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
|
||||
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
|
||||
.route(
|
||||
"/tenants/:pubkey/invoices/latest",
|
||||
get(get_tenant_latest_invoice),
|
||||
)
|
||||
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
|
||||
.route("/relays", get(list_relays).post(create_relay))
|
||||
.route("/relays/:id", get(get_relay).put(update_relay))
|
||||
.route("/relays/:id/members", get(list_relay_members))
|
||||
.route("/relays/:id/activity", get(list_relay_activity))
|
||||
.route("/relays/:id/deactivate", post(deactivate_relay))
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
|
||||
.route("/invoices/:id", get(get_invoice))
|
||||
.route("/invoices/:id/bolt11", get(get_lightning_invoice))
|
||||
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
|
||||
.route("/stripe/webhook", post(stripe_webhook))
|
||||
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
|
||||
.with_state(api)
|
||||
}
|
||||
|
||||
// --- authorization helpers ----------------------------------------------
|
||||
|
||||
pub fn is_admin(&self, pubkey: &str) -> bool {
|
||||
self.env.server_admin_pubkeys.iter().any(|a| a == pubkey)
|
||||
env::get().server_admin_pubkeys.iter().any(|a| a == pubkey)
|
||||
}
|
||||
|
||||
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> {
|
||||
@@ -118,6 +106,18 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_tenant(
|
||||
&self,
|
||||
authorized_pubkey: &str,
|
||||
tenant_pubkey: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
if authorized_pubkey == tenant_pubkey {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(forbidden("not authorized"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_admin_or_tenant(
|
||||
&self,
|
||||
authorized_pubkey: &str,
|
||||
@@ -131,7 +131,7 @@ impl Api {
|
||||
}
|
||||
|
||||
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> {
|
||||
match self.query.get_tenant(pubkey).await {
|
||||
match query::get_tenant(pubkey).await {
|
||||
Ok(Some(t)) => Ok(t),
|
||||
Ok(None) => Err(not_found("tenant not found")),
|
||||
Err(e) => Err(internal(e)),
|
||||
@@ -139,7 +139,7 @@ impl Api {
|
||||
}
|
||||
|
||||
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> {
|
||||
match self.query.get_relay(id).await {
|
||||
match query::get_relay(id).await {
|
||||
Ok(Some(r)) => Ok(r),
|
||||
Ok(None) => Err(not_found("relay not found")),
|
||||
Err(e) => Err(internal(e)),
|
||||
@@ -188,10 +188,7 @@ impl Api {
|
||||
.last()
|
||||
.ok_or_else(|| anyhow!("missing u tag"))?;
|
||||
|
||||
ensure!(
|
||||
self.env.server_host.is_empty() || got_u.contains(&self.env.server_host),
|
||||
"authorization host mismatch"
|
||||
);
|
||||
ensure!(got_u == env::get().server_host, "authorization host mismatch");
|
||||
|
||||
Ok(event.pubkey.to_hex())
|
||||
}
|
||||
|
||||
+525
-239
@@ -1,41 +1,45 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bitcoin;
|
||||
use crate::command::Command;
|
||||
use crate::env::Env;
|
||||
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
|
||||
use crate::query::Query;
|
||||
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
|
||||
use crate::command;
|
||||
use crate::db;
|
||||
use crate::env;
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Tenant};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
use crate::wallet::Wallet;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Billing {
|
||||
stripe: Stripe,
|
||||
wallet: Wallet,
|
||||
query: Query,
|
||||
command: Command,
|
||||
env: Env,
|
||||
robot: Robot,
|
||||
}
|
||||
|
||||
impl Billing {
|
||||
pub fn new(query: Query, command: Command, env: &Env) -> Self {
|
||||
pub fn new(robot: Robot) -> Self {
|
||||
Self {
|
||||
stripe: Stripe::new(env),
|
||||
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
|
||||
query,
|
||||
command,
|
||||
env: env.clone(),
|
||||
stripe: Stripe::new(),
|
||||
wallet: Wallet::from_url(&env::get().robot_wallet).expect("invalid ROBOT_WALLET"),
|
||||
robot,
|
||||
}
|
||||
}
|
||||
|
||||
// --- lifecycle methods ---
|
||||
|
||||
pub async fn start(self) {
|
||||
let mut rx = self.command.notify.subscribe();
|
||||
let mut rx = db::subscribe();
|
||||
|
||||
tokio::spawn({
|
||||
let billing = self.clone();
|
||||
async move { billing.poll().await }
|
||||
});
|
||||
|
||||
if let Err(error) = self.reconcile_subscriptions("startup").await {
|
||||
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
|
||||
tracing::error!(error = %error, "failed to reconcile subscriptions on startup");
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -46,10 +50,10 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(missed = n, "billing lagged");
|
||||
tracing::warn!(missed = n, "billing lagged, reconciling all subscriptions");
|
||||
|
||||
if let Err(error) = self.reconcile_subscriptions("lagged").await {
|
||||
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
|
||||
tracing::error!(error = %error, "failed to reconcile after lag");
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
@@ -57,17 +61,71 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
|
||||
let tenants = self.query.list_tenants().await?;
|
||||
async fn poll(&self) {
|
||||
let mut interval = tokio::time::interval(POLL_INTERVAL);
|
||||
|
||||
if tenants.is_empty() {
|
||||
return Ok(());
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if let Err(error) = self.autogenerate_invoices().await {
|
||||
tracing::error!(error = %error, "billing poll failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn autogenerate_invoices(&self) -> Result<()> {
|
||||
let tenants = query::list_tenants().await?;
|
||||
|
||||
tracing::info!(
|
||||
tenant_count = tenants.len(),
|
||||
"polling tenants for subscription renewal"
|
||||
);
|
||||
|
||||
for tenant in tenants {
|
||||
if let Err(error) = self.autogenerate_invoice(&tenant).await {
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
error = ?error,
|
||||
"failed to autogenerate invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll entry point: generate the tenant's invoice for the current period
|
||||
/// (adding any due renewals) and, if one results, collect payment.
|
||||
async fn autogenerate_invoice(&self, tenant: &Tenant) -> Result<()> {
|
||||
if let Some(invoice) = self.generate_invoice(tenant).await? {
|
||||
self.attempt_payment(tenant, &invoice).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let should_reconcile = matches!(
|
||||
activity.activity_type.as_str(),
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
|
||||
);
|
||||
|
||||
if should_reconcile
|
||||
&& let Some(tenant) = query::get_tenant(&activity.tenant).await?
|
||||
{
|
||||
self.reconcile_subscription(&tenant).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
|
||||
let tenants = query::list_tenants().await?;
|
||||
|
||||
tracing::info!(
|
||||
source,
|
||||
tenant_count = tenants.len(),
|
||||
"reconciling relay billing state"
|
||||
"reconciling all subscriptions"
|
||||
);
|
||||
|
||||
for tenant in tenants {
|
||||
@@ -76,7 +134,7 @@ impl Billing {
|
||||
source,
|
||||
tenant = %tenant.pubkey,
|
||||
error = ?error,
|
||||
"failed to reconcile relay billing state"
|
||||
"failed to reconcile subscription"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,197 +142,266 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_billing_sync = matches!(
|
||||
activity.activity_type.as_str(),
|
||||
"create_relay"
|
||||
| "update_relay"
|
||||
| "activate_relay"
|
||||
| "deactivate_relay"
|
||||
| "fail_relay_sync"
|
||||
| "complete_relay_sync"
|
||||
);
|
||||
// --- Reconciliation, renewal, and on-demand billing ---
|
||||
|
||||
if needs_billing_sync
|
||||
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
|
||||
{
|
||||
self.reconcile_subscription(&tenant).await?;
|
||||
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
|
||||
let mut tenant = tenant.clone();
|
||||
|
||||
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? {
|
||||
if tenant.billing_anchor.is_none() {
|
||||
tenant.billing_anchor = Some(activity.created_at);
|
||||
command::set_tenant_billing_anchor(&tenant).await?;
|
||||
}
|
||||
|
||||
self.reconcile_activity(&tenant, &activity).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
/// Reconciles a tenant's single Stripe subscription with the set of relays that
|
||||
/// should be billed.
|
||||
///
|
||||
/// Stripe forbids two subscription items on the same subscription from sharing a
|
||||
/// price, so billing is modeled as one subscription item per plan (price) with
|
||||
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
|
||||
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
|
||||
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
|
||||
|
||||
// If we've got no subscription items, we can cancel and clear the tenant's subscription
|
||||
if quantity_by_price_id.is_empty() {
|
||||
self.ensure_subscription_is_inactive(tenant).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let subscription = self
|
||||
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
|
||||
.await?;
|
||||
|
||||
self.ensure_subscription_items(subscription, quantity_by_price_id).await
|
||||
}
|
||||
|
||||
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
|
||||
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
|
||||
let mut quantity_by_price_id = BTreeMap::new();
|
||||
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? {
|
||||
if relay.status != RELAY_STATUS_ACTIVE {
|
||||
continue;
|
||||
/// Reconcile one activity into the ledger: build its line item (if any) and
|
||||
/// persist it with the activity's billed marker. Activities that produce no
|
||||
/// item (e.g. free-plan changes) are still marked billed so they aren't
|
||||
/// re-scanned.
|
||||
async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result<()> {
|
||||
let invoice_item = match activity.activity_type.as_str() {
|
||||
"create_relay" => {
|
||||
self.make_prorated_item(tenant, activity, 1, "New relay created")
|
||||
.await?
|
||||
}
|
||||
let Some(price_id) = self.query.get_plan(&relay.plan).and_then(|p| p.stripe_price_id) else {
|
||||
continue;
|
||||
};
|
||||
*quantity_by_price_id.entry(price_id).or_insert(0) += 1;
|
||||
}
|
||||
Ok(quantity_by_price_id)
|
||||
}
|
||||
|
||||
/// Fetch the tenant's current subscription from Stripe, if it has one
|
||||
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> {
|
||||
let subscription = match &tenant.stripe_subscription_id {
|
||||
Some(id) => self.stripe.get_subscription(id).await?,
|
||||
None => None,
|
||||
"activate_relay" => {
|
||||
self.make_prorated_item(tenant, activity, 1, "Relay reactivated")
|
||||
.await?
|
||||
}
|
||||
"deactivate_relay" => {
|
||||
self.make_prorated_item(tenant, activity, -1, "Relay deactivated (prorated credit)")
|
||||
.await?
|
||||
}
|
||||
"update_relay" => self.make_plan_change_item(tenant, activity).await?,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// If it's canceled, clear the subscription id and return nothing for simplicity
|
||||
if subscription
|
||||
.as_ref()
|
||||
.is_some_and(|s| matches!(s.status.as_str(), "canceled" | "incomplete_expired"))
|
||||
{
|
||||
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
|
||||
match invoice_item {
|
||||
Some(item) => command::insert_invoice_item_for_activity(&item, &activity.id).await,
|
||||
None => command::mark_activity_billed(&activity.id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// A prorated charge (or credit, with `sign` = -1) for the relay's current
|
||||
/// plan. `None` for a missing relay or a free plan. Mid-period items don't
|
||||
/// stamp `period_start` — the renewal decides coverage from activity history.
|
||||
async fn make_prorated_item(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
activity: &Activity,
|
||||
sign: i64,
|
||||
description: &str,
|
||||
) -> Result<Option<InvoiceItem>> {
|
||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(plan) = query::get_plan(&relay.plan) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if plan.amount <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(subscription)
|
||||
let anchor = tenant
|
||||
.billing_anchor
|
||||
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
|
||||
let fraction = period_fraction_remaining(anchor, activity.created_at);
|
||||
let amount = sign * prorate(plan.amount, fraction);
|
||||
|
||||
Ok(Some(line_item(activity, &relay.id, plan.id, amount, description)))
|
||||
}
|
||||
|
||||
/// Make sure the tenant has an active subscription, creating one with the desired
|
||||
/// items if it doesn't (Stripe rejects an itemless subscription).
|
||||
async fn ensure_subscription_is_active(
|
||||
/// The prorated delta for a plan change, read straight from the activity log:
|
||||
/// `new` is this `update_relay` activity's recorded plan, `old` is the relay's
|
||||
/// plan immediately before it. Because the renewal charges the relay's plan as
|
||||
/// of the period boundary, this delta composes to the correct total regardless
|
||||
/// of ordering and needs no coverage gate. `None` when nothing changed.
|
||||
async fn make_plan_change_item(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
quantity_by_price_id: &BTreeMap<String, i64>,
|
||||
) -> Result<StripeSubscription> {
|
||||
if let Some(sub) = self.get_subscription(tenant).await? {
|
||||
return Ok(sub);
|
||||
activity: &Activity,
|
||||
) -> Result<Option<InvoiceItem>> {
|
||||
let Some(new_plan_id) = activity.plan_id.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(old_plan_id) =
|
||||
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if old_plan_id == new_plan_id {
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(new_plan), Some(old_plan)) =
|
||||
(query::get_plan(new_plan_id), query::get_plan(&old_plan_id))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let anchor = tenant
|
||||
.billing_anchor
|
||||
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
|
||||
let fraction = period_fraction_remaining(anchor, activity.created_at);
|
||||
let amount = prorate(new_plan.amount, fraction) - prorate(old_plan.amount, fraction);
|
||||
if amount == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let sub = self
|
||||
.stripe
|
||||
.create_subscription(&tenant.stripe_customer_id, quantity_by_price_id)
|
||||
.await?;
|
||||
self.command.set_tenant_subscription(&tenant.pubkey, &sub.id).await?;
|
||||
Ok(sub)
|
||||
let description = format!("Plan changed from {} to {}", old_plan.name, new_plan.name);
|
||||
Ok(Some(line_item(
|
||||
activity,
|
||||
&activity.resource_id,
|
||||
new_plan.id,
|
||||
amount,
|
||||
&description,
|
||||
)))
|
||||
}
|
||||
|
||||
/// If the tenant has a subscription, cancel and clear it
|
||||
async fn ensure_subscription_is_inactive(&self, tenant: &Tenant) -> Result<()> {
|
||||
if let Some(s) = self.get_subscription(tenant).await? {
|
||||
self.stripe.cancel_subscription(&s.id).await?;
|
||||
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
|
||||
/// Reconcile pending activity, add this period's renewals for any relay due,
|
||||
/// and claim everything outstanding onto an invoice. Shared by the poll and
|
||||
/// the on-demand invoice endpoint — safe to call either way: renewals are
|
||||
/// per-relay idempotent. No payment is attempted here; callers that want
|
||||
/// auto-pay do it on the returned invoice. `None` when nothing is owed.
|
||||
pub async fn generate_invoice(&self, tenant: &Tenant) -> Result<Option<Invoice>> {
|
||||
self.reconcile_subscription(tenant).await?;
|
||||
|
||||
// reconcile may have just set the anchor (first activity); re-read it.
|
||||
let Some(tenant) = query::get_tenant(&tenant.pubkey).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(anchor) = tenant.billing_anchor else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let period_start = period_start_at(anchor, now);
|
||||
let period_end = add_one_month(period_start);
|
||||
|
||||
// Short-circuit the renewal scan once this period is already renewed — the
|
||||
// common case on all but the first poll of a period (saving ~720 scans a
|
||||
// month per tenant). renew_tenant re-checks this in-tx as the real guard.
|
||||
if tenant.renewed_at.is_none_or(|at| at < period_start) {
|
||||
self.renew_period(&tenant, period_start).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
self.claim_outstanding(&tenant, period_start, period_end).await
|
||||
}
|
||||
|
||||
/// Sync desired quantity_by_price_id with stripe
|
||||
async fn ensure_subscription_items(
|
||||
/// Charge a full-period renewal for every relay that was active on a paid plan
|
||||
/// as of `period_start`, reconstructing that state from the activity log
|
||||
/// (status from create/activate/deactivate, plan from create/update). Per-relay
|
||||
/// idempotent via `period_start`, so calling it on every generation can't
|
||||
/// renew a relay twice; a relay created/activated *within* the period isn't
|
||||
/// active before the boundary, so it's covered by its own prorated charge.
|
||||
async fn renew_period(&self, tenant: &Tenant, period_start: i64) -> Result<()> {
|
||||
let activities = query::list_relay_activity_before(&tenant.pubkey, period_start).await?;
|
||||
|
||||
let mut renewal_items = Vec::new();
|
||||
for (relay_id, state) in relay_states(&activities) {
|
||||
if !state.active {
|
||||
continue;
|
||||
}
|
||||
let Some(plan) = state.plan.and_then(|id| query::get_plan(&id)) else {
|
||||
continue;
|
||||
};
|
||||
if plan.amount <= 0 {
|
||||
continue;
|
||||
}
|
||||
renewal_items.push(InvoiceItem {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
invoice_id: None,
|
||||
activity_id: None,
|
||||
tenant_pubkey: tenant.pubkey.clone(),
|
||||
relay_id,
|
||||
plan: plan.id,
|
||||
amount: plan.amount,
|
||||
description: "Subscription renewal".to_string(),
|
||||
created_at: period_start,
|
||||
});
|
||||
}
|
||||
|
||||
// Inserts the items and advances `renewed_at` to `period_start` in one
|
||||
// transaction (idempotent via an in-tx guard), so a re-tick is a no-op.
|
||||
command::renew_tenant(&tenant.pubkey, period_start, &renewal_items).await
|
||||
}
|
||||
|
||||
/// Claim the tenant's outstanding items onto a fresh invoice if they net
|
||||
/// positive; `None` when nothing is owed (a net credit stays outstanding and
|
||||
/// carries to the next positive invoice).
|
||||
async fn claim_outstanding(
|
||||
&self,
|
||||
subscription: StripeSubscription,
|
||||
quantity_by_price_id: BTreeMap<String, i64>,
|
||||
) -> Result<()> {
|
||||
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
|
||||
for item in subscription.items {
|
||||
current.insert(item.price.id, (item.id, item.quantity));
|
||||
}
|
||||
tenant: &Tenant,
|
||||
period_start: i64,
|
||||
period_end: i64,
|
||||
) -> Result<Option<Invoice>> {
|
||||
let invoice_id = uuid::Uuid::new_v4().to_string();
|
||||
command::claim_outstanding_into_invoice(
|
||||
&invoice_id,
|
||||
&tenant.pubkey,
|
||||
period_start,
|
||||
period_end,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
for (price_id, &quantity) in &quantity_by_price_id {
|
||||
if let Some((item_id, current_quantity)) = current.remove(price_id) {
|
||||
if current_quantity != quantity {
|
||||
self.stripe
|
||||
.set_subscription_item_quantity(&item_id, quantity)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
self.stripe
|
||||
.create_subscription_item(&subscription.id, price_id, quantity)
|
||||
.await?;
|
||||
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||
let mut error_message: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match self.attempt_payment_using_nwc(tenant, invoice).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => error_message = Some(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
for (_, (item_id, _)) in current {
|
||||
self.stripe.delete_subscription_item(&item_id).await?;
|
||||
// 2. Payment method on file: if the tenant has one saved, charge it via Stripe.
|
||||
if let Some(payment_method) =
|
||||
self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
|
||||
{
|
||||
match self
|
||||
.attempt_payment_using_stripe(tenant, invoice, &payment_method)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => error_message = Some(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||
let summary = error_message.as_deref().and_then(summarize_error_message);
|
||||
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
error = %e,
|
||||
"failed to send manual payment DM"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Invoices ---
|
||||
|
||||
/// return or generate a lightning invoice for an open stripe invoice
|
||||
pub async fn ensure_lightning_invoice(
|
||||
&self,
|
||||
stripe_invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
amount_due: i64,
|
||||
currency: &str,
|
||||
) -> Result<LightningInvoice> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await?
|
||||
&& (existing.status != "pending" || now < existing.expires_at)
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let expiry: i64 = 3600;
|
||||
let info = "Relay subscription payment";
|
||||
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
|
||||
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
|
||||
|
||||
let invoice = match self
|
||||
.command
|
||||
.insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
|
||||
.await?
|
||||
{
|
||||
Some(invoice) => invoice,
|
||||
None => self
|
||||
.query
|
||||
.get_lightning_invoice(stripe_invoice_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
|
||||
};
|
||||
|
||||
Ok(invoice)
|
||||
}
|
||||
|
||||
/// Attempt to pay and settle an invoice via nwc
|
||||
pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
|
||||
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
|
||||
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
|
||||
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
||||
let bolt11 = self.ensure_bolt11(&invoice.id).await?;
|
||||
|
||||
match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await {
|
||||
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
|
||||
match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await {
|
||||
Ok(()) => {
|
||||
command::clear_tenant_nwc_error(&tenant.pubkey).await?;
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "nwc").await
|
||||
}
|
||||
Err(pay_error) => {
|
||||
// The pay request errored, but the payment may have landed
|
||||
// before the response was lost. Confirm against the system
|
||||
// wallet before reporting failure.
|
||||
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
|
||||
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
|
||||
// The pay request errored, but the invoice may have been paid out of band.
|
||||
if self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) {
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "oob").await
|
||||
} else {
|
||||
Err(pay_error)
|
||||
}
|
||||
@@ -282,68 +409,227 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
async fn attempt_payment_using_stripe(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
invoice: &Invoice,
|
||||
payment_method_id: &str,
|
||||
) -> Result<()> {
|
||||
let amount = self.get_invoice_amount(&invoice.id).await?;
|
||||
|
||||
// A decline or an off-session authentication demand comes back as Err, so
|
||||
// the cascade falls back to the manual DM.
|
||||
let intent_id = self
|
||||
.stripe
|
||||
.create_payment_intent(
|
||||
&tenant.stripe_customer_id,
|
||||
payment_method_id,
|
||||
&invoice.id,
|
||||
amount,
|
||||
"usd",
|
||||
)
|
||||
.await?;
|
||||
|
||||
command::insert_intent(&intent_id, &invoice.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "stripe").await
|
||||
}
|
||||
|
||||
async fn attempt_payment_using_dm(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
invoice: &Invoice,
|
||||
error_message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let invoice_id = &invoice.id;
|
||||
let url_base = &env::get().app_url;
|
||||
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
|
||||
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
|
||||
let dm_message = match error_message {
|
||||
Some(error_message) if !error_message.is_empty() => {
|
||||
format!("{base}\n\n{USER_ERROR_PREFIX} {error_message}")
|
||||
}
|
||||
_ => base,
|
||||
};
|
||||
|
||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await
|
||||
}
|
||||
|
||||
// --- Invoice utils ---
|
||||
|
||||
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
|
||||
let invoice_items = query::get_invoice_items_for_invoice(invoice_id).await?;
|
||||
|
||||
Ok(invoice_items.iter().map(|item| item.amount).sum())
|
||||
}
|
||||
|
||||
// --- Bolt11 utils ---
|
||||
|
||||
pub async fn ensure_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
if let Some(existing) = query::get_bolt11_for_invoice(invoice_id).await?
|
||||
&& (existing.settled_at.is_none() || now < existing.expires_at)
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let expiry: i64 = 3600;
|
||||
let info = "Relay subscription payment";
|
||||
let amount = self.get_invoice_amount(invoice_id).await?;
|
||||
let msats = bitcoin::fiat_to_msats(amount, "usd").await?;
|
||||
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?;
|
||||
|
||||
command::insert_bolt11(invoice_id, &lnbc, msats as i64, now + expiry)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
||||
}
|
||||
|
||||
/// Catch an out-of-band payment we never recorded — e.g. the user paid the
|
||||
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has
|
||||
/// settled on the robot wallet, mark it paid and return the refreshed Stripe
|
||||
/// invoice; otherwise return it unchanged. Meant to run before presenting a
|
||||
/// payable invoice so we never hand back one that's already been paid.
|
||||
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
|
||||
if invoice.status != "open" {
|
||||
return Ok(invoice.clone());
|
||||
/// settled on the robot wallet, mark it paid and return the refreshed record;
|
||||
/// otherwise return it unchanged. Meant to run before presenting a payable
|
||||
/// invoice so we never hand back one that's already been paid.
|
||||
pub async fn ensure_and_reconcile_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
|
||||
let bolt11 = self.ensure_bolt11(invoice_id).await?;
|
||||
|
||||
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
|
||||
// Re-fetch so the caller sees that it's been settled.
|
||||
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
|
||||
} else {
|
||||
Ok(bolt11)
|
||||
}
|
||||
|
||||
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
|
||||
return Ok(invoice.clone());
|
||||
};
|
||||
|
||||
let settled = match self.wallet.is_settled(&row.bolt11).await {
|
||||
Ok(settled) => settled,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
stripe_invoice_id = %invoice.id,
|
||||
"failed to look up bolt11 invoice settlement"
|
||||
);
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
};
|
||||
|
||||
if !settled {
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
|
||||
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
stripe_invoice_id = %invoice.id,
|
||||
"failed to record settled bolt11 invoice as paid"
|
||||
);
|
||||
}
|
||||
|
||||
// Re-fetch so the caller sees the now-paid status; fall back to the
|
||||
// pre-reconcile snapshot if Stripe momentarily 404s.
|
||||
Ok(self
|
||||
.stripe
|
||||
.get_invoice(&invoice.id)
|
||||
.await?
|
||||
.unwrap_or_else(|| invoice.clone()))
|
||||
}
|
||||
|
||||
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
|
||||
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
|
||||
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
|
||||
/// first-writer-wins, so the recorded method reflects whoever settled first.
|
||||
async fn settle_invoice(
|
||||
&self,
|
||||
stripe_invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
method: &str,
|
||||
) -> Result<()> {
|
||||
self.command
|
||||
.mark_lightning_invoice_paid(stripe_invoice_id, method)
|
||||
.await?;
|
||||
self.stripe.pay_invoice_out_of_band(stripe_invoice_id).await?;
|
||||
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
|
||||
const USER_ERROR_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const USER_ERROR_MAX_CHARS: usize = 240;
|
||||
|
||||
/// The start of the billing period containing `now`, for monthly periods
|
||||
/// anchored at `anchor`. Steps forward in whole calendar months so boundaries
|
||||
/// track months (28–31 days) rather than a fixed span of seconds.
|
||||
fn period_start_at(anchor: i64, now: i64) -> i64 {
|
||||
use chrono::{DateTime, Months, Utc};
|
||||
|
||||
let anchor_dt = DateTime::<Utc>::from_timestamp(anchor, 0).unwrap_or_default();
|
||||
|
||||
let mut start = anchor_dt;
|
||||
let mut months = 1u32;
|
||||
while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) {
|
||||
if next.timestamp() > now {
|
||||
break;
|
||||
}
|
||||
start = next;
|
||||
months += 1;
|
||||
}
|
||||
|
||||
start.timestamp()
|
||||
}
|
||||
|
||||
/// One calendar month after `ts` (a unix timestamp), falling back to `ts` if the
|
||||
/// shifted date can't be represented.
|
||||
fn add_one_month(ts: i64) -> i64 {
|
||||
use chrono::{DateTime, Months, Utc};
|
||||
|
||||
DateTime::<Utc>::from_timestamp(ts, 0)
|
||||
.and_then(|dt| dt.checked_add_months(Months::new(1)))
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(ts)
|
||||
}
|
||||
|
||||
/// Fraction of the current billing period still unused at `at`, in `[0.0, 1.0]`,
|
||||
/// for prorating a mid-period charge or credit. With no billing anchor yet the
|
||||
/// period is only just beginning, so the whole period remains (full price).
|
||||
fn period_fraction_remaining(billing_anchor: i64, at: i64) -> f64 {
|
||||
let period_start = period_start_at(billing_anchor, at);
|
||||
let period_end = add_one_month(period_start);
|
||||
let period_len = (period_end - period_start) as f64;
|
||||
if period_len <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
(((period_end - at) as f64) / period_len).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Prorate a minor-unit `amount` by `fraction`, rounded to the nearest unit.
|
||||
fn prorate(amount: i64, fraction: f64) -> i64 {
|
||||
(amount as f64 * fraction).round() as i64
|
||||
}
|
||||
|
||||
/// Build an outstanding (unassigned, `invoice_id = None`) line item from a
|
||||
/// reconciled activity. `period_start` is `Some` only for coverage charges
|
||||
/// (creation/activation), which mark the relay-period as paid.
|
||||
fn line_item(
|
||||
activity: &Activity,
|
||||
relay_id: &str,
|
||||
plan: String,
|
||||
amount: i64,
|
||||
description: &str,
|
||||
) -> InvoiceItem {
|
||||
InvoiceItem {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
invoice_id: None,
|
||||
activity_id: Some(activity.id.clone()),
|
||||
tenant_pubkey: activity.tenant.clone(),
|
||||
relay_id: relay_id.to_string(),
|
||||
plan,
|
||||
amount,
|
||||
description: description.to_string(),
|
||||
created_at: activity.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay's billing-relevant state at a point in time, reconstructed by folding
|
||||
/// its activity log.
|
||||
#[derive(Default)]
|
||||
struct RelayState {
|
||||
active: bool,
|
||||
plan: Option<String>,
|
||||
}
|
||||
|
||||
/// Fold relay activities (which must be oldest-first) into each relay's
|
||||
/// `(active, plan)` state. `create`/`activate`/`deactivate` drive status;
|
||||
/// `create`/`update` carry the plan via `plan_id`. Feed it activities up to a
|
||||
/// cutoff to get each relay's state as of that moment (e.g. the period boundary).
|
||||
fn relay_states(activities: &[Activity]) -> HashMap<String, RelayState> {
|
||||
let mut states: HashMap<String, RelayState> = HashMap::new();
|
||||
|
||||
for activity in activities {
|
||||
let state = states.entry(activity.resource_id.clone()).or_default();
|
||||
match activity.activity_type.as_str() {
|
||||
"create_relay" => {
|
||||
state.active = true;
|
||||
state.plan = activity.plan_id.clone();
|
||||
}
|
||||
"update_relay" => {
|
||||
if activity.plan_id.is_some() {
|
||||
state.plan = activity.plan_id.clone();
|
||||
}
|
||||
}
|
||||
"activate_relay" => state.active = true,
|
||||
"deactivate_relay" => state.active = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
states
|
||||
}
|
||||
|
||||
fn summarize_error_message(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= USER_ERROR_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = USER_ERROR_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
+453
-337
@@ -1,355 +1,471 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{Sqlite, SqlitePool, Transaction};
|
||||
use tokio::sync::broadcast;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
|
||||
use crate::db::{pool, publish, with_tx};
|
||||
use crate::models::{
|
||||
Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||
RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Command {
|
||||
pool: SqlitePool,
|
||||
pub notify: broadcast::Sender<Activity>,
|
||||
// --- Tenants ---
|
||||
|
||||
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&tenant.pubkey)
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(tenant.created_at)
|
||||
.bind(&tenant.stripe_customer_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
let (notify, _) = broadcast::channel(64);
|
||||
Self { pool, notify }
|
||||
}
|
||||
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Activity
|
||||
pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
|
||||
.bind(tenant.billing_anchor)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_activity(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_type: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<Activity> {
|
||||
let tenant = match resource_type {
|
||||
"tenant" => resource_id.to_string(),
|
||||
"relay" => {
|
||||
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
|
||||
.bind(resource_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
};
|
||||
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
// --- Relays ---
|
||||
|
||||
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO relay (
|
||||
id, tenant, subdomain, plan, status, synced, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled
|
||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&relay.id)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.sync_error)
|
||||
.bind(&relay.info_name)
|
||||
.bind(&relay.info_icon)
|
||||
.bind(&relay.info_description)
|
||||
.bind(relay.policy_public_join)
|
||||
.bind(relay.policy_strip_signatures)
|
||||
.bind(relay.groups_enabled)
|
||||
.bind(relay.management_enabled)
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "create_relay", "relay", &relay.id, Some(&relay.plan)).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query(
|
||||
"UPDATE relay
|
||||
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
|
||||
info_name = ?, info_icon = ?, info_description = ?,
|
||||
policy_public_join = ?, policy_strip_signatures = ?,
|
||||
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
||||
livekit_enabled = ?, push_enabled = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.status)
|
||||
.bind(&relay.sync_error)
|
||||
.bind(&relay.info_name)
|
||||
.bind(&relay.info_icon)
|
||||
.bind(&relay.info_description)
|
||||
.bind(relay.policy_public_join)
|
||||
.bind(relay.policy_strip_signatures)
|
||||
.bind(relay.groups_enabled)
|
||||
.bind(relay.management_enabled)
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "update_relay", "relay", &relay.id, Some(&relay.plan)).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay").await
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
|
||||
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
||||
}
|
||||
|
||||
pub async fn activate_relay(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
|
||||
}
|
||||
|
||||
async fn set_relay_status(relay_id: &str, status: &str, activity_type: &str) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, activity_type, "relay", relay_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
|
||||
.bind(&sync_error)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Invoice items (the outstanding-charge ledger) ---
|
||||
|
||||
pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activity_id: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
with_tx(async |tx| {
|
||||
insert_invoice_item_tx(tx, invoice_item).await?;
|
||||
mark_activity_billed_tx(tx, activity_id, now).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark an activity billed without a line item — for activities that produce no
|
||||
/// charge (e.g. free-plan changes), so a recovery pass doesn't re-scan them.
|
||||
pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
with_tx(async |tx| mark_activity_billed_tx(tx, activity_id, now).await).await
|
||||
}
|
||||
|
||||
/// Insert renewal line items, skipping any relay already covered for the item's
|
||||
/// `period_start`. The per-relay existence check and insert are a single
|
||||
/// statement, so neither a re-tick nor a relay's own creation/activation charge
|
||||
/// (which also stamps `period_start`) can bill the same relay-period twice.
|
||||
pub async fn renew_tenant(
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
items: &[InvoiceItem],
|
||||
) -> Result<()> {
|
||||
with_tx(async |tx| {
|
||||
// In-tx guard: bail if this tenant has already been renewed for this
|
||||
// period (or later). This is the correctness backstop — it keeps renewal
|
||||
// idempotent under a crash mid-renewal or a poll racing the eager
|
||||
// endpoint, since the item inserts and the `renewed_at` write commit
|
||||
// together.
|
||||
let renewed_at = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT renewed_at FROM tenant WHERE pubkey = ?",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if renewed_at.is_some_and(|at| at >= period_start) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for item in items {
|
||||
insert_invoice_item_tx(tx, item).await?;
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?")
|
||||
.bind(period_start)
|
||||
.bind(tenant_pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Invoices ---
|
||||
|
||||
/// Claim all of a tenant's outstanding items onto a new invoice — but only if
|
||||
/// they sum to a positive amount. A non-positive balance (net credit or nothing
|
||||
/// owed) leaves the items outstanding so the credit carries to the next positive
|
||||
/// invoice. The sum, insert, and claim run in one transaction. Returns the
|
||||
/// invoice, or `None` when there's nothing to bill.
|
||||
pub async fn claim_outstanding_into_invoice(
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
period_end: i64,
|
||||
) -> Result<Option<Invoice>> {
|
||||
with_tx(async |tx| {
|
||||
let total = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
|
||||
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if total <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let invoice =
|
||||
insert_invoice_tx(tx, invoice_id, tenant_pubkey, period_start, period_end).await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"UPDATE invoice_item SET invoice_id = ?
|
||||
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&tenant)
|
||||
.bind(created_at)
|
||||
.bind(activity_type)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.bind(invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Activity {
|
||||
id,
|
||||
tenant,
|
||||
created_at,
|
||||
activity_type: activity_type.to_string(),
|
||||
resource_type: resource_type.to_string(),
|
||||
resource_id: resource_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` inside a transaction, record an activity row, commit, and broadcast.
|
||||
async fn with_activity<F>(
|
||||
&self,
|
||||
activity_type: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
f: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<()>,
|
||||
{
|
||||
let mut tx = self.pool.begin().await?;
|
||||
f(&mut tx).await?;
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, activity_type, resource_type, resource_id).await?;
|
||||
tx.commit().await?;
|
||||
let _ = self.notify.send(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Tenants
|
||||
|
||||
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||
self.with_activity("create_tenant", "tenant", &tenant.pubkey, async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&tenant.pubkey)
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(tenant.created_at)
|
||||
.bind(&tenant.stripe_customer_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||
self.with_activity("update_tenant", "tenant", &tenant.pubkey, async |tx| {
|
||||
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_tenant_subscription(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
stripe_subscription_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
|
||||
.bind(stripe_subscription_id)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_subscription_id = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
|
||||
.bind(error)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
|
||||
.bind(now)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET past_due_at = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Relays
|
||||
|
||||
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.with_activity("create_relay", "relay", &relay.id, async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO relay (
|
||||
id, tenant, subdomain, plan, status, synced, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled
|
||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&relay.id)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.sync_error)
|
||||
.bind(&relay.info_name)
|
||||
.bind(&relay.info_icon)
|
||||
.bind(&relay.info_description)
|
||||
.bind(relay.policy_public_join)
|
||||
.bind(relay.policy_strip_signatures)
|
||||
.bind(relay.groups_enabled)
|
||||
.bind(relay.management_enabled)
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.with_activity("update_relay", "relay", &relay.id, async |tx| {
|
||||
sqlx::query(
|
||||
"UPDATE relay
|
||||
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
|
||||
info_name = ?, info_icon = ?, info_description = ?,
|
||||
policy_public_join = ?, policy_strip_signatures = ?,
|
||||
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
||||
livekit_enabled = ?, push_enabled = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.status)
|
||||
.bind(&relay.sync_error)
|
||||
.bind(&relay.info_name)
|
||||
.bind(&relay.info_icon)
|
||||
.bind(&relay.info_description)
|
||||
.bind(relay.policy_public_join)
|
||||
.bind(relay.policy_strip_signatures)
|
||||
.bind(relay.groups_enabled)
|
||||
.bind(relay.management_enabled)
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_relay_status(
|
||||
&self,
|
||||
relay_id: &str,
|
||||
status: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<()> {
|
||||
self.with_activity(activity_type, "relay", relay_id, async |tx| {
|
||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
|
||||
self.with_activity("fail_relay_sync", "relay", &relay.id, async |tx| {
|
||||
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
|
||||
.bind(&sync_error)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||
self.with_activity("complete_relay_sync", "relay", relay_id, async |tx| {
|
||||
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// Invoices
|
||||
|
||||
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On
|
||||
/// conflict the stored bolt11/expiry are replaced — this is how an expired
|
||||
/// invoice is regenerated — except once the invoice is paid, when the
|
||||
/// `status = 'pending'` guard makes the update a no-op and `None` is
|
||||
/// returned so the caller can fall back to reading the settled row.
|
||||
pub async fn insert_lightning_invoice(
|
||||
&self,
|
||||
stripe_invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
bolt11: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<Option<LightningInvoice>> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||
"INSERT INTO lightning_invoice
|
||||
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
||||
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
|
||||
bolt11 = excluded.bolt11,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE status = 'pending'
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(stripe_invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.bind(bolt11)
|
||||
.bind(expires_at)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Mark a pending invoice paid, recording which method settled it. The
|
||||
/// `status = 'pending'` guard makes this idempotent and first-writer-wins:
|
||||
/// a later reconcile won't clobber the method recorded by whoever settled
|
||||
/// it first.
|
||||
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
sqlx::query(
|
||||
"UPDATE lightning_invoice
|
||||
SET status = 'paid', paid_method = ?, updated_at = ?
|
||||
WHERE stripe_invoice_id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(method)
|
||||
.bind(now)
|
||||
.bind(stripe_invoice_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Ok(Some(invoice))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
||||
let updated_at = chrono::Utc::now().timestamp();
|
||||
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(method)
|
||||
.bind(updated_at)
|
||||
.bind(invoice_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Bolt11 records ---
|
||||
|
||||
pub async fn insert_bolt11(
|
||||
invoice_id: &str,
|
||||
lnbc: &str,
|
||||
msats: i64,
|
||||
expires_at: i64,
|
||||
) -> Result<Option<Bolt11>> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
Ok(sqlx::query_as::<_, Bolt11>(
|
||||
"INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(invoice_id)
|
||||
.bind(lnbc)
|
||||
.bind(msats)
|
||||
.bind(created_at)
|
||||
.bind(expires_at)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
|
||||
let settled_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
|
||||
.bind(settled_at)
|
||||
.bind(bolt11_id)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
|
||||
/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
|
||||
/// the same id and the re-insert is a no-op rather than a primary-key conflict.
|
||||
pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO intent (id, invoice_id, created_at)
|
||||
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
|
||||
)
|
||||
.bind(intent_id)
|
||||
.bind(invoice_id)
|
||||
.bind(created_at)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Internal utils that take an explicit transaction ---
|
||||
|
||||
async fn insert_activity_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_type: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
plan_id: Option<&str>,
|
||||
) -> Result<Activity> {
|
||||
let tenant = match resource_type {
|
||||
"tenant" => resource_id.to_string(),
|
||||
"relay" => {
|
||||
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
|
||||
.bind(resource_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id, plan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&tenant)
|
||||
.bind(created_at)
|
||||
.bind(activity_type)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.bind(plan_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Activity {
|
||||
id,
|
||||
tenant,
|
||||
created_at,
|
||||
activity_type: activity_type.to_string(),
|
||||
resource_type: resource_type.to_string(),
|
||||
resource_id: resource_id.to_string(),
|
||||
billed_at: None,
|
||||
plan_id: plan_id.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
async fn insert_invoice_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
period_end: i64,
|
||||
) -> Result<Invoice> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.bind(period_start)
|
||||
.bind(period_end)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &InvoiceItem) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO invoice_item
|
||||
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&item.id)
|
||||
.bind(&item.invoice_id)
|
||||
.bind(&item.activity_id)
|
||||
.bind(&item.tenant_pubkey)
|
||||
.bind(&item.relay_id)
|
||||
.bind(&item.plan)
|
||||
.bind(item.amount)
|
||||
.bind(&item.description)
|
||||
.bind(item.created_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_activity_billed_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_id: &str,
|
||||
billed_at: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ?")
|
||||
.bind(billed_at)
|
||||
.bind(activity_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{
|
||||
Sqlite, SqlitePool, Transaction,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::env;
|
||||
use crate::models::Activity;
|
||||
|
||||
/// Process-wide connection pool. Set once at startup via [`init`]; read
|
||||
/// everywhere else via [`pool`], so command/query stay free functions instead of
|
||||
/// threading a handle through every service.
|
||||
static POOL: OnceLock<SqlitePool> = OnceLock::new();
|
||||
|
||||
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
|
||||
/// [`publish`] after their transaction commits; reactors (billing, infra)
|
||||
/// [`subscribe`] to react to durable changes.
|
||||
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
|
||||
|
||||
/// Create the connection pool from `env`, run migrations, and store it as the
|
||||
/// process-wide global. Panics if called more than once.
|
||||
pub async fn init() -> Result<()> {
|
||||
let pool = create_pool(&env::get().database_url).await?;
|
||||
POOL.set(pool).expect("pool already initialized");
|
||||
|
||||
let (notify, _) = broadcast::channel(64);
|
||||
NOTIFY.set(notify).expect("notify already initialized");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The global pool. Panics if [`init`] hasn't run yet.
|
||||
pub fn pool() -> &'static SqlitePool {
|
||||
POOL.get().expect("pool not initialized")
|
||||
}
|
||||
|
||||
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
|
||||
pub fn subscribe() -> broadcast::Receiver<Activity> {
|
||||
NOTIFY.get().expect("notify not initialized").subscribe()
|
||||
}
|
||||
|
||||
/// Broadcast an activity to subscribers. Called after the writing transaction
|
||||
/// commits, so reactors only ever observe durable rows. A send with no current
|
||||
/// subscribers is intentionally ignored.
|
||||
pub fn publish(activity: Activity) {
|
||||
if let Some(notify) = NOTIFY.get() {
|
||||
let _ = notify.send(activity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
|
||||
/// it returns an error. Returns whatever `f` produces. Callers compose the
|
||||
/// transaction-scoped `command`/`query` functions inside `f` to make a
|
||||
/// multi-step write atomic.
|
||||
pub async fn with_tx<F, T>(f: F) -> Result<T>
|
||||
where
|
||||
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
|
||||
{
|
||||
let mut tx = pool().begin().await?;
|
||||
let value = f(&mut tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
|
||||
let database_url = normalize_sqlite_url(database_url);
|
||||
|
||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||
&& !path.is_empty()
|
||||
&& path != ":memory:"
|
||||
&& let Some(parent) = Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode = WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn normalize_sqlite_url(url: &str) -> String {
|
||||
let Some(path) = url.strip_prefix("sqlite://") else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
|
||||
}
|
||||
+19
-7
@@ -1,6 +1,24 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Process-wide configuration, loaded once from the environment at startup via
|
||||
/// [`init`] and read everywhere else via [`get`].
|
||||
static ENV: OnceLock<Env> = OnceLock::new();
|
||||
|
||||
/// Load configuration from the environment and store it as the global. Panics
|
||||
/// if a required variable is missing or if called more than once.
|
||||
pub fn init() {
|
||||
ENV.set(Env::load())
|
||||
.unwrap_or_else(|_| panic!("env already initialized"));
|
||||
}
|
||||
|
||||
/// The global configuration. Panics if [`init`] hasn't run yet.
|
||||
pub fn get() -> &'static Env {
|
||||
ENV.get().expect("env not initialized")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Env {
|
||||
pub server_host: String,
|
||||
@@ -27,15 +45,12 @@ pub struct Env {
|
||||
pub livekit_api_key: String,
|
||||
pub livekit_api_secret: String,
|
||||
pub stripe_secret_key: String,
|
||||
pub stripe_webhook_secret: String,
|
||||
pub stripe_price_basic: String,
|
||||
pub stripe_price_growth: String,
|
||||
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
|
||||
pub keys: Keys,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
pub fn load() -> Self {
|
||||
fn load() -> Self {
|
||||
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
|
||||
.expect("ROBOT_SECRET is not a valid nostr secret key");
|
||||
|
||||
@@ -64,9 +79,6 @@ impl Env {
|
||||
livekit_api_key: require_str("LIVEKIT_API_KEY"),
|
||||
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
|
||||
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
|
||||
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
|
||||
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
|
||||
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
+26
-35
@@ -2,33 +2,26 @@ use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::env::Env;
|
||||
use crate::command;
|
||||
use crate::db;
|
||||
use crate::env;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::query::Query;
|
||||
use crate::query;
|
||||
|
||||
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
|
||||
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Infra {
|
||||
env: Env,
|
||||
query: Query,
|
||||
command: Command,
|
||||
}
|
||||
pub struct Infra;
|
||||
|
||||
impl Infra {
|
||||
pub fn new(query: Query, command: Command, env: &Env) -> Self {
|
||||
Self {
|
||||
env: env.clone(),
|
||||
query,
|
||||
command,
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub async fn start(self) {
|
||||
let mut rx = self.command.notify.subscribe();
|
||||
let mut rx = db::subscribe();
|
||||
|
||||
if let Err(error) = self.reconcile_relay_state("startup").await {
|
||||
tracing::error!(error = %error, "failed to reconcile relay state on startup");
|
||||
@@ -68,7 +61,7 @@ impl Infra {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -77,7 +70,7 @@ impl Infra {
|
||||
}
|
||||
|
||||
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
|
||||
let relays = self.query.list_relays_pending_sync().await?;
|
||||
let relays = query::list_relays_pending_sync().await?;
|
||||
|
||||
if relays.is_empty() {
|
||||
return Ok(());
|
||||
@@ -112,7 +105,7 @@ impl Infra {
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
let activities = self.query.list_activity_for_resource(relay_id).await?;
|
||||
let activities = query::list_activity_for_resource(relay_id).await?;
|
||||
let consecutive_failures = activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
@@ -142,7 +135,7 @@ impl Infra {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
match infra.query.get_relay(&relay_id).await {
|
||||
match query::get_relay(&relay_id).await {
|
||||
Ok(Some(relay)) => infra.sync_relay(&relay).await,
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
@@ -158,13 +151,13 @@ impl Infra {
|
||||
match self.try_sync_relay(relay).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
|
||||
if let Err(e) = command::complete_relay_sync(&relay.id).await {
|
||||
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
|
||||
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await {
|
||||
if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
|
||||
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
|
||||
}
|
||||
}
|
||||
@@ -177,14 +170,12 @@ impl Infra {
|
||||
// otherwise check the activity history so that a re-sync after an update
|
||||
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
|
||||
let is_new = relay.synced != 1
|
||||
&& self
|
||||
.query
|
||||
.get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
|
||||
&& query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
|
||||
.await?
|
||||
.is_none();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain),
|
||||
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
|
||||
"schema": relay.id,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
@@ -205,11 +196,11 @@ impl Infra {
|
||||
"enabled": true,
|
||||
"adapter": "s3",
|
||||
"s3": {
|
||||
"endpoint": self.env.blossom_s3_endpoint,
|
||||
"region": self.env.blossom_s3_region,
|
||||
"bucket": self.env.blossom_s3_bucket,
|
||||
"access_key": self.env.blossom_s3_access_key,
|
||||
"secret_key": self.env.blossom_s3_secret_key,
|
||||
"endpoint": env::get().blossom_s3_endpoint,
|
||||
"region": env::get().blossom_s3_region,
|
||||
"bucket": env::get().blossom_s3_bucket,
|
||||
"access_key": env::get().blossom_s3_access_key,
|
||||
"secret_key": env::get().blossom_s3_secret_key,
|
||||
"key_prefix": relay.id,
|
||||
},
|
||||
})
|
||||
@@ -219,9 +210,9 @@ impl Infra {
|
||||
"livekit": if relay.livekit_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_url": self.env.livekit_url,
|
||||
"api_key": self.env.livekit_api_key,
|
||||
"api_secret": self.env.livekit_api_secret,
|
||||
"server_url": env::get().livekit_url,
|
||||
"api_key": env::get().livekit_api_key,
|
||||
"api_secret": env::get().livekit_api_secret,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
@@ -274,10 +265,10 @@ impl Infra {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
let base = self.env.zooid_api_url.trim_end_matches('/');
|
||||
let base = env::get().zooid_api_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
let url = format!("{base}/{path}");
|
||||
let auth = self.env.make_auth(&url, method).await?;
|
||||
let auth = env::get().make_auth(&url, method).await?;
|
||||
|
||||
let reqwest_method = match method {
|
||||
HttpMethod::GET => reqwest::Method::GET,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ pub mod command;
|
||||
pub mod env;
|
||||
pub mod infra;
|
||||
pub mod models;
|
||||
pub mod pool;
|
||||
pub mod db;
|
||||
pub mod query;
|
||||
pub mod robot;
|
||||
pub mod routes;
|
||||
|
||||
+16
-15
@@ -5,7 +5,7 @@ mod command;
|
||||
mod env;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod pool;
|
||||
mod db;
|
||||
mod query;
|
||||
mod robot;
|
||||
mod routes;
|
||||
@@ -20,10 +20,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer, Any};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::billing::Billing;
|
||||
use crate::command::Command;
|
||||
use crate::env::Env;
|
||||
use crate::infra::Infra;
|
||||
use crate::query::Query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
|
||||
@@ -36,18 +33,17 @@ async fn main() -> Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let env = Env::load();
|
||||
env::init();
|
||||
|
||||
let pool = pool::create_pool(&env.database_url).await?;
|
||||
let robot = Robot::new(&env).await?;
|
||||
let stripe = Stripe::new(&env);
|
||||
let query = Query::new(pool.clone(), &env);
|
||||
let command = Command::new(pool);
|
||||
let billing = Billing::new(query.clone(), command.clone(), &env);
|
||||
let infra = Infra::new(query.clone(), command.clone(), &env);
|
||||
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env);
|
||||
db::init().await?;
|
||||
|
||||
let parsed = env
|
||||
let robot = Robot::new().await?;
|
||||
let stripe = Stripe::new();
|
||||
let infra = Infra::new();
|
||||
let billing = Billing::new(robot.clone());
|
||||
let api = Api::new(billing.clone(), stripe, robot, infra.clone());
|
||||
|
||||
let parsed = env::get()
|
||||
.server_allow_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
||||
@@ -68,7 +64,12 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
|
||||
tokio::net::TcpListener::bind(format!(
|
||||
"{}:{}",
|
||||
env::get().server_host,
|
||||
env::get().server_port
|
||||
))
|
||||
.await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+53
-15
@@ -12,6 +12,10 @@ pub struct Activity {
|
||||
pub activity_type: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: String,
|
||||
pub billed_at: Option<i64>,
|
||||
/// The relay's plan at the time of a `create_relay`/`update_relay` activity;
|
||||
/// `None` for all other activity types.
|
||||
pub plan_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -22,7 +26,6 @@ pub struct Plan {
|
||||
pub members: Option<i64>,
|
||||
pub blossom: bool,
|
||||
pub livekit: bool,
|
||||
pub stripe_price_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -31,21 +34,11 @@ pub struct Tenant {
|
||||
pub nwc_url: String,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
pub stripe_subscription_id: Option<String>,
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct LightningInvoice {
|
||||
pub stripe_invoice_id: String,
|
||||
pub tenant_pubkey: String,
|
||||
pub bolt11: String,
|
||||
pub status: String,
|
||||
pub paid_method: Option<String>,
|
||||
pub expires_at: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// `period_start` of the most recent period this tenant was renewed for, or
|
||||
/// `None` if never renewed. The per-period renewal idempotency marker.
|
||||
pub renewed_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -92,3 +85,48 @@ impl Default for Relay {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub tenant_pubkey: String,
|
||||
pub status: String,
|
||||
pub period_start: i64,
|
||||
pub period_end: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct InvoiceItem {
|
||||
pub id: String,
|
||||
/// `None` while outstanding; set once the item is claimed onto an invoice.
|
||||
pub invoice_id: Option<String>,
|
||||
/// `None` for renewal items, which have no source activity.
|
||||
pub activity_id: Option<String>,
|
||||
pub tenant_pubkey: String,
|
||||
pub relay_id: String,
|
||||
pub plan: String,
|
||||
pub amount: i64,
|
||||
pub description: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Bolt11 {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub lnbc: String,
|
||||
pub msats: i64,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub settled_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // backs the `intent` table for the (not yet implemented) Stripe intent flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Intent {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
|
||||
let database_url = normalize_sqlite_url(database_url);
|
||||
|
||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||
&& !path.is_empty()
|
||||
&& path != ":memory:"
|
||||
&& let Some(parent) = Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode = WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn normalize_sqlite_url(url: &str) -> String {
|
||||
let Some(path) = url.strip_prefix("sqlite://") else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
|
||||
}
|
||||
+214
-158
@@ -1,8 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::env::Env;
|
||||
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||
use crate::db::pool;
|
||||
|
||||
fn select_tenant(tail: &str) -> String {
|
||||
format!("SELECT * FROM tenant {tail}")
|
||||
@@ -16,161 +15,218 @@ fn select_activity(tail: &str) -> String {
|
||||
format!("SELECT * FROM activity {tail}")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Query {
|
||||
pool: SqlitePool,
|
||||
env: Env,
|
||||
// Plans
|
||||
|
||||
pub fn list_plans() -> Vec<Plan> {
|
||||
vec![
|
||||
Plan {
|
||||
id: "free".to_string(),
|
||||
name: "Free".to_string(),
|
||||
amount: 0,
|
||||
members: Some(10),
|
||||
blossom: false,
|
||||
livekit: false,
|
||||
},
|
||||
Plan {
|
||||
id: "basic".to_string(),
|
||||
name: "Basic".to_string(),
|
||||
amount: 500,
|
||||
members: Some(100),
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
Plan {
|
||||
id: "growth".to_string(),
|
||||
name: "Growth".to_string(),
|
||||
amount: 2500,
|
||||
members: None,
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn new(pool: SqlitePool, env: &Env) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
env: env.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// Plans
|
||||
|
||||
pub fn list_plans(&self) -> Vec<Plan> {
|
||||
vec![
|
||||
Plan {
|
||||
id: "free".to_string(),
|
||||
name: "Free".to_string(),
|
||||
amount: 0,
|
||||
members: Some(10),
|
||||
blossom: false,
|
||||
livekit: false,
|
||||
stripe_price_id: None,
|
||||
},
|
||||
Plan {
|
||||
id: "basic".to_string(),
|
||||
name: "Basic".to_string(),
|
||||
amount: 500,
|
||||
members: Some(100),
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
stripe_price_id: Some(self.env.stripe_price_basic.clone()),
|
||||
},
|
||||
Plan {
|
||||
id: "growth".to_string(),
|
||||
name: "Growth".to_string(),
|
||||
amount: 2500,
|
||||
members: None,
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
|
||||
self.list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
}
|
||||
|
||||
pub fn is_paid_plan(&self, plan_id: &str) -> bool {
|
||||
self.get_plan(plan_id).is_some_and(|p| p.amount > 0)
|
||||
}
|
||||
|
||||
// Tenants
|
||||
|
||||
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
||||
let rows = sqlx::query_as::<_, Tenant>(&select_tenant(""))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
|
||||
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||
.bind(pubkey)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get_tenant_by_stripe_customer_id(
|
||||
&self,
|
||||
stripe_customer_id: &str,
|
||||
) -> Result<Option<Tenant>> {
|
||||
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?"))
|
||||
.bind(stripe_customer_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// Relays
|
||||
|
||||
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(&select_relay(
|
||||
"WHERE synced = 0 OR TRIM(sync_error) != ''",
|
||||
))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
|
||||
.bind(tenant_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
|
||||
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// Invoices
|
||||
|
||||
pub async fn get_lightning_invoice(
|
||||
&self,
|
||||
stripe_invoice_id: &str,
|
||||
) -> Result<Option<LightningInvoice>> {
|
||||
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
|
||||
)
|
||||
.bind(stripe_invoice_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// Activity
|
||||
|
||||
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> {
|
||||
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC"))
|
||||
.bind(resource_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_latest_activity_for_resource_and_type(
|
||||
&self,
|
||||
resource_id: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<Option<Activity>> {
|
||||
let row = sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
|
||||
))
|
||||
.bind(resource_id)
|
||||
.bind(activity_type)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
||||
list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
}
|
||||
|
||||
// Tenants
|
||||
|
||||
pub async fn list_tenants() -> Result<Vec<Tenant>> {
|
||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Relays
|
||||
|
||||
pub async fn list_relays() -> Result<Vec<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
|
||||
.bind(tenant_id)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Invoices
|
||||
|
||||
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// The relay's plan immediately before `before`, read from the activity log
|
||||
/// (the most recent `create_relay`/`update_relay` with `created_at < before`).
|
||||
/// Billing uses this as the `old` side of a plan-change delta.
|
||||
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, String>(
|
||||
"SELECT plan_id FROM activity
|
||||
WHERE resource_id = ?
|
||||
AND created_at < ?
|
||||
AND activity_type IN ('create_relay', 'update_relay')
|
||||
AND plan_id IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(relay_id)
|
||||
.bind(before)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
|
||||
.bind(bolt11_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>(
|
||||
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Activity
|
||||
|
||||
/// Billable activity for a tenant not yet folded into an invoice. The
|
||||
/// activity-type filter and the `billed_at IS NULL` guard live here so the
|
||||
/// caller reconciles off a precise marker rather than a timestamp watermark.
|
||||
/// Ordered oldest-first so line items and proration apply in event order.
|
||||
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE tenant = ?
|
||||
AND billed_at IS NULL
|
||||
AND activity_type IN (
|
||||
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
|
||||
)
|
||||
ORDER BY created_at ASC",
|
||||
))
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A tenant's relay status/plan activity strictly before `before`, oldest-first
|
||||
/// — folded by billing to reconstruct each relay's state as of a period boundary.
|
||||
/// Strict `<` so a relay created exactly at the boundary isn't counted active
|
||||
/// there (its own creation charge covers that period).
|
||||
pub async fn list_relay_activity_before(
|
||||
tenant_pubkey: &str,
|
||||
before: i64,
|
||||
) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE tenant = ?
|
||||
AND resource_type = 'relay'
|
||||
AND activity_type IN (
|
||||
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
|
||||
)
|
||||
AND created_at < ?
|
||||
ORDER BY created_at ASC",
|
||||
))
|
||||
.bind(tenant_pubkey)
|
||||
.bind(before)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE resource_id = ? ORDER BY created_at DESC",
|
||||
))
|
||||
.bind(resource_id)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_activity_for_resource_and_type(
|
||||
resource_id: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<Option<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
|
||||
))
|
||||
.bind(resource_id)
|
||||
.bind(activity_type)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
+15
-17
@@ -5,11 +5,10 @@ use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::env::Env;
|
||||
use crate::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Robot {
|
||||
env: Env,
|
||||
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
@@ -21,9 +20,8 @@ struct CacheEntry {
|
||||
}
|
||||
|
||||
impl Robot {
|
||||
pub async fn new(env: &Env) -> Result<Self> {
|
||||
pub async fn new() -> Result<Self> {
|
||||
let robot = Self {
|
||||
env: env.clone(),
|
||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
@@ -33,7 +31,7 @@ impl Robot {
|
||||
}
|
||||
|
||||
async fn make_client(&self, relays: &[String]) -> Result<Client> {
|
||||
let client = Client::new(self.env.keys.clone());
|
||||
let client = Client::new(env::get().keys.clone());
|
||||
for relay in relays {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
@@ -46,24 +44,24 @@ impl Robot {
|
||||
&self,
|
||||
) -> Result<()> {
|
||||
let mut metadata = Metadata::new();
|
||||
if !self.env.robot_name.is_empty() {
|
||||
metadata = metadata.name(&self.env.robot_name);
|
||||
if !env::get().robot_name.is_empty() {
|
||||
metadata = metadata.name(&env::get().robot_name);
|
||||
}
|
||||
if !self.env.robot_description.is_empty() {
|
||||
metadata = metadata.about(&self.env.robot_description);
|
||||
if !env::get().robot_description.is_empty() {
|
||||
metadata = metadata.about(&env::get().robot_description);
|
||||
}
|
||||
if !self.env.robot_picture.is_empty() {
|
||||
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
|
||||
if !env::get().robot_picture.is_empty() {
|
||||
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
|
||||
}
|
||||
|
||||
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
|
||||
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
|
||||
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
|
||||
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
|
||||
|
||||
outbox_client
|
||||
.send_event_builder(EventBuilder::metadata(&metadata))
|
||||
.await?;
|
||||
|
||||
let outbox_tags = self.env.robot_outbox_relays
|
||||
let outbox_tags = env::get().robot_outbox_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["r", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -71,7 +69,7 @@ impl Robot {
|
||||
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
||||
.await?;
|
||||
|
||||
let messaging_tags = self.env.robot_messaging_relays
|
||||
let messaging_tags = env::get().robot_messaging_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["relay", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -108,7 +106,7 @@ impl Robot {
|
||||
|
||||
let pubkey = PublicKey::parse(recipient)?;
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
||||
let client = self.make_client(&self.env.robot_indexer_relays).await?;
|
||||
let client = self.make_client(&env::get().robot_indexer_relays).await?;
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||
|
||||
let mut relays = Vec::new();
|
||||
@@ -128,7 +126,7 @@ impl Robot {
|
||||
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
|
||||
let pubkey = PublicKey::parse(pubkey).ok()?;
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
|
||||
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?;
|
||||
let client = self.make_client(&env::get().robot_indexer_relays).await.ok()?;
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
|
||||
let event = events.into_iter().max_by_key(|e| e.created_at)?;
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
|
||||
|
||||
@@ -3,9 +3,15 @@ use std::sync::Arc;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
/// The tenant's most recent invoice, after first materializing any outstanding
|
||||
/// line items into a fresh one — so the frontend can collect payment right after
|
||||
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller
|
||||
/// drives it via the bolt11/Stripe endpoints. `null` when the tenant has no
|
||||
/// invoices and nothing is outstanding.
|
||||
pub async fn get_tenant_latest_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
@@ -13,12 +19,16 @@ pub async fn list_tenant_invoices(
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let invoices = api
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
// Roll any outstanding charges (and due renewals) into an invoice, then
|
||||
// return the latest.
|
||||
api.billing
|
||||
.generate_invoice(&tenant)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(invoices)
|
||||
|
||||
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
@@ -26,61 +36,33 @@ pub async fn get_invoice(
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||
let invoice = query::get_invoice(&id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.reconcile_invoice(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_lightning_invoice(
|
||||
pub async fn get_invoice_bolt11(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||
let invoice = query::get_invoice(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.reconcile_invoice(&invoice)
|
||||
.ensure_and_reconcile_bolt11(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let lightning_invoice = api
|
||||
.billing
|
||||
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!(lightning_invoice))
|
||||
ok(serde_json::json!(bolt11))
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ pub mod identity;
|
||||
pub mod invoices;
|
||||
pub mod plans;
|
||||
pub mod relays;
|
||||
pub mod stripe;
|
||||
pub mod tenants;
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::sync::Arc;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, not_found, ok};
|
||||
|
||||
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(api.query.list_plans())
|
||||
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(query::list_plans())
|
||||
}
|
||||
|
||||
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match api.query.get_plan(&id) {
|
||||
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match query::get_plan(&id) {
|
||||
Some(plan) => ok(plan),
|
||||
None => Err(not_found("plan not found")),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::{command, query};
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
@@ -17,46 +18,13 @@ use crate::web::{
|
||||
parse_bool_default, unprocessable,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let relays = api.query.list_relays().await.map_err(internal)?;
|
||||
let relays = query::list_relays().await.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
@@ -78,9 +46,7 @@ pub async fn list_relay_activity(
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let activity = api
|
||||
.query
|
||||
.list_activity_for_resource(&id)
|
||||
let activity = query::list_activity_for_resource(&id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "activity": activity }))
|
||||
@@ -98,6 +64,23 @@ pub async fn list_relay_members(
|
||||
ok(serde_json::json!({ "members": members }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
pub async fn create_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -129,15 +112,31 @@ pub async fn create_relay(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
api.command
|
||||
.create_relay(&relay)
|
||||
command::create_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
created(relay)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn update_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -145,6 +144,7 @@ pub async fn update_relay(
|
||||
Json(payload): Json<UpdateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
let mut relay = api.get_relay_or_404(&id).await?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let current_plan = relay.plan.clone();
|
||||
@@ -187,17 +187,15 @@ pub async fn update_relay(
|
||||
relay.push_enabled = v;
|
||||
}
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
let plan_changed = requested_plan
|
||||
.as_deref()
|
||||
.is_some_and(|requested| requested != current_plan);
|
||||
|
||||
if plan_changed {
|
||||
let selected_plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
.expect("validated plan must exist");
|
||||
let selected_plan =
|
||||
query::get_plan(&relay.plan).expect("validated plan must exist");
|
||||
if let Some(limit) = selected_plan.members {
|
||||
let current_members = fetch_relay_members(&api, &relay)
|
||||
.await
|
||||
@@ -214,10 +212,10 @@ pub async fn update_relay(
|
||||
}
|
||||
}
|
||||
|
||||
api.command
|
||||
.update_relay(&relay)
|
||||
command::update_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
@@ -237,10 +235,10 @@ pub async fn deactivate_relay(
|
||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||
}
|
||||
|
||||
api.command
|
||||
.deactivate_relay(&relay)
|
||||
command::deactivate_relay(&relay)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
@@ -260,7 +258,8 @@ pub async fn reactivate_relay(
|
||||
return Err(bad_request("relay-is-active", "relay is already active"));
|
||||
}
|
||||
|
||||
api.command.activate_relay(&relay).await.map_err(internal)?;
|
||||
command::activate_relay(&relay).await.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
@@ -279,15 +278,13 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
|
||||
static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
|
||||
|
||||
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||
}
|
||||
|
||||
let plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
let plan = query::get_plan(&relay.plan)
|
||||
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
||||
|
||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query as QueryParams, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||
use crate::web::{ApiResult, bad_request, internal, ok};
|
||||
|
||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
QueryParams(params): QueryParams<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.stripe
|
||||
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
|
||||
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
|
||||
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
|
||||
pub async fn stripe_webhook(
|
||||
State(api): State<Arc<Api>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult {
|
||||
let signature = headers
|
||||
.get("Stripe-Signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let payload = std::str::from_utf8(&body)
|
||||
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
|
||||
|
||||
handle_webhook(&api, payload, signature)
|
||||
.await
|
||||
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
|
||||
ok(())
|
||||
}
|
||||
|
||||
// --- Webhook event handlers ---
|
||||
//
|
||||
// These translate verified Stripe events into domain actions. The Stripe HTTP
|
||||
// calls and Lightning/NWC payment orchestration they invoke live in
|
||||
// [`crate::stripe`] and [`crate::billing`] respectively.
|
||||
|
||||
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
|
||||
let event = api.stripe.get_webhook_event(payload, signature)?;
|
||||
let obj = &event.data.object;
|
||||
|
||||
match event.event_type.as_str() {
|
||||
"invoice.created" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
|
||||
let currency = obj["currency"].as_str().unwrap_or("usd");
|
||||
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
|
||||
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
|
||||
}
|
||||
"invoice.paid" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_paid(api, customer).await?;
|
||||
}
|
||||
"invoice.payment_failed" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_payment_failed(api, customer).await?;
|
||||
}
|
||||
"invoice.overdue" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_overdue(api, customer).await?;
|
||||
}
|
||||
"customer.subscription.updated" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
let status = obj["status"].as_str().unwrap_or_default();
|
||||
handle_subscription_updated(api, customer, status).await?;
|
||||
}
|
||||
"customer.subscription.deleted" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_subscription_deleted(api, customer).await?;
|
||||
}
|
||||
"payment_method.attached" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_payment_method_attached(api, customer).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_created(
|
||||
api: &Api,
|
||||
stripe_customer_id: &str,
|
||||
amount_due: i64,
|
||||
currency: &str,
|
||||
stripe_invoice_id: &str,
|
||||
) -> Result<()> {
|
||||
if amount_due == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
||||
.await?;
|
||||
|
||||
let mut nwc_error_for_dm: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let error_msg = format!("{e}");
|
||||
api.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id,
|
||||
stripe_invoice_id,
|
||||
"nwc auto-payment failed for invoice.created"
|
||||
);
|
||||
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||
// Fall through to card / manual payment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
||||
if api
|
||||
.stripe
|
||||
.has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Manual payment: DM a link to the in-app payment page for this invoice
|
||||
let url_base = &api.env.app_url;
|
||||
let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}");
|
||||
let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}");
|
||||
let dm_message = match nwc_error_for_dm {
|
||||
Some(error) if !error.is_empty() => {
|
||||
format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}")
|
||||
}
|
||||
_ => base,
|
||||
};
|
||||
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if tenant.past_due_at.is_some() {
|
||||
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.activate_relay(&relay).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if tenant.past_due_at.is_none() {
|
||||
api.command.set_tenant_past_due(&tenant.pubkey).await?;
|
||||
api.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_subscription_updated(
|
||||
api: &Api,
|
||||
stripe_customer_id: &str,
|
||||
status: &str,
|
||||
) -> Result<()> {
|
||||
if status != "canceled" && status != "unpaid" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
api.command
|
||||
.clear_tenant_subscription(&tenant.pubkey)
|
||||
.await?;
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
api.command
|
||||
.clear_tenant_subscription(&tenant.pubkey)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
|
||||
api.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Your paid relays have been deactivated due to non-payment.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
if stripe_customer_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let invoices = api
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
|
||||
for invoice in &invoices {
|
||||
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
stripe_invoice_id = %invoice.id,
|
||||
"failed to retry card payment for outstanding invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::Tenant;
|
||||
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
||||
use crate::{command, env, query};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantResponse {
|
||||
@@ -17,9 +18,8 @@ pub struct TenantResponse {
|
||||
pub nwc_is_set: bool,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
pub stripe_subscription_id: Option<String>,
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Tenant> for TenantResponse {
|
||||
@@ -29,39 +29,30 @@ impl From<Tenant> for TenantResponse {
|
||||
pubkey: t.pubkey,
|
||||
nwc_error: t.nwc_error,
|
||||
created_at: t.created_at,
|
||||
billing_anchor: t.billing_anchor,
|
||||
stripe_customer_id: t.stripe_customer_id,
|
||||
stripe_subscription_id: t.stripe_subscription_id,
|
||||
past_due_at: t.past_due_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let tenants = api.query.list_tenants().await.map_err(internal)?;
|
||||
let tenants = query::list_tenants().await.map_err(internal)?;
|
||||
ok(tenants
|
||||
.into_iter()
|
||||
.map(TenantResponse::from)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
|
||||
/// already exists (including a unique-constraint race) we return the existing
|
||||
/// row.
|
||||
pub async fn create_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
|
||||
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
|
||||
return ok(TenantResponse::from(t));
|
||||
}
|
||||
|
||||
@@ -84,10 +75,10 @@ pub async fn create_tenant(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match api.command.create_tenant(&tenant).await {
|
||||
match command::create_tenant(&tenant).await {
|
||||
Ok(()) => ok(TenantResponse::from(tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match api.query.get_tenant(&pubkey).await {
|
||||
match query::get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => ok(TenantResponse::from(t)),
|
||||
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
|
||||
Err(e) => Err(internal(e)),
|
||||
@@ -107,6 +98,11 @@ pub async fn get_tenant(
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -121,11 +117,11 @@ pub async fn update_tenant(
|
||||
if nwc_url.is_empty() {
|
||||
tenant.nwc_url = String::new();
|
||||
} else {
|
||||
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
|
||||
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
|
||||
}
|
||||
}
|
||||
|
||||
api.command.update_tenant(&tenant).await.map_err(internal)?;
|
||||
command::update_tenant(&tenant).await.map_err(internal)?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
@@ -136,10 +132,47 @@ pub async fn list_tenant_relays(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let relays = api
|
||||
.query
|
||||
.list_relays_for_tenant(&pubkey)
|
||||
let relays = query::list_relays_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
Query(params): Query<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_tenant(&auth, &pubkey)?;
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.stripe
|
||||
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
|
||||
+68
-265
@@ -7,91 +7,21 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::env::Env;
|
||||
use crate::env;
|
||||
|
||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||
|
||||
// Webhooks
|
||||
|
||||
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StripeWebhookEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub data: StripeWebhookEventData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StripeWebhookEventData {
|
||||
pub object: serde_json::Value,
|
||||
}
|
||||
|
||||
// API return types
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StripeSubscription {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
#[serde(deserialize_with = "deserialize_list")]
|
||||
pub items: Vec<StripeSubscriptionItem>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StripeSubscriptionItem {
|
||||
pub id: String,
|
||||
pub price: StripePrice,
|
||||
#[serde(default = "default_quantity")]
|
||||
pub quantity: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StripePrice {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||
pub struct StripeInvoice {
|
||||
pub id: String,
|
||||
pub customer: String,
|
||||
pub status: String,
|
||||
pub amount_due: i64,
|
||||
pub currency: String,
|
||||
pub period_start: i64,
|
||||
pub period_end: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StripeList<T> {
|
||||
data: Vec<T>,
|
||||
}
|
||||
|
||||
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
|
||||
}
|
||||
|
||||
fn default_quantity() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
// Stripe struct and impl
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Stripe {
|
||||
env: Env,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Stripe {
|
||||
pub fn new(env: &Env) -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
env: env.clone(),
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
@@ -101,23 +31,17 @@ impl Stripe {
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.get(format!("{STRIPE_API}{path}"))
|
||||
.bearer_auth(&self.env.stripe_secret_key)
|
||||
.bearer_auth(&env::get().stripe_secret_key)
|
||||
}
|
||||
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}{path}"))
|
||||
.bearer_auth(&self.env.stripe_secret_key)
|
||||
}
|
||||
|
||||
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.delete(format!("{STRIPE_API}{path}"))
|
||||
.bearer_auth(&self.env.stripe_secret_key)
|
||||
.bearer_auth(&env::get().stripe_secret_key)
|
||||
}
|
||||
|
||||
fn idempotency_key(&self, parts: &[&str]) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes())
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -146,153 +70,74 @@ impl Stripe {
|
||||
Ok(customer_id.to_string())
|
||||
}
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
pub async fn get_subscription(
|
||||
&self,
|
||||
subscription_id: &str,
|
||||
) -> Result<Option<StripeSubscription>> {
|
||||
let body = self
|
||||
.get(&format!("/subscriptions/{subscription_id}"))
|
||||
.send_optional_json()
|
||||
.await?;
|
||||
body.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Stripe requires at least one item to create a subscription, so the desired
|
||||
/// items are sent inline here; [`crate::billing`] reconciles from there.
|
||||
pub async fn create_subscription(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
items: &BTreeMap<String, i64>,
|
||||
) -> Result<StripeSubscription> {
|
||||
let mut form: Vec<(String, String)> = vec![
|
||||
("customer".to_string(), customer_id.to_string()),
|
||||
(
|
||||
"collection_method".to_string(),
|
||||
"charge_automatically".to_string(),
|
||||
),
|
||||
];
|
||||
let mut key_parts: Vec<String> =
|
||||
vec!["create_subscription".to_string(), customer_id.to_string()];
|
||||
for (index, (price_id, quantity)) in items.iter().enumerate() {
|
||||
form.push((format!("items[{index}][price]"), price_id.clone()));
|
||||
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
|
||||
key_parts.push(format!("{price_id}={quantity}"));
|
||||
}
|
||||
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
|
||||
|
||||
Ok(self
|
||||
.post("/subscriptions")
|
||||
.header("Idempotency-Key", self.idempotency_key(&key_refs))
|
||||
.form(&form)
|
||||
.send_ok()
|
||||
.await?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_subscription_item(
|
||||
&self,
|
||||
subscription_id: &str,
|
||||
price_id: &str,
|
||||
quantity: i64,
|
||||
) -> Result<()> {
|
||||
let quantity = quantity.to_string();
|
||||
self.post("/subscription_items")
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
||||
)
|
||||
.form(&[
|
||||
("subscription", subscription_id),
|
||||
("price", price_id),
|
||||
("quantity", quantity.as_str()),
|
||||
])
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
|
||||
self.post(&format!("/subscription_items/{item_id}"))
|
||||
.form(&[("quantity", quantity.to_string())])
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
|
||||
self.delete(&format!("/subscription_items/{item_id}"))
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
|
||||
self.delete(&format!("/subscriptions/{subscription_id}"))
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Invoices ---
|
||||
|
||||
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
|
||||
let list: StripeList<StripeInvoice> = self
|
||||
.get("/invoices")
|
||||
.query(&[("customer", customer_id)])
|
||||
.send_ok()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(list.data)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
|
||||
let body = self
|
||||
.get(&format!("/invoices/{invoice_id}"))
|
||||
.send_optional_json()
|
||||
.await?;
|
||||
body.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["pay_invoice", invoice_id]),
|
||||
)
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
|
||||
)
|
||||
.form(&[("paid_out_of_band", "true")])
|
||||
.send_ok()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Payment methods ---
|
||||
|
||||
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
||||
/// Return the id of the customer's first saved payment method, or `None` if
|
||||
/// they have none. The returned `pm_…` id can be charged off-session via
|
||||
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
|
||||
/// method, so the first one Stripe lists is the one we'll charge.
|
||||
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
|
||||
let body = self
|
||||
.get("/payment_methods")
|
||||
.query(&[("customer", customer_id), ("type", "card")])
|
||||
.send_json()
|
||||
.await?;
|
||||
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
|
||||
Ok(body["data"]
|
||||
.as_array()
|
||||
.and_then(|methods| methods.first())
|
||||
.and_then(|method| method["id"].as_str())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
/// Create and immediately confirm an off-session PaymentIntent charging a
|
||||
/// saved payment method. `amount` is in the currency's minor units (cents for
|
||||
/// `usd`). Returns the PaymentIntent id on success.
|
||||
///
|
||||
/// A decline or an issuer authentication demand (`authentication_required`,
|
||||
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
|
||||
/// error, so the caller naturally falls through to another payment method.
|
||||
/// The charge is made idempotent on `invoice_id`, so a retried collection
|
||||
/// reuses the same charge instead of billing the payment method twice.
|
||||
pub async fn create_payment_intent(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
payment_method_id: &str,
|
||||
invoice_id: &str,
|
||||
amount: i64,
|
||||
currency: &str,
|
||||
) -> Result<String> {
|
||||
let amount = amount.to_string();
|
||||
let body = self
|
||||
.post("/payment_intents")
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["payment_intent", invoice_id]),
|
||||
)
|
||||
.form(&[
|
||||
("amount", amount.as_str()),
|
||||
("currency", currency),
|
||||
("customer", customer_id),
|
||||
("payment_method", payment_method_id),
|
||||
("off_session", "true"),
|
||||
("confirm", "true"),
|
||||
])
|
||||
.send_json()
|
||||
.await?;
|
||||
|
||||
// A successful off-session charge settles synchronously. Anything
|
||||
// else (e.g. `requires_action`) can't be completed without the customer,
|
||||
// so treat it as a failure and let the caller fall back.
|
||||
let status = body["status"].as_str().unwrap_or_default();
|
||||
if status != "succeeded" {
|
||||
return Err(anyhow!("payment intent not succeeded (status: {status})"));
|
||||
}
|
||||
|
||||
body["id"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("missing payment intent id"))
|
||||
}
|
||||
|
||||
// --- Portal ---
|
||||
@@ -316,47 +161,13 @@ impl Stripe {
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("missing portal session url"))
|
||||
}
|
||||
|
||||
// --- Webhooks ---
|
||||
|
||||
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
|
||||
let mut timestamp = None;
|
||||
let mut sig = None;
|
||||
for part in signature.split(',') {
|
||||
if let Some(t) = part.strip_prefix("t=") {
|
||||
timestamp = Some(t);
|
||||
} else if let Some(v) = part.strip_prefix("v1=") {
|
||||
sig = Some(v);
|
||||
}
|
||||
}
|
||||
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
|
||||
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
|
||||
|
||||
let signed_payload = format!("{timestamp}.{payload}");
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
|
||||
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
||||
mac.update(signed_payload.as_bytes());
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
if expected != signature {
|
||||
return Err(anyhow!("webhook signature mismatch"));
|
||||
}
|
||||
|
||||
let ts: i64 = timestamp
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("bad webhook timestamp"))?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
|
||||
return Err(anyhow!("webhook timestamp outside tolerance"));
|
||||
}
|
||||
Ok(serde_json::from_str(payload)?)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Stripe request util
|
||||
|
||||
trait StripeRequest {
|
||||
async fn send_ok(self) -> Result<reqwest::Response>;
|
||||
async fn send_json(self) -> Result<serde_json::Value>;
|
||||
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
|
||||
}
|
||||
|
||||
impl StripeRequest for reqwest::RequestBuilder {
|
||||
@@ -367,14 +178,6 @@ impl StripeRequest for reqwest::RequestBuilder {
|
||||
async fn send_json(self) -> Result<serde_json::Value> {
|
||||
Ok(self.send_ok().await?.json().await?)
|
||||
}
|
||||
|
||||
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
|
||||
let resp = self.send().await?;
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(error_for_status(resp).await?.json().await?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Give callers an actionable message instead of a bare "400 Bad Request"
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
||||
return (relays() ?? [])
|
||||
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
|
||||
.filter((entry) => entry.plan?.stripe_price_id)
|
||||
.filter((entry) => entry.plan?.amount > 0)
|
||||
})
|
||||
|
||||
async function loadBolt11() {
|
||||
|
||||
@@ -35,7 +35,6 @@ export type Plan = {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
stripe_price_id: string | null
|
||||
members: number | null
|
||||
blossom: boolean
|
||||
livekit: boolean
|
||||
|
||||
Reference in New Issue
Block a user