Compare commits

...

29 Commits

Author SHA1 Message Date
Jon Staab 43eaad1621 Differentiate checkout id/session id
Docker / build-and-push-image (push) Successful in 52m8s
2026-06-03 14:27:57 -07:00
Jon Staab 5e6d5ab7c4 Handle stripe's 50c minimum, avoid lost write 2026-06-03 14:14:54 -07:00
Jon Staab a9f66dc3e5 Fix not charging existing relays on reactivation
Docker / build-and-push-image (push) Successful in 50m58s
2026-06-03 11:29:24 -07:00
Jon Staab ffb1491f00 Void unattached invoice items when churning a tenant 2026-06-03 11:05:00 -07:00
Jon Staab 4dc8ea942d Hide stripe error, remove pdf qr 2026-06-03 10:50:22 -07:00
Jon Staab 0e18d4020a Restructure reconciliation to always reconcile oob payments 2026-06-03 10:36:06 -07:00
Jon Staab b702733559 Add checkout sessions for paying an invoice 2026-06-03 10:02:43 -07:00
Jon Staab 8c44d8cc0f Add backend skill
Docker / build-and-push-image (push) Successful in 51m48s
2026-06-02 15:11:19 -07:00
Jon Staab 3682d0606d Fix period tiling 2026-06-02 15:07:03 -07:00
Jon Staab 6b693e11d3 refactor billing endpoints to separate reads from reconciliation requests 2026-06-02 14:33:26 -07:00
Jon Staab 5e7aa7df10 Add frontend skill 2026-06-02 14:17:27 -07:00
Jon Staab 240304b302 Add rebuild-skill 2026-06-02 13:28:57 -07:00
Jon Staab 4bd469fd17 Fix small bugs 2026-06-02 13:17:05 -07:00
Jon Staab b331a806ca Update readme, move frontend build to build phase in dockerfile 2026-06-02 12:56:52 -07:00
Jon Staab 430f33383b Fix a few possible concurrency bugs 2026-06-02 10:16:14 -07:00
Jon Staab 1d5c825e15 Consolidate dockerfiles 2026-06-02 10:01:43 -07:00
Jon Staab bd5f4b1cd0 Frontend refactor 2026-06-02 09:24:27 -07:00
Jon Staab 08e59e3b40 Add draft invoices
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-06-01 17:22:44 -07:00
Jon Staab 93bfe8e5a4 Show tenant links, remove plan from relay edit form 2026-06-01 16:37:39 -07:00
Jon Staab 55a0b69089 Convert inline error message to toast, tweak account page 2026-06-01 16:13:25 -07:00
Jon Staab 0b6302b66b Improve payment dialogs 2026-06-01 15:53:23 -07:00
Jon Staab fd38f9cbc0 Fix bolt11 reconciliation 2026-06-01 15:09:03 -07:00
Jon Staab 76fbee6be1 Round prorations to the nearest hour 2026-06-01 14:43:40 -07:00
Jon Staab 572f772ed1 Split payment setup into separate components 2026-06-01 14:39:58 -07:00
Jon Staab fed9387617 Fix login/tenant create race 2026-06-01 13:53:15 -07:00
Jon Staab 9171824ee5 Fix nip98
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-06-01 13:31:00 -07:00
Jon Staab e4e0172972 Add agents stuff
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-06-01 13:15:27 -07:00
Jon Staab 31c8e596a6 Avoid spammy payment DMs 2026-06-01 12:59:19 -07:00
Jon Staab f5403b6aef Massive user-story-oriented refactor 2026-06-01 12:38:58 -07:00
104 changed files with 5670 additions and 1838 deletions
+128
View File
@@ -0,0 +1,128 @@
---
name: backend
description: Architecture and conventions for the Caravel backend — an Axum + SQLite (sqlx) Rust service (edition 2024) with two long-running reactors. Covers the flat module map and where new code goes; the free-function query/command data layer (no repository objects) over a OnceLock global pool; the commit-then-publish activity-broadcast model the relay-sync and billing reactors hang off; auth that is structural (the AuthedPubkey NIP-98 extractor authenticates, but each handler must call require_admin/require_tenant itself — there is NO router-level authz, so a forgotten check fails OPEN); the web.rs response envelope; the env OnceLock singleton (every var required, panics at boot); the leaf integration wrappers (Stripe/NWC/Coinbase/Nostr/zooid) that billing.rs is the primary orchestrator for (though route handlers also call Stripe/Robot directly); and the clippy+build verification gate (prefer the backend-only just recipes over `just check`, which also compiles the frontend). Use this whenever working anywhere in backend/ — adding an endpoint, query, write, model, migration, config var, integration, or reactor — to follow house conventions and avoid fail-open auth, double-billing, and publish-before-commit traps.
---
# Caravel backend
This is the map of the Caravel backend: an Axum HTTP service plus two long-running reactors, persisting to SQLite via sqlx (the crate is edition 2024). It explains how the backend is organized and *why*, and points you at the modules for the *how* — reach for this for orientation and conventions, and reach for the modules it names for implementation detail. The deep, lookup-style material lives in `references/`.
One warning up front, because it is the single most dangerous wrong assumption you can carry into this codebase: **there is no router-level authorization.** Adding the `AuthedPubkey` extractor to a handler only proves *identity* — that the caller signed a valid NIP-98 event. If a handler then forgets to call a `require_*` helper, it is authenticated-but-open to *any* signed-in pubkey. Auth here fails **open**, not closed, so authorization is each handler's own explicit responsibility (`api.rs:13-15,101-137`).
Two more silent traps the body expands on, named here so you carry them in: `publish()` must happen **after** the transaction commits (never inside `with_tx`), or the reactors observe rows that might roll back; and double-billing is prevented by atomic guards rather than naive read-then-write — per-activity charges use a conditional `UPDATE ... WHERE billed_at IS NULL` claim (checking `rows_affected`) backed by a `UNIQUE` index on `invoice_item.activity_id`, while monthly renewals (whose items have `activity_id = NULL`) use a transaction-scoped read-then-write guard on the tenant's `renewed_at` marker re-read inside the same transaction.
## It's free functions and a global pool, not services/repositories
Internalize the actual shape before reaching for defaults, because the wrong default here (a `Service` or `Repository` holding a connection handle) is exactly what an agent reaches for:
- **There are no service/repository objects holding a pool or connection.** Data access is two modules of free functions — `query.rs` (all reads/SELECTs) and `command.rs` (all writes). Reads call `db::pool()` directly; writes either call `pool()` directly for single-statement updates or, for anything multi-step, run through the `db::with_tx` helper and operate on a `&mut Transaction` — instead of threading a handle through call sites (`query.rs:58-262`, `command.rs:14-705`, `db.rs:38-40,56-68`).
- **The pool and the activity broadcast channel are process-wide globals** in `OnceLock`s (`POOL`, `NOTIFY`), set once by `db::init()` at startup; reading before init or setting twice panics. This is deliberate — it is what lets `query`/`command` stay free functions instead of carrying a handle (`db.rs:15-54`).
- **The shared, application-scoped service container is `Api`** (it holds `billing`, `stripe`, `robot`). It is constructed once, wrapped in `Arc` in `Api::router()`, and installed as axum router state; handlers receive a cheaply-cloned reference as `State<Arc<Api>>` (the per-request cost is just a refcount bump, not a new instance). It is a thin authorization-and-orchestration surface, not a data handle (`api.rs:50-99`).
- **The crate is edition 2024**, which is required for the let-chains (`&&`-joined `let` patterns) in `infra.rs`. The `async |tx| { ... }` closures the `with_tx` callers use are *not* edition-gated — they were stabilized in Rust 1.85 and only need a recent toolchain, not edition 2024 specifically (`Cargo.toml:2-4`).
## The module map and where new code goes
The module map is flat under `backend/src` — one job per module: `api`, `billing`, `bitcoin`, `command`, `db`, `env`, `infra`, `models`, `query`, `robot`, `routes`, `stripe`, `wallet`, `web`. `backend` is a dual library+binary crate, so this same set of modules is declared in two roots: `lib.rs` declares them as `pub mod` (the library root, the public/canonical declaration) and `main.rs` re-declares them as private `mod` for the binary entry point (`lib.rs:1-14`, `main.rs:1-14`).
The layering, so you know the call direction: a route handler performs authorization via `Api` helpers (`require_admin` / `require_admin_or_tenant` / `require_tenant`) when needed, then calls `query` (reads) / `command` (writes) / `billing` (orchestration), which call `db`. The integration leaves (`stripe`/`wallet`/`bitcoin`/`robot`) are composed in two places: `billing.rs` holds its own `stripe`/`wallet`/`robot` for the reconciliation loop, while `Api` holds `stripe` and `robot` that route handlers invoke *directly* (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`) — so the leaves are not composed exclusively by billing (`routes/tenants.rs:76-94,263-280`, `billing.rs:25-33`).
Where a *new* thing goes:
- **An endpoint** → a handler fn in the matching `routes/*.rs` **and** a `.route(...)` line in `Api::router()`. Both files are required; "I added a handler but the route 404s" is the number-one gotcha here because the two live in different files (`api.rs:66-99`).
- **A read** → a free async fn in `query.rs`.
- **A write** → a free fn in `command.rs` (a single-statement write runs directly on `pool()`; a multi-step write that must be atomic is composed inside `db::with_tx` and publishes its `Activity` after commit).
- **A model or field** → `models.rs`, plus a numbered migration under `migrations/` (pre-release the change is squashed into the current `0001_init.sql` rather than appended).
- **A config var** → `env.rs`.
- **A third-party call** → the matching leaf module (`stripe`/`wallet`/`bitcoin`/`robot`, or the zooid sync in `infra.rs`).
The full per-module responsibility table, the exact `main()` bootstrap order, and the `lib.rs`/tests note are in [references/module-map-and-layering.md](references/module-map-and-layering.md).
## Request lifecycle: authenticate structurally, authorize explicitly, return an envelope
**Authentication is structural, and there is no middleware.** Adding the `AuthedPubkey(auth)` param to a handler *is* the entire auth mechanism — it is a NIP-98 `FromRequestParts` extractor, and its mere presence makes the route require a signed-in caller. Omitting it makes the route public; the public routes (`GET /plans`, `GET /plans/:id`) simply omit it (`api.rs:206-223`, `routes/plans.rs:9-16`).
**Authorization is the handler's explicit job** via `Api` helpers: `require_admin` / `require_tenant` / `require_admin_or_tenant` (403 on failure) and `get_tenant_or_404` / `get_relay_or_404` (load-or-404). Restating the fail-open *why*: identity is not permission, and the router gates nothing, so a handler that authenticates but never authorizes is open to any signed-in pubkey (`api.rs:103-153`).
The handler shape is fixed: params ordered `State<Arc<Api>>``AuthedPubkey(auth)``Path`/`Query`/`Json`; the body returns `web::ApiResult`; wrap infra/db/external errors with `.map_err(internal)?`; let `require_*`/`get_*_or_404` propagate with a bare `?`; tail with `ok(..)`/`created(..)` (`routes/tenants.rs:61-71,121-141`).
One ordering rule with a security reason. For a path-by-id resource owned by a tenant (a relay, an invoice), **fetch first, then authorize** against the loaded resource's `tenant_pubkey` — you need the row to know whose it is, and this intentionally returns 403 (not 404) to a non-owner of an *existing* resource. For tenant routes keyed by the tenant's own pubkey, the `Path` *is* the `tenant_pubkey`, so authorize on it first (`routes/relays.rs:29-37`, `routes/invoices.rs:19-32`, `routes/tenants.rs:61-71`).
The response envelope: success goes through `web::ok`/`created`/`res`, returning `{ data, code: "ok" }`; errors go through typed builders returning `{ error, code }`. Note the keys differ — `data` on success, `error` on failure. `unauthorized`/`forbidden`/`not_found`/`internal` hardcode their code; `bad_request`/`unprocessable` take a caller-supplied kebab-case domain code. Translate sqlite `UNIQUE` violations to 422 via `map_unique_error` rather than letting them 500 (`web.rs:31-129`, `routes/relays.rs:309-316`).
Flag one deliberate weakness so nobody "fixes" it: the NIP-98 check here is a **session-style variant**. It verifies kind 27235, the signature, and that the last `u` tag equals `SERVER_URL`, but it does **not** bind HTTP method/URL/query, payload hash, timestamp freshness, or keep a replay cache — a valid header is effectively a ~10-minute bearer token. This is intentional (fewer signing prompts); do not add per-request binding (`api.rs:157-203`, `README.md:128-137`).
The exact decode steps, the envelope field shapes, and the full in-use domain-error-code list are in [references/request-lifecycle-and-web.md](references/request-lifecycle-and-web.md).
## The data layer: query/command split, transactions, and the activity log
Reads live in `query.rs` (mostly free async fns over `db::pool()`; `list_plans`/`get_plan` are synchronous). Writes live in `command.rs`: simple single-row writes are free async fns over `db::pool()`, but multi-step writes run inside `with_tx()` and delegate to private `_tx` helpers taking `&mut Transaction`. Tenant-scoped reads take a `tenant_pubkey` param and filter on it; some are suffixed `_for_tenant` (`list_relays_for_tenant`, `list_invoices_for_tenant`) but several are not (`list_open_invoices`, `list_unbilled_invoice_items`, `list_billable_activity`), so the suffix is not a reliable marker of tenant scoping (`query.rs:89-96,165-218`, `command.rs:14-87`).
`with_tx` is the only transaction primitive: it runs an async closure with a `&mut Transaction`, commits on `Ok`, and rolls back **only** via `Transaction`'s `Drop` on `Err` — there is no explicit rollback. The consequence to respect: a closure that swallows an error and returns `Ok` will commit a partial write. Multi-step atomic writes compose private `*_tx` helpers (each taking `&mut Transaction` as its first param) inside one `with_tx` closure (`db.rs:60-68`, `command.rs:466-704`).
The core idiom is the **activity log plus commit-then-publish**: a mutation records an `Activity` row inside the transaction, the `*_tx` helper *returns* that `Activity`, and the public command calls `db::publish(activity)` **after** `with_tx` commits — so reactors only ever observe durable rows. Publishing inside the transaction is the trap, because a subscriber could then act on a row that rolls back (`command.rs:179-182`, `db.rs:47-54`).
**Idempotency and double-billing are prevented by atomic guards, not naive read-then-write checks,** and the guard differs by path. Per-activity charges use a conditional claim: `mark_activity_billed_tx` updates `WHERE billed_at IS NULL` and returns a bool (`rows_affected() > 0`) you must honor, backstopped by `UNIQUE(invoice_item.activity_id)`. Other monotonic flips guard on null markers: `mark_invoice_paid_tx` only flips while `paid_at IS NULL AND voided_at IS NULL`. Renewals are the exception — their line items have `activity_id = NULL`, so neither the WHERE-guard nor the UNIQUE index protects them; their sole protection is a transaction-scoped read-then-write that re-reads `renewed_at` inside the same `with_tx` and only writes if the period hasn't been renewed (`command.rs:279-335,563-630`, `migrations/0001_init.sql:111-112`).
Billing-lifecycle entities model state as **nullable timestamp markers, not status enums**: an invoice is open while `paid_at` and `voided_at` are both null, a tenant is churned once `churned_at` is set, a bolt11 is settled once `settled_at` is set. Filter on the timestamps; these billing tables have no status column. Relay status is the one exception — a free-form `TEXT` column (active/inactive/delinquent) with no `CHECK` and no Rust enum, guarded only by the `RELAY_STATUS_*` consts and filtered/branched on throughout the relay code (`models.rs:4-6,54-60,120-142`, `migrations/0001_init.sql:32,48-60`).
Two cross-cutting gotchas worth stating inline: boolean-ish columns are stored and typed as `i64` 0/1 (`policy_public_join`, the `*_enabled` flags, `synced`), not Rust `bool` — compare against 0/1; and plans are **not** a DB table (`list_plans`/`get_plan` are hardcoded, synchronous in-memory data), so adding a plan is a code edit, not a migration (`models.rs:86-94`, `query.rs:20-54`).
The per-table read helpers, the `Snapshot` enum, the strict-`<` historical lookups, the schema-squash migration rule, and the FK naming convention are in [references/data-layer-and-schema.md](references/data-layer-and-schema.md).
## Background reactors and the broadcast model
Two detached tokio tasks are launched from `main()` after `db::init()` and run for the life of the process alongside the axum server: `billing.start()` (time-driven) and `infra::start()` (event-driven) (`main.rs:56-67`).
**Billing is the hourly poller.** A tokio interval loop calls `reconcile_subscriptions()`, which sweeps all tenants and logs-and-continues per tenant so one failure never aborts the sweep. The same `reconcile_subscription(tenant, attempt_payment)` is shared by the worker (`true`) and the synchronous reconcile route (`false`) — parameterize shared reconciliation rather than duplicating it (`billing.rs:46-130`).
**Infra is the broadcast reactor, and the model for any new background reaction.** It calls `db::subscribe()` to the activity channel, runs a reconcile sweep on *startup* to recover work missed while the process was down, then loops on `recv()`. It must handle `RecvError::Lagged` by running a full reconcile sweep over the DB "pending" query — the channel is best-effort with capacity 64, so you cannot assume you saw every message; `Closed` ends the worker (`infra.rs:21-44`).
The two non-negotiable reactor rules, with their *why*:
- **The top-level reactor driver loops never crash the process on a failure.** The billing poll loop, the per-tenant sweep, and the infra `recv` loop each wrap their unit of work in a `tracing::error!`-logged guard with structured fields and continue (`sync_relay` is even infallible by design). Note this catch-and-continue is only at the driver level: inner batch loops (e.g. the per-activity loop, `reconcile_renewal`, `reconcile_relay_state`) propagate a per-item error via `?` and abandon the rest of the current batch — that error still bubbles up to the nearest wrapped driver, so the process stays alive, but the failing item aborts its enclosing batch rather than being skipped (`billing.rs:52-54,66-74,104-110`, `infra.rs:28-44,83-89`).
- **Correctness comes from the DB reconcile sweep, never from one-message-per-event.** `db::publish` silently drops when there are no subscribers, and the bounded channel drops on lag — the broadcast is a hint, the DB "pending" query is the source of truth (`db.rs:50-54`, `infra.rs:35-41`).
New background reactions should hook the publish/subscribe activity stream rather than adding a new poller; tuning knobs (poll interval, grace/DM windows, retry base/max/attempts) live as module-level consts at the top of the worker file (`db.rs:43-54`, `billing.rs:15-23`, `infra.rs:15-17`).
The relay-sync retry/backoff mechanics, the self-feeding `fail_relay_sync` loop, the POST-vs-PATCH `is_new` logic, the secret-never-stored detail, and the full billing dunning cascade are in [references/reactors-and-relay-sync.md](references/reactors-and-relay-sync.md).
## External integrations: Nostr, Stripe, Lightning/NWC, Coinbase, zooid
Every integration is a **leaf I/O wrapper** that speaks only to the third party, returns `anyhow::Result`, and knows nothing about the DB, routes, or domain (`stripe.rs`'s only `crate` import is `env`, for the API key). `stripe.rs` parses Stripe's JSON internally via a private `send_json -> Result<serde_json::Value>` helper, but its public methods hand back small typed results (e.g. `Result<String>`, `Result<Option<String>>`), not raw `serde_json::Value`. New external calls go in the matching leaf module (`stripe`/`wallet`/`bitcoin`/`robot`, or the zooid sync in `infra.rs`), never inline in a route or mixed with DB logic (`stripe.rs:1-5`, `wallet.rs:7-13`).
`billing.rs` is the **primary orchestrator** that composes integrations against the DB — notably the payment cascade (NWC auto-pay → out-of-band lightning check → Stripe card on file → manual DM), where a failing NWC or Stripe attempt records its error on the tenant but never aborts the cascade and the first success returns early. It is *not* the only place integrations are used: route handlers also call Stripe and the robot directly (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`). And a handler may invoke more than one billing method — `reconcile_tenant` calls both `sync_stripe_customer` and `reconcile_subscription`, `reconcile_invoice` calls both `ensure_bolt11_for_invoice` and `attempt_payment` — and those public billing methods are themselves orchestrators that fan out internally (`billing.rs:29-33,326-377`, `routes/tenants.rs:84-94,182-190`).
Sensitive at-rest values (a tenant's `nwc_url`) are NIP-44 **self-encrypted** with the robot's own keypair via `env.encrypt`/`decrypt` — at-rest confidentiality for the service, *not* a DM to the tenant — encrypted at the write boundary (the route) and decrypted only at point of use (billing). Outbound zooid calls are NIP-98 signed via `env.make_auth` (`env.rs:86-107`, `routes/tenants.rs:130-137`).
Two design intentions not to "fix": an off-session Stripe `PaymentIntent` is treated as **failed unless `status == "succeeded"`**, so the cascade falls through via two distinct paths — an off-session 3DS/authentication demand returns an HTTP 402 error caught earlier by `error_for_status`, while a 2xx response whose status is merely not `"succeeded"` is caught by the explicit status check (do not assume 3DS "comes back 2xx" — for off-session confirmed intents Stripe surfaces it as an HTTP error); and the zooid relay secret is generated fresh and sent **only on first sync** (`is_new`), so Caravel never stores relay secrets, which is why a re-sync must `PATCH`, not `POST` (`stripe.rs:104-106,135-143,194-227`, `infra.rs:168-243`).
The Stripe idempotency-key HMAC scheme, the currency-minor exponent table, Robot's publish-on-construct side effect, the relay-list cache TTL, and the per-integration error-string conventions are in [references/integrations.md](references/integrations.md).
## Config: the env singleton
All config is one process-wide `Env` struct in a `static OnceLock`, loaded once by `env::init()` in `main()` immediately after `dotenv`, *before* `db::init()` — the env → db → services order is load-bearing. Read config **only** through `crate::env::get()` (which returns `&'static Env`); never read `std::env::var` outside `env.rs` (`env.rs:8-20`, `main.rs:28-37`).
**Every variable is required** — there are no optional vars and no graceful degradation. `require_str`/`require_u16`/`require_csv` panic at boot on a missing, blank, or invalid value (and an invalid `ROBOT_SECRET` panics too). Adding an integration var without setting it crashes the process on boot rather than degrading (`env.rs:110-140`).
Adding a config var is **four coordinated edits**: a field on `Env`, a load line in `Env::load` with the right `require_*` helper, README docs, and `.env.template`. Do crypto/auth through `Env` methods (`encrypt`/`decrypt`, `make_auth`), not by reaching for the keys ad hoc (`env.rs:22-84`).
Two traps. NIP-98 host-affinity means `SERVER_URL` must *exactly* equal the client's `u` tag or every authenticated request 401s. And the README uses stale var names that don't exist in `env.rs`: its local-dev table lists `ADMINS` (real name `SERVER_ADMIN_PUBKEYS`) and `ZOOID_API_SECRET` (the backend has no such var — it consumes `ZOOID_API_URL` and signs zooid requests with `ROBOT_SECRET`), while the production `docker run` example sets `PLATFORM_NAME` (a frontend VITE var, not a backend `Env` field) — trust `env.rs` and `.env.template`, not the README (`api.rs:158-202`, `README.md:19,101-102` vs `env.rs:60,76`).
The full variable surface, the `DATABASE_URL`/`CARGO_MANIFEST_DIR` rewrite, and the CORS silent-drop are in [references/config-and-env.md](references/config-and-env.md).
## Building and verifying a change
The `justfile` is the canonical task runner; backend recipes `cd` into `backend/` and run one cargo command. The minimal diff-safe gate for a backend edit is `just build-backend` (`cargo build`) plus `just lint-backend` (`cargo clippy -- -D warnings`, where every warning is a hard error), plus `just test-backend` if the touched area has tests (`justfile:17-30`).
**For a backend-only change prefer the backend-scoped recipes (`just fmt-backend lint-backend build-backend test-backend`) over a full `just check`.** `just check` also runs against the frontend — `build` is `build-backend build-frontend` — so it compiles the frontend even for a backend-only edit. The backend crate is currently both fmt-clean (`cargo fmt --check` exits 0) and clippy-clean (`cargo clippy -- -D warnings` exits 0), so running fmt is fine; verify fmt state with `cargo fmt --check` rather than assuming drift (`justfile:39,43`).
There are currently **zero tests** in the backend — no `#[cfg(test)]`/`#[tokio::test]` under `backend/src`, no `tests/` dir — so `cargo test` and `cargo test api::tests::` both pass trivially with 0 tests run. A green `cargo test` does *not* mean your change is exercised. The scaffolding exists (the `tower`/`util` dev-dep for `ServiceExt::oneshot`, the `api` module path the `test-backend-api` filter expects), so new behavior should add tests under `api::tests::` and drive the `Router` via tower's `oneshot` (`justfile:26-27`, `Cargo.toml:29-30`).
`lint-backend` runs `cargo clippy -- -D warnings`, where every warning is a hard error; the crate currently lints clean, so keep your additions warning-free rather than churning unrelated code to silence nits (`justfile:20-21`).
## House style (brief)
- **Comments are minimal**, one line where possible; a doc comment states a function's *purpose*, not its implementation. There is one canonical place for any fact — model/field semantics in `models.rs` doc comments only, DB index rationale in migration SQL comments — so don't duplicate across layers (root `AGENTS.md:3-12`).
- **Naming.** FK columns are `{model}_{pk}` (`relay.tenant_pubkey`, and in `models.rs` `invoice_item.activity_id`, `bolt11.invoice_id`); a tenant's pubkey is `tenant_pubkey` except in already-tenant-scoped contexts like `tenant.pubkey` or `get_tenant(pubkey)` (root `AGENTS.md:16,18`). Separately, some tenant-scoped query/command fns are suffixed `_for_tenant` (a codebase convention in `query.rs`/`command.rs`, not stated in `AGENTS.md`; see the data-layer reference for why it is not a reliable marker).
- **Rust idioms.** Prefer `&str` over `&String` params; avoid passing `&mut` into functions — return results and let the caller manage mutability; resist over-DRY — extract only for a distinct concern, 3+ repetitions, or genuine clarity (the inline zooid body and the longhand `update_relay` merge are deliberate) (root `AGENTS.md:30,32,34`).
- **Markdown.** Do not hard-wrap at a fixed column — write one logical line per paragraph (root `AGENTS.md:24-26`).
@@ -0,0 +1,59 @@
# Config and the env singleton (deep detail)
This is the lookup-depth companion to the SKILL.md "config" section: the full variable surface, the `require_*` helpers, the `DATABASE_URL` rewrite, and the stale-README traps. All of it lives in `env.rs`, `db.rs`, `main.rs`, `api.rs`, `.env.template`, and the README.
## The variable surface
`Env` is `#[derive(Clone)]` with 24 fields plus a parsed `keys: Keys`. The full grouped surface:
- **server** — `SERVER_URL`, `SERVER_PORT` (u16), `SERVER_ADMIN_PUBKEYS` (csv), `SERVER_ALLOW_ORIGINS` (csv), `APP_URL`, `DATABASE_URL`
- **robot identity** — `ROBOT_SECRET` (parsed into `Keys`), `ROBOT_NAME`, `ROBOT_DESCRIPTION`, `ROBOT_PICTURE`, `ROBOT_WALLET`, `ROBOT_OUTBOX_RELAYS`, `ROBOT_INDEXER_RELAYS`, `ROBOT_MESSAGING_RELAYS`
- **zooid/livekit** — `ZOOID_API_URL`, `RELAY_DOMAIN`, `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`
- **blossom S3** — `BLOSSOM_S3_ENDPOINT`, `BLOSSOM_S3_REGION`, `BLOSSOM_S3_BUCKET`, `BLOSSOM_S3_ACCESS_KEY`, `BLOSSOM_S3_SECRET_KEY`
- **billing** — `STRIPE_SECRET_KEY`
Source: `env.rs:22-82`, `.env.template:1-38`.
## The require_* helpers
- `require_str` reads the var, panics `"{key} is required"` if unset, trims it, and panics again if the trimmed value is empty.
- `require_u16` calls `require_str` then `.parse()`, panicking `"{key} is invalid"` on a parse failure.
- `require_csv` uses `std::env::var(key).unwrap_or_default()`, splits on `,`, trims each element, drops empties, and panics `"{key} is required"` if the result is empty — so an *unset* csv var and a *present-but-all-blank* one both say "required", which can mislead debugging.
`ROBOT_SECRET` is parsed via `Keys::parse(...).expect(...)`, so an invalid key panics too. Pick the helper by type: scalars use `require_str`, the port uses `require_u16`, lists use `require_csv`. Source: `env.rs:53-55,110-140`.
## Field semantics worth noting
- `SERVER_URL` is the NIP-98 host-affinity value — the `u` tag must equal it exactly or every authenticated request 401s.
- `SERVER_PORT` binds `127.0.0.1` only (localhost, not `0.0.0.0`).
- `SERVER_ADMIN_PUBKEYS` is checked by membership for `is_admin`.
- `SERVER_ALLOW_ORIGINS` is parsed into `CorsLayer` `HeaderValue`s, with unparseable origins **silently dropped** via `filter_map(...ok())` — a typo won't error at startup, it just won't allow that origin and surfaces later as a browser CORS block.
- `APP_URL` is the only var trailing-slash-trimmed at load (`zooid_api_url` is trimmed at use).
Source: `api.rs:104,158-202`, `main.rs:44-65`, `env.rs:62`.
## DATABASE_URL normalization
`normalize_sqlite_url` rewrites a relative `sqlite://<rel>` to `sqlite://{CARGO_MANIFEST_DIR}/<rel>` — and `CARGO_MANIFEST_DIR` is baked in at **compile** time via `env!`, so the path resolves relative to the build-time backend crate dir, not the process cwd. `:memory:`, absolute, and non-sqlite URLs pass through unchanged; the parent dir is `create_dir_all`'d; connection uses `create_if_missing` plus WAL plus `./migrations`. In the Docker image the backend is compiled with `WORKDIR /app`, so `CARGO_MANIFEST_DIR` = `/app`; the documented **relative** `DATABASE_URL=sqlite://data/caravel.db` therefore resolves to `/app/data/caravel.db`, lining up with the `-v my-caravel-data:/app/data` volume mount. The resolution comes from the build-time crate dir, not the runtime working directory, and the deployment URL is relative (not absolute). Source: `db.rs:70-108`, `Dockerfile:6`, `README.md:18,23`.
## Adding a config var, and doing crypto through Env
Adding a var is four coordinated edits: an `Env` field, a load line in `Env::load` with the right `require_*` helper, README docs, and `.env.template`. Do crypto/auth through `Env` methods (`encrypt`/`decrypt`, `make_auth`), not by reaching for `keys` ad hoc. Normalize URL-shaped config at the edge the way existing code does. Source: `env.rs:22-107`.
## Stale-README traps to ignore
- The README's local-dev table uses `ADMINS` (the real var is `SERVER_ADMIN_PUBKEYS`) and `ZOOID_API_SECRET` (no such var — zooid auth is `ROBOT_SECRET` via NIP-98).
- The README docker example sets `PLATFORM_NAME`, which is a frontend VITE var, not a backend `Env` field.
- Trust `env.rs` and `.env.template`, not the README.
- `.env` is gitignored (root `.env` and `**/.env`), as are `data/` and `target/`; there is no backend-level `.gitignore`.
Source: `README.md` vs `env.rs:54,60,76`, `.gitignore:5-8`.
## Sources
- variable surface — `backend/src/env.rs:22-82`, `backend/.env.template:1-38`
- require_* helpers — `backend/src/env.rs:53-55,110-140`
- field semantics — `backend/src/api.rs:104,158-202`, `backend/src/main.rs:44-65`, `backend/src/env.rs:62`
- DATABASE_URL normalization — `backend/src/db.rs:70-108`
- adding a var + crypto via Env — `backend/src/env.rs:22-107`
- stale-README traps — `README.md` vs `backend/src/env.rs:54,60,76`, `.gitignore:5-8`
@@ -0,0 +1,56 @@
# Data layer and schema (deep detail)
This is the lookup-depth companion to the SKILL.md "data layer" section: read-helper assembly, transaction-helper conventions, the idempotency idioms in full, the `Snapshot` enum, the i64/timestamp modeling, the strict-`<` historical reads, and the schema/migration rules. All of it lives in `query.rs`, `command.rs`, `models.rs`, `db.rs`, and `migrations/0001_init.sql`.
## Read assembly
Reads use `query_as::<_, T>` with `T` deriving `sqlx::FromRow`, returning typed structs (`Tenant`, `Relay`, `Activity`, `Invoice`, `InvoiceItem`, `Bolt11`). The `SELECT *` body is built by per-table `select_tenant`/`select_relay`/`select_activity` string helpers that append a trailing `WHERE`/`ORDER` clause; one-off reads inline the SQL but still go through `query_as`. Tenant-scoped reads take a `tenant_pubkey` param and filter on `tenant_pubkey`, but the `_for_tenant` suffix is **not** a reliable marker of tenant scoping: only two reads carry it (`list_relays_for_tenant`, `list_invoices_for_tenant`), while `list_open_invoices`, `list_unbilled_invoice_items`, and `list_billable_activity` are tenant-scoped without the suffix. (Note also that `list_plans`/`get_plan` are synchronous, not async.) Source: `query.rs:6-16,58-262`.
## Transaction conventions
`with_tx` is the only primitive: it begins a tx on the pool, runs the async closure with a `&mut Transaction`, commits on `Ok`, and relies on `Transaction`'s `Drop` to roll back on `Err` — there is no explicit `rollback()` call, so a closure that swallows an error and returns `Ok` will commit a partial write. Source: `db.rs:60-68`.
The `*_tx` helpers are private, suffixed `_tx`, take `&mut Transaction` as their first param, and run via `.execute(&mut **tx)`. Public commands compose them inside one `with_tx` closure and never take a transaction. A `*_tx` that records a state change *returns* the constructed `Activity`, and the public command publishes it after commit. Some single-statement writes run directly on `pool()` with no transaction at all: `create_tenant`, `update_tenant`, the `set_tenant_*` setters, `mark_invoice_notified`, and `insert_bolt11`. Source: `command.rs:14-82,146-183,466-704`, `db.rs:60-68`.
## The idempotency idioms in full
- `mark_activity_billed_tx` runs `UPDATE ... WHERE id = ? AND billed_at IS NULL` and returns `rows_affected() > 0` — a bool you must honor, because ignoring a `false` and inserting the invoice item anyway hits the `UNIQUE(activity_id)` index and fails.
- `insert_invoice_item_for_activity` claims the activity first and only inserts the line item when the claim won, so a concurrent reconcile never double-bills.
- Conditional monotonic `UPDATE`s are guarded on null markers: `mark_invoice_paid_tx`, `mark_bolt11_settled_tx`, and `void_open_invoices_tx`.
- `insert_intent_tx` records the Stripe `PaymentIntent` with `INSERT ... ON CONFLICT(id) DO NOTHING`, making settlement idempotent on retried webhooks.
- Renewal re-reads `renewed_at` inside the tx and advances it, so it is idempotent per period.
- `UNIQUE(invoice_item.activity_id)` is the database backstop behind the claim.
- `insert_activity_tx` requires the relay row to already exist in the same tx — it fetches the relay's `tenant_pubkey` — so insert/update the relay before logging.
Source: `command.rs:279-335,563-704`, `migrations/0001_init.sql:111-112`.
## The Snapshot type
`Snapshot` is a serde-tagged enum keyed on `resource_type`, with one variant per resource that logs activity, wrapped in `sqlx::types::Json` on insert. Each `Activity` carries a JSON snapshot of the resource's plan+status. Add a variant (and its `resource_type()`) when a new resource type starts logging activity. Source: `models.rs:8-24`, `command.rs:174-178`.
## Timestamp-vs-enum modeling and the i64 convention
Lifecycle is nullable timestamp markers, not status enums: an invoice is open while `paid_at` and `voided_at` are both null, a tenant is churned once `churned_at` is set, a bolt11 is settled once `settled_at` is set; `invoice.method` records provenance only when paid. Filter on the timestamps. Relay status is the exception — a free-form `TEXT` column with **no** `CHECK` constraint and no Rust enum, guarded only by the `RELAY_STATUS_*` consts (by contrast `invoice.method` *does* have a `CHECK`), so a typo'd status string would persist silently. Boolean-ish columns are `i64` 0/1, not Rust `bool``list_relays_pending_sync` uses `synced = 0 OR TRIM(sync_error) != ''`. Source: `models.rs:54-94,120-142`, `query.rs:81-121,206-240`, `migrations/0001_init.sql:32,48-60`.
## Strict-`<` historical lookups
`get_relay_plan_before` / `get_latest_relay_activity_before` use `created_at < before` (strict `<`, not `<=`) when reconstructing historical relay state from the activity log. A relay created exactly at a period boundary is intentionally not counted active in the prior period (its own creation/change charge covers that period); using `<=` would double-charge the creation period. `list_billable_activity` does **not** use a timestamp boundary at all — it has no `before` param and selects a tenant's unbilled activity via the `billed_at IS NULL` marker (plus an `activity_type` filter), reconciling off a precise marker rather than a timestamp watermark. Source: `query.rs:108-121,206-218,225-240`.
## Schema and migration rules
The whole schema lives in a single migration, `0001_init.sql`. Pre-release, schema changes are **squashed** into that file rather than appended as new files; migrations become append-only only after release. `db::init` runs create-if-missing plus WAL plus `./migrations`. Relative `sqlite://` `DATABASE_URL` paths are rewritten against `CARGO_MANIFEST_DIR` (the compile-time backend crate dir), not the process cwd. FK columns are named `{model}_{pk}`. Plans are hardcoded in-memory and synchronous — `list_plans`/`get_plan` are not a DB table, so a new plan is a code edit, not a migration. Source: `db.rs:70-108`, `migrations/0001_init.sql:1-115`, `query.rs:20-54`, root `AGENTS.md:20-22`.
## A few "Ok with no write" cases
`create_invoice` and `insert_invoice_items_for_renewal` can legitimately return `Ok` with no write: a non-positive outstanding balance returns `Ok(None)` (credit carries forward) and empty renewal items returns early `Ok(())`. Don't treat a missing invoice as an error. `insert_bolt11` uses `INSERT ... RETURNING *` with `fetch_optional` and returns `Option<Bolt11>`, so handle the `Option` rather than unwrapping. `set_relay_status_tx` (and `update_relay`) always reset `synced = 0` as a side effect of any status/field change, re-queuing the relay for the infra reactor. Source: `command.rs:185-223,300-335,443-464`.
## Sources
- read assembly + `_for_tenant` suffix — `backend/src/query.rs:6-16,58-262`
- transaction conventions — `backend/src/command.rs:14-82,146-183,466-704`, `backend/src/db.rs:60-68`
- idempotency idioms — `backend/src/command.rs:279-335,563-704`, `backend/migrations/0001_init.sql:111-112`
- `Snapshot``backend/src/models.rs:8-24`, `backend/src/command.rs:174-178`
- timestamp/i64/status modeling — `backend/src/models.rs:54-94,120-142`, `backend/src/query.rs:81-121`, `backend/migrations/0001_init.sql:32,48-60`
- strict-`<` historical reads (and the marker-based `list_billable_activity`) — `backend/src/query.rs:108-121,206-218,225-240`
- schema/migration rules — `backend/src/db.rs:70-108`, `backend/migrations/0001_init.sql:1-115`, `backend/src/query.rs:20-54`, root `AGENTS.md:20-22`
- Ok-with-no-write + side effects — `backend/src/command.rs:185-223,300-335,443-464`
@@ -0,0 +1,45 @@
# External integrations (deep detail)
This is the lookup-depth companion to the SKILL.md "external integrations" section: per-leaf behavior, the Stripe idempotency/error/currency details, the NWC per-call pattern, Robot's caches and side effects, the at-rest encryption, and the payment cascade. All of it lives in `stripe.rs`, `wallet.rs`, `bitcoin.rs`, `robot.rs`, `env.rs`, `infra.rs`, and `billing.rs`.
## Stripe leaf
A thin `reqwest` wrapper, no SDK. `get()`/`post()` build a `RequestBuilder` against the Stripe API and attach `.bearer_auth(&stripe_secret_key)` on every call. The `StripeRequest` trait provides `send_ok()` (runs `error_for_status`) and `send_json()`; all methods end in `.send_json()`. `error_for_status` parses Stripe's JSON error envelope into `message [type-or-code] (param: ...)`, falling back to the raw body.
The `Idempotency-Key` is `HMAC-SHA256(stripe_secret_key, parts joined by ':')` with a stable per-operation prefix: `create_customer` keys on `[create_customer, tenant_pubkey]`; the charge (`create_payment_intent`) keys on `[payment_intent, invoice_id, payment_method_id]``payment_method_id` is in the key on purpose, so a fall-back to a different card for the same invoice produces a distinct key instead of colliding with (and replaying) the original charge. Reuse `idempotency_key()` with a descriptive prefix for any new mutating call; `get_saved_payment_method` and `create_portal_session` send no idempotency key.
`create_payment_intent` posts `off_session=true`, `confirm=true`, and **requires** `status == "succeeded"`, so the billing cascade falls through via **two distinct paths**: (1) an off-session 3DS/authentication demand is returned by Stripe as an HTTP 402 error, caught earlier by `error_for_status` (do **not** assume `requires_action`/3DS "comes back 2xx" — for off-session confirmed intents Stripe surfaces it as an HTTP error, as the file's own doc comment states); (2) a 2xx response whose status is merely not `"succeeded"` is converted to `Err` by the explicit status check. Don't relax this expecting Stripe to retry off-session. `get_saved_payment_method` returns the **first** card listed (no Stripe-default notion). `create_portal_session` is called directly from the route handler, not via billing. Source: `stripe.rs:1-225` (3DS-as-HTTP-error doc at `104-106`; status check at `135-143`; `error_for_status` at `194-227`), `routes/tenants.rs:263-280`.
## Wallet (NWC) leaf
A parsed `NostrWalletConnectURI` with a short-lived **new-then-shutdown-per-call** pattern: every method does `NWC::new(...)`, awaits the op, then `nwc.shutdown()`. Nothing is pooled across awaits. `is_settled` treats an invoice as settled if `state == Settled` *or* `settled_at.is_some()`; `make_invoice` takes msats + description + expiry and returns a bolt11; `pay_invoice` takes a bolt11. Source: `wallet.rs:7-61`.
## Bitcoin (Coinbase) leaf
Converts fiat-minor to msats: it fetches the Coinbase spot price for `BTC-<CURRENCY>`, divides minor units by `10^exponent`, then `/ price * 1e11`, rounded to `u64`. `currency_minor_exponent` encodes Stripe's currency table (0 decimals for the zero-decimal currencies, 3 for BHD/JOD/KWD/OMR/TND, 2 for any other 3-letter alpha code) and must stay aligned with what Stripe expects. Trap: the Coinbase client is built with **no timeout** (unlike the 5s zooid and robot clients), so a hung response can stall `fiat_to_msats` indefinitely. The Stripe charge currency is hardcoded `"usd"` in billing despite both modules supporting arbitrary currencies, so a non-USD invoice would be charged as a USD-minor amount. Source: `bitcoin.rs:5-50`, `billing.rs:406-416`.
## Robot leaf
The service's Nostr identity, built on `env::get().keys`. `Robot::new()` has a **network side effect** — it `publish_identity().await?` (kind 0 metadata, kind 10002 outbox, kind 10050 messaging relays) before returning, so it is not a pure constructor and it propagates relay send errors. `send_dm` discovers the recipient's relays (kind 10002, then kind 10050) and then NIP-17 `send_private_msg`s; empty relay lists are an error. `fetch_nostr_name` swallows errors via `.ok()` and returns `Option`, so a relay outage looks identical to "no name set" (callers fall back to the first 8 chars of the pubkey). Relay-list caches are positive-only with a 5-minute TTL and cache even an empty `Vec`, so a recipient who just published 10002/10050 keeps failing `send_dm` for up to 5 minutes. Source: `robot.rs:11-211`.
## At-rest encryption
`env.encrypt`/`decrypt` are NIP-44 v2 **self-encryption** — the robot's own secret and public key, sender equals recipient — so they provide at-rest confidentiality for the service, not a DM the tenant can read. A tenant's `nwc_url` is encrypted at the route on write and decrypted only at point of use in billing. Outbound zooid auth is NIP-98 via `env.make_auth`, the sole zooid auth mechanism. Source: `env.rs:86-107`, `routes/tenants.rs:130-137`, `billing.rs:381`.
## The payment cascade
`attempt_payment` (the cascade orchestrator, defined in `billing.rs`) runs the cascade in order: (1) NWC auto-pay (decrypt `nwc_url` → per-call `Wallet`), (2) out-of-band lightning `is_settled` via the robot wallet, (3) Stripe card on file, (4) manual DM link. A failing NWC or Stripe attempt records its error on the tenant but never aborts the cascade, and the first success returns `Ok`. Lightning pricing flows through `bitcoin::fiat_to_msats(amount, "usd")` in `ensure_bolt11_for_invoice`, which mints a 3600s bolt11. Note `billing.rs` is *not* the only place integrations are used: route handlers call Stripe and the robot directly too (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`). And a handler may invoke **more than one** billing method — `reconcile_tenant` calls both `sync_stripe_customer` and `reconcile_subscription`, `reconcile_invoice` calls both `ensure_bolt11_for_invoice` and `attempt_payment` — and those public billing methods are themselves orchestrators that fan out internally, not leaf methods. Source: `billing.rs:29-33,326-377,400-502`, `routes/tenants.rs:84-94,182-190`, `routes/invoices.rs:54-62`.
## Per-integration error-string convention
Error-string quality varies, so prefer actionable strings when adding calls. Stripe builds `message [code] (param)` (actionable); zooid builds `method path returned status: body` (actionable). But the leaves are not uniform: NWC's `make_invoice`/`is_settled` add `anyhow!` context (`failed to create invoice: {e}` / `failed to lookup invoice: {e}`), while `pay_invoice` passes the raw error through unchanged (`anyhow!("{e}")`); and Coinbase only wraps the price-*parse* failure (`invalid BTC spot quote for {currency}: {e}`) — a non-2xx Coinbase API response is surfaced as a bare `reqwest` `error_for_status()` error with no added context. (NWC lives in `wallet.rs`, not the files cited below.) Source: `stripe.rs:191-225`, `infra.rs:289-293`, `bitcoin.rs:24-33`, `wallet.rs:39,47,59`.
## Sources
- Stripe leaf — `backend/src/stripe.rs:1-225`, `backend/src/routes/tenants.rs:263-280`
- Wallet (NWC) leaf — `backend/src/wallet.rs:7-61`
- bitcoin leaf — `backend/src/bitcoin.rs:5-50`, `backend/src/billing.rs:406-416`
- Robot leaf — `backend/src/robot.rs:11-211`
- at-rest encryption — `backend/src/env.rs:86-107`, `backend/src/routes/tenants.rs:130-137`, `backend/src/billing.rs:381`
- payment cascade — `backend/src/billing.rs:29-33,326-377,400-502`
- error-string convention — `backend/src/stripe.rs:191-225`, `backend/src/infra.rs:289-293`, `backend/src/bitcoin.rs:24-33`, `backend/src/wallet.rs:39,47,59`
@@ -0,0 +1,66 @@
# Module map and layering (deep detail)
This is the lookup-depth companion to the SKILL.md "module map" section: the full per-module responsibility map, the exact `main()` bootstrap and spawn order, the layering direction with concrete traces, and the test-target note. It exists so the SKILL.md "where things live" section can stay prose-only.
## The flat module map
Everything lives flat under `backend/src`, one job per module. `backend` is a dual library+binary crate, so this same set of modules is declared in two roots: `lib.rs` declares them as `pub mod` (the library root, the public/canonical declaration) and `main.rs` re-declares them as private `mod` for the binary entry point:
- **`api`** — the router, the `Api` service container, the authorization helpers (`require_*`, `get_*_or_404`, `is_admin`), and the `AuthedPubkey` NIP-98 extractor.
- **`billing`** — the orchestrator: it composes the integration leaves against the DB to reconcile activity into invoice items, renew subscriptions, and collect payment.
- **`bitcoin`** — the Coinbase fiat↔msats conversion leaf.
- **`command`** — all writes, as free async fns: single-statement writes run directly on `db::pool()`, while multi-step writes run inside `db::with_tx`.
- **`db`** — the global `SqlitePool`, the activity broadcast channel, and `with_tx`.
- **`env`** — the config singleton.
- **`infra`** — the relay-sync reactor plus the zooid client.
- **`models`** — the domain and sqlite-row structs plus the relay status string constants.
- **`query`** — all reads, plus the hardcoded plans.
- **`robot`** — the service's Nostr identity and DM sender.
- **`routes/*`** — the HTTP handlers, grouped by resource (`identity`, `plans`, `tenants`, `relays`, `invoices`).
- **`stripe`** — the Stripe HTTP leaf.
- **`wallet`** — the NWC (Nostr Wallet Connect) leaf.
- **`web`** — the response envelope and its success/error builders.
Source: `lib.rs:1-14`, `main.rs:1-14`, and each module head.
## The `main()` bootstrap order
The order is strict, and each service is built from the ones before it:
1. `dotenvy::dotenv().ok()`
2. tracing setup
3. `env::init()` — loads and validates all config, panicking on any missing var
4. `db::init().await` — normalizes the sqlite URL, opens the pool, sets WAL, runs migrations, and installs the broadcast channel
5. `Robot::new().await` — builds the Nostr identity (and publishes it; see the integrations reference)
6. `Stripe::new()`
7. `Billing::new(robot.clone())`
8. `Api::new(billing, stripe, robot)`
Then it builds the router (`api.router().layer(cors)`), spawns `infra::start()` and `billing.start()` as detached tokio tasks, and only then binds `127.0.0.1:{SERVER_PORT}` and calls `axum::serve`. The HTTP server and the two workers run concurrently for the life of the process.
Source: `main.rs:27-67`.
## The layering direction
The call direction is: route handler → (when needed) `Api` authorization helpers (`require_admin` / `require_admin_or_tenant` / `require_tenant`) → `query` (reads) / `command` (writes) / `billing` (orchestration) → `db`. There is no single strict linear layer: a handler may skip the authz helpers entirely, and it may call integration leaves directly rather than going through `billing`. The integration leaves (`stripe`/`wallet`/`bitcoin`/`robot`) are composed in **two** places — `billing.rs` holds its own `stripe`/`wallet`/`robot` for the reconciliation loop, while `Api` holds `stripe` and `robot` that route handlers invoke directly — so the leaves are *not* composed exclusively by `billing.rs`.
Two concrete traces:
- **`create_tenant`** calls no `require_*` helper (only the `AuthedPubkey` extractor); it calls `query::get_tenant`, then `api.robot.fetch_nostr_name` and `api.stripe.create_customer` directly (two integration leaves, not via `billing`), then `command::create_tenant` (`routes/tenants.rs:76-109`).
- **`reconcile_invoice`** calls `query::get_invoice`, then `billing.ensure_bolt11_for_invoice` and `billing.attempt_payment` — here the handler delegates the multi-integration orchestration to `billing` rather than calling the leaves itself (`routes/invoices.rs:35-60`).
## Router assembly
`Api::router()` is the single place every route string is wired to a handler fn imported from `routes/*`, after which `Arc<Api>` is attached via `.with_state`. An endpoint therefore needs *both* a handler fn (in the matching `routes/*.rs`) and a `.route(...)` line (in `api.rs`) — two different files, both required (`api.rs:66-99`).
## The test-target note
`lib.rs` re-exports every module `pub` solely for an integration-test target that does not exist yet — there is no `backend/tests` dir — so nothing currently consumes those re-exports. If you add an integration-test crate, this is the surface it reads (`lib.rs:1-14`).
## Sources
- flat module list — `backend/src/lib.rs:1-14`, `backend/src/main.rs:1-14`, and each module head
- `main()` bootstrap and spawn order — `backend/src/main.rs:27-67`
- layering traces — `backend/src/routes/tenants.rs:76-109`, `backend/src/routes/invoices.rs:35-60`
- router assembly — `backend/src/api.rs:66-99`
- test-target note — `backend/src/lib.rs:1-14`
@@ -0,0 +1,47 @@
# Reactors and relay sync (deep detail)
This is the lookup-depth companion to the SKILL.md "background reactors" section: the relay-sync retry/backoff machinery, the self-feeding failure loop, the zooid POST-vs-PATCH request, and the billing dunning cascade timing. All of it lives in `infra.rs`, `billing.rs`, `db.rs`, and `query.rs`.
## The infra recv loop
`infra::start()` calls `db::subscribe()` once, runs `reconcile_relay_state("startup")` to recover relays left unsynced from a prior run, then loops on `rx.recv().await`, handling all three broadcast outcomes:
- **`Ok(activity)`** → `handle_activity`, which filters to `resource_type == "relay"` *and* an `activity_type` in `{create_relay, update_relay, activate_relay, deactivate_relay, fail_relay_sync}`; everything else is ignored. A `fail_relay_sync` routes to `schedule_relay_sync_retry`; the others load the relay via `query::get_relay` and call `sync_relay`.
- **`Lagged(n)`** → `warn` plus a full `reconcile_relay_state("lagged")` sweep to recover the dropped messages.
- **`Closed`** → break out of the loop, terminating the worker. Because the broadcast `Sender` lives in a `static OnceLock` for the whole process, `Closed` effectively never happens in normal operation — but if it did, `infra::start` returns and is not restarted by `main`, leaving relay provisioning dead until the process restarts.
`reconcile_relay_state` queries `list_relays_pending_sync` (`synced = 0 OR TRIM(sync_error) != ''`), returns early if empty, and otherwise routes blank-error relays to an immediate `sync_relay` and error-carrying ones through backoff. Source: `infra.rs:28-92`, `query.rs:81-83`.
## Backoff
`schedule_relay_sync_retry` counts `consecutive_failures` via `take_while` over `fail_relay_sync` activities at the **head** of the resource history (ordered `created_at DESC`) — any non-failure activity at the head resets the count to 0, which is what lets a recovered relay restart backoff from the base delay. The delay is `BASE(30s) << (attempt - 1)`, capped at `MAX(15min)`; after `MAX_ATTEMPTS(6)` it returns `None`, logs "retries exhausted; awaiting manual intervention", and stops. The retry itself is a fire-and-forget `tokio::spawn` that sleeps the computed delay, re-fetches the relay, and calls `sync_relay` (a missing relay is a silent no-op). Source: `infra.rs:15-17,94-148`, `query.rs:242-249`.
## The self-feeding loop
`sync_relay` never returns an error: on `Ok` it calls `command::complete_relay_sync` (sets `synced = 1`, `sync_error = ''`); on `Err` it calls `command::fail_relay_sync` (sets `synced = 0`, `sync_error = ...`), which publishes a `fail_relay_sync` activity after commit, which re-enters `handle_activity` and re-schedules backoff. The retry chain terminates when **any** of these happen: the sync succeeds (`complete_relay_sync` resets `synced = 1` and breaks the consecutive-failure streak counted by `take_while`, so no further retry is scheduled), the relay no longer exists (`get_relay` returns `None`, a silent no-op), a `get_relay` query errors (logged and stopped), or the consecutive-failure count exceeds `MAX_ATTEMPTS(6)` — after which the relay sits with `synced = 0` and a set `sync_error` until manual intervention or another activity touches it. Note `set_relay_status_tx` and `update_relay` always reset `synced = 0` as a side effect, so a "pure" status flip is never sync-neutral. Source: `infra.rs:57-60,136-146,151-166`, `command.rs:185-273,580-596`.
## The zooid request
`try_sync_relay` assembles the request body inline as a `serde_json::json!`: `host` (subdomain + `relay_domain`), `schema` (`relay.id`), an `inactive` flag, and the `info`/`policy`/`groups`/`management`/`push`/`roles` blocks, plus a conditional blossom S3 block and a livekit block — each gated on the relay's `*_enabled` i64 flag and falling back to `{enabled: false}`.
`is_new` is true **only** when `synced != 1` *and* there is no prior `complete_relay_sync` activity. `is_new` alone decides `POST` (with a freshly generated `Keys::generate` secret inserted into the body) vs `PATCH` (secret omitted). Because `update_relay` resets `synced = 0`, a re-sync after an update would look "new" by the `synced` flag alone — the second condition (no prior `complete_relay_sync`) is what makes it a `PATCH`, so the relay is not re-created and its secret is not clobbered. Caravel never persists the secret, so this check is load-bearing.
All zooid calls go through `request(method, path, body)`: a 5-second `reqwest` client, base from `zooid_api_url` (trailing slash trimmed), NIP-98 `Authorization` via `env.make_auth`, and a non-2xx response is turned into an `anyhow::bail!` carrying the status and body. Source: `infra.rs:168-295`.
## Billing worker timing
`POLL_INTERVAL` is 1 hour, so dunning runs at hour granularity. The DM guards exist specifically so the hourly tick doesn't re-DM on every pass:
- `GRACE_PERIOD_SECS` = 7 days (dunning grace before churn)
- `FRESH_INVOICE_DM_GRACE_SECS` = 24h (hold the manual-payment DM until an open invoice is at least this old, because a fresh invoice is surfaced in-app first)
- `MANUAL_PAYMENT_DM_INTERVAL_SECS` = 12 days (minimum spacing between reminder DMs)
`attempt_payment_using_dm` checks both `invoice.created_at` and `invoice.notified_at` before sending. `reconcile_subscription` clones the tenant and mutates the local copy (billing anchor, churn, payment method), updating the DB via explicit `command` calls, so the synchronous reconcile route re-reads the tenant afterward to reflect the changes. Source: `billing.rs:15-23,46-130,436-449`.
## Sources
- infra recv loop + reconcile — `backend/src/infra.rs:28-92`, `backend/src/query.rs:81-83`
- backoff — `backend/src/infra.rs:15-17,94-148`, `backend/src/query.rs:242-249`
- self-feeding loop — `backend/src/infra.rs:57-60,136-146,151-166`, `backend/src/command.rs:185-273,580-596`
- zooid request + POST-vs-PATCH — `backend/src/infra.rs:168-295`
- billing worker timing — `backend/src/billing.rs:15-23,46-130,436-449`
@@ -0,0 +1,60 @@
# Request lifecycle and the web envelope (deep detail)
This is the lookup-depth companion to the SKILL.md "request lifecycle" section: the exact NIP-98 decode, the success/error envelope field shapes, every builder's fixed-vs-supplied code, the full in-use domain-error-code list, and the handful of authorization quirks. All of it lives in `api.rs`, `web.rs`, and the `routes/*` handlers.
## The NIP-98 decode, step by step
`decode_nip98_pubkey` does the following, and every failure collapses to a single 401 `unauthorized` via `extract_auth_pubkey`'s `.map_err(unauthorized)`:
1. require an `Authorization: Nostr <base64>` header
2. base64-decode it to a JSON Nostr event
3. assert `event.kind == HttpAuth` (kind 27235)
4. call `event.verify()` (the signature/id check)
5. take the **last** `u` tag (`.last()`, not `.first()`)
6. assert it equals `env::get().server_url`
7. return `event.pubkey.to_hex()`
Because all of these collapse to one 401, you cannot distinguish a missing header from a bad signature from a host mismatch at the response level. Source: `api.rs:163-203`.
## The deliberate non-strictness
The check binds signer identity and host affinity only. It does **not** verify the HTTP method, the exact request URL/path/query, a payload hash, timestamp freshness, or maintain any replay cache. Per the README rationale, the frontend signs one kind-27235 event with `u = VITE_API_URL` and caches the header ~10 minutes; the tradeoff is a reusable ~10-minute bearer header (fewer wallet-signing prompts, no cookie sessions) at the cost of weaker request-intent binding than strict NIP-98. Do not "fix" it to per-request binding — it is a design choice. Source: `api.rs:167-203`, `README.md:128-137`.
## The envelope structs
- **Success:** `DataResponse { data: T, code: "ok" }`, serialized as `{ "data": ..., "code": "ok" }`. The field is `data`, and `code` is a `&'static str`.
- **Error:** `ErrorResponse { error: String, code: String }`, serialized as `{ "error": ..., "code": ... }`. The field is `error`, and `code` is an owned `String`.
Note the top-level keys differ — `data` on success, `error` on failure — so a client must branch on success-vs-error rather than reading one fixed key. The only HTTP statuses the success builders emit are 200 (`ok`) and 201 (`created`); there is no 204/no-content builder in this file. Source: `web.rs:33-43`.
Success builders return `ApiResult` (= `Result<Response, ApiError>`) already wrapped in `Ok`, so they sit at the tail of a handler with no `Ok(..)`: `res(status, data)`, `ok(data)` (= `res(OK, ..)`), `created(data)` (= `res(CREATED, ..)`). `ApiError` is a boxed `Response` with the status baked in at construction — it carries no separate status field. Source: `web.rs:17-57`.
## Error builders and their codes
The named error builders fix both status and code: `unauthorized` → 401/`unauthorized`, `forbidden` → 403/`forbidden`, `not_found` → 404/`not-found`, `internal` → 500/`internal`. The two domain builders take a **caller-supplied** kebab-case code: `bad_request(code, msg)` → 400, `unprocessable(code, msg)` → 422. Passing the wrong status builder silently emits the wrong HTTP status with your intended code. Source: `web.rs:61-103`.
`map_unique_error` downcasts `sqlx::Error::Database` and matches the raw message *substring*: contains `pubkey``pubkey-exists`, contains `subdomain``subdomain-exists`, else `None`. Because it matches on the message text rather than the constraint name, a column rename can silently regress the 422 to a 500. `map_relay_write_error` is a **private** helper inside `routes/relays.rs` (not exported from `web.rs`) that wraps `map_unique_error`: a `subdomain-exists` hit becomes a 422, anything else a 500. Source: `web.rs:115-129`, `routes/relays.rs:309-316`.
## The full in-use domain-error-code list
Beyond the framework codes (`ok`/`unauthorized`/`forbidden`/`not-found`/`internal`), the domain codes actually surfaced to clients are: `subdomain-exists`, `invalid-subdomain`, `invalid-plan`, `premium-feature`, `member-limit-exceeded` (all 422), and `relay-is-active` / `relay-is-inactive` / `relay-is-delinquent` (all 400, via `bad_request`). `pubkey-exists` is *defined* in `map_unique_error` alongside `subdomain-exists` but is **not** surfaced as an error: its only call site (`routes/tenants.rs:105`) intercepts the unique-constraint violation and returns 200 OK with the existing tenant (idempotent re-fetch). Source: status helpers `bad_request` (400) / `unprocessable` (422) at `web.rs:89-95`, code strings at `web.rs:122-127` and `routes/relays.rs:204,225,230,249,253,283,287,293,311-312`, `pubkey-exists` interception at `routes/tenants.rs:103-110`.
## Load-vs-authorize ordering
For a path-by-id resource owned by a tenant, fetch the resource **first** (via `get_*_or_404`), then authorize against its `tenant_pubkey` — you need the loaded row to know whose it is. This also intentionally leaks existence: a non-owner of an *existing* relay/invoice gets a 403, and a 404 only for a truly missing id. For tenant routes keyed by the tenant's own pubkey, the `Path` *is* the `tenant_pubkey`, so authorize on it first, then fetch. Don't reorder these. Source: `routes/relays.rs:29-37`, `routes/invoices.rs:19-32`, `routes/tenants.rs:61-71`.
## Authorization quirks
- **`create_tenant`** authorizes nobody beyond authentication: it uses the `AuthedPubkey` *as* the tenant identity, so a caller can only ever create or return their own tenant. It is idempotent and even swallows a `pubkey-exists` race by re-reading; a missing row after that race is a 500 with "tenant row missing after unique-constraint race". Source: `routes/tenants.rs:76-114`.
- **`GET /tenants/:pubkey/stripe/session`** is the only same-tenant-*only* route (it uses `require_tenant`, not `require_admin_or_tenant`). Source: `routes/tenants.rs:263-269`.
- **`get_plan`** is synchronous: `query::get_plan` returns a `Result` (no `.await`, no `Option`), so a missing plan is mapped to `not_found` via an `Err`, not a `None`. Don't pattern-match plans for a `None` case. Source: `routes/plans.rs:13-15`.
- **`update_relay`** enforces `member-limit-exceeded` (422) only when the plan actually changes and the new plan has a `members` limit; it fetches live member counts from zooid, which returns empty for unsynced relays, so an unsynced relay appears to have 0 members for the limit check. Source: `routes/relays.rs:190-207,263-269`.
## Sources
- NIP-98 decode + non-strictness — `backend/src/api.rs:163-203`, `backend/README.md:128-137`
- envelope structs + success builders — `backend/src/web.rs:17-57`
- error builders + `map_unique_error``backend/src/web.rs:61-129`, `backend/src/routes/relays.rs:309-316`
- domain-error-code list — `backend/src/web.rs:89-95,122-127`, `backend/src/routes/relays.rs:204,224-296`, `backend/src/routes/tenants.rs:103-110`
- load-vs-authorize ordering — `backend/src/routes/relays.rs:29-37`, `backend/src/routes/invoices.rs:19-32`, `backend/src/routes/tenants.rs:61-71`
- authorization quirks — `backend/src/routes/tenants.rs:76-114,263-269`, `backend/src/routes/plans.rs:13-15`, `backend/src/routes/relays.rs:190-207,263-269`
+84
View File
@@ -0,0 +1,84 @@
---
name: billing-model
description: Conceptual, user-story-level overview of how Caravel bills tenants for their relays — plans, proration, invoices, automatic payment collection, payment-method errors, dunning, churn/delinquency, and reactivation. Use this when working on or reasoning about anything billing-related, to understand the intended domain behavior before touching the code.
---
# Caravel billing model
This explains *what* the billing system is supposed to do and *why*, in plain domain terms. It is deliberately free of implementation detail — no functions, tables, or fields — so it stays true as the code evolves. Reach for the code for the *how*; reach for this for the *intent*.
## The cast
- **Tenant** — a customer account, identified by a Nostr pubkey. Everything is billed to a tenant.
- **Relay** — a hosted relay a tenant runs. A tenant can have many. Each relay sits on one plan and is either active, inactive, or delinquent.
- **Plan** — a tier (e.g. free, basic, growth). The free tier costs nothing; paid tiers have a flat monthly price and unlock features.
Only active relays on a paid plan ever cost money. Free relays, inactive relays, and delinquent relays are not charged.
## The guiding idea: bill from history, not from "now"
Every meaningful change to a relay — created, plan changed, deactivated, reactivated — is recorded as a dated event that also captures what the relay looked like at that instant (its plan and status). Billing is computed by replaying these events, never by reading the relay's *current* settings.
Why this matters: if a customer was on the growth plan last week and downgraded today, last week must still be billed at the growth price. Pricing the past from the present would overcharge or undercharge. Treat the event history as the source of truth for money.
## Billing periods
Each tenant is billed in monthly cycles. The cycle is anchored to the moment of their first billable activity, and each period is a whole calendar month from that anchor. A customer who starts on the 7th is billed on the 7th, and so on.
## How charges arise (the user stories)
**"I created a paid relay mid-month."** The customer is charged immediately, but only for the slice of the current period that remains — a prorated charge, not a full month.
**"I upgraded (or downgraded) a relay mid-month."** The customer is charged (or credited) the prorated *difference* between the old and new plan for the rest of the period. Downgrades and removals can produce credits.
**"I deactivated a relay mid-month."** The customer receives a prorated credit for the unused remainder of the period.
**"A new month started."** Every relay that was active on a paid plan at the moment the new period began is charged a full month. A relay created partway through the previous month already paid its prorated slice, so the renewal and the proration compose to exactly one fair month — never double-charged.
Credits and charges accumulate together. A customer's net balance can be zero or negative; in that case nothing is billed and the credit simply carries forward to the next time there is something to pay.
## Invoices
When a tenant has a positive outstanding balance, the pending charges and credits are gathered into a single invoice for the period. An invoice moves through a simple lifecycle expressed as *when* things happened rather than as a status label:
- **Open** — issued and awaiting payment.
- **Paid** — settled (and we remember how: Lightning, card, or a manual/out-of-band payment).
- **Void** — forgiven and no longer collectible (used when an account churns).
## Collecting payment (the cascade)
When an invoice is open, the system tries to collect on the customer's behalf, in order of least friction:
1. **The customer's Lightning wallet**, if they've connected one for automatic payments.
2. **A saved card**, if they have one on file.
3. **A manual nudge** — if automatic methods don't go through, the customer is sent a direct message with a link to pay the invoice themselves, by Lightning or card.
The system also guards against double payment: if an invoice was already settled out of band (e.g. the customer paid the Lightning request directly), that is recognized and the invoice is not collected again.
## When automatic payment fails
**The failure reason is remembered and shown to the customer.** If the Lightning wallet or the card is declined, we keep the most recent error for each method so the UI can warn the customer that something is wrong with their payment setup — separately for the wallet and the card, since they can fail independently.
**A stored error never stops us from trying again.** Recording the problem is purely informational. The next collection attempt still runs; the relevant warning is cleared automatically the moment that method succeeds.
**Unpaid invoices are retried, within a grace period.** An open invoice is re-attempted on each billing cycle. The customer has a **7-day grace period** from when the invoice was issued to get payment working.
## Churn and delinquency
If an invoice is still unpaid once its grace period has elapsed, the account **churns**:
- The tenant's active relays are marked **delinquent**, pausing service.
- The outstanding balance is **forgiven** (the invoice is voided). We stop chasing money the customer clearly isn't going to pay; the unpaid amount is not carried as a debt.
Delinquency is the visible signal — to the customer and to admins — that the account lapsed for non-payment.
## Coming back (reactivation)
A churned customer who re-engages with the service is welcomed back automatically: their churn is cleared and their delinquent relays are restored to active. Critically, **old unpaid invoices do not have to be settled to return** — the past balance was already forgiven at churn, so reactivation is a clean slate rather than a debt collection.
## Principles to preserve
- **Bill the past at its historical price.** Always price a change from the state captured when it happened, not from the relay's current settings.
- **Never double-charge.** Proration and renewal must compose to one fair month; collection must tolerate retries and out-of-band payments idempotently.
- **Errors inform, they don't block.** Surface payment problems to the customer, but keep retrying.
- **Forgive on churn, don't accrue debt.** A lapsed customer's old balance is written off, and returning never requires paying it.
+107
View File
@@ -0,0 +1,107 @@
---
name: frontend
description: Architecture and conventions for the Caravel frontend — a SolidJS + Vite + TypeScript app (NOT React, NOT TanStack Query, NOT nonboard despite the README). Covers the pages/components/lib layout, the `@/` import alias, strict TS rules (verbatimModuleSyntax), the createResource + api.ts data layer, lazy tenant provisioning, applesauce Nostr singletons, the Tailwind v4 brand-remap (blue utilities render brown), the shared component/modal/toast kit, and the `bun run build` verification gate. Use this whenever working anywhere in frontend/ — adding a page, hook, API call, component, or style — to follow house conventions and avoid stale-README traps.
---
# Caravel frontend
This is the map of the Caravel frontend: a SolidJS + Vite + TypeScript single-page app living under `frontend/src`. It explains how the frontend is organized and *why*, and points you at the code for the *how* — so reach for this for orientation and conventions, and reach for the modules it names for implementation detail. The deep, lookup-style topics live in `references/`.
One warning up front, because it is the single most dangerous thing about this codebase: **`frontend/README.md` is stale and lies about the stack.** Do not trust it for what libraries are in use, how to run the app, or what routes exist. Its specific traps are called out below.
## It's SolidJS, not React — and the README lies about the stack
Before touching anything, internalize the actual stack, because the wrong defaults here are exactly the ones an agent reaches for:
- **SolidJS, not React.** Use `class`, not `className`. Use `<Show>`/`<For>` for conditional and list rendering, not ternaries and `.map()`. Access props lazily as `props.foo` — never destructure props at the top, because that breaks reactivity (see `src/components/relay/PlanGatedToggle.tsx`, whose own header comment says so). State is `createSignal`/`createResource`/`createMemo`/`createEffect`.
- **Data fetching is hand-rolled `fetch` in `lib/api.ts` wrapped in SolidJS `createResource` — NOT TanStack Query.** `@tanstack/solid-query` is in `package.json` and the README, but it is imported *nowhere* in `src` (verified: zero references). Do not introduce it.
- **Login is built directly on `applesauce-accounts` / `applesauce-signers` — NOT "nonboard".** There is no such dependency; the README invented it.
- **`applesauce-wallet-connect` and `@tailwindcss/forms` are declared dependencies that are never imported.** NWC on the frontend is just a text input whose value is POSTed to the backend; form inputs are styled with explicit utility classes.
Bottom line: follow the `createResource` + applesauce patterns the codebase actually uses.
## Where things live
There are four top-level code directories under `src`, each with one job (plus an `assets/` dir and loose root files `App.tsx`, `index.tsx`, `index.css`, `global.d.ts`):
- **`pages/`** — route components. Subfolders group screens: `relays/` (tenant relay screens) and `admin/` (the admin console).
- **`components/`** — shared, reusable UI, with feature subfolders `login/`, `payment/`, `account/`, `relay/`.
- **`lib/`** — all non-UI logic.
- **`views/`** — holds only `Login.tsx` (see the quirk below).
Within `lib/`, four modules are the load-bearing pillars:
- **`api.ts`** — the *only* place backend wire types and `fetch` wrappers live.
- **`state.ts`** — app-lifetime singletons plus global signals/resources.
- **`hooks.ts`** — the `use*` `createResource` read hooks and the active-tenant action helpers.
- **`nostr.ts`** — the `useNostr()` DI seam for reaching the Nostr singletons.
The rest of `lib/` is camelCase pure-helper modules and `use*` hook files. One quirk to know: **Login lives in `src/views/Login.tsx`, not `pages/`** — it is the lone occupant of `views/`.
Where a *new* thing goes: a route page → `pages/`; shared UI → `components/`; a hook → `lib/use*.ts`; a backend call → an `api.ts` wrapper; pure decision logic → its own `lib` module. A concrete file inventory and the entrypoint/route wiring are in [references/file-inventory.md](references/file-inventory.md) if you need it.
## Naming, imports, and TypeScript rules
To compile and fit in, an edit must satisfy these:
- **File naming.** Components/pages/views are PascalCase `.tsx` with one default-exported function named after the file. Lib modules are camelCase `.ts` (no default exports). Hooks are camelCase `use*.ts`. Files that export multiple hooks/symbols use named exports (e.g. `PaymentSetupShell.tsx` exports `PaymentSetupShell` plus sibling components). Single-hook files are inconsistent: `useRelayToggles.ts` and `useMinLoading.ts` default-export, but `useInvoicePdf.ts` uses a named export — so don't assume a single-purpose hook default-exports.
- **Imports always use the `@/` alias** (maps to `src/`), e.g. `import RelayList from "@/pages/relays/RelayList"`. There is not a single relative `./`/`../` cross-module import in the tree (the only relative import is `index.tsx`'s `import "./index.css"`). The alias is configured in **two places that must stay in sync**: `vite.config.ts` and `tsconfig.app.json`.
- **No barrel `index` files.** The only `index.{ts,tsx,js,jsx}` under `src/` is the entrypoint `index.tsx`, which renders the app rather than re-exporting a module. Import each symbol from its concrete module. (`index.css` also exists, so `index.tsx` isn't literally the only `index.*` file.)
- **One canonical export per symbol — no re-export shims.** When code moves, update every call site (root `AGENTS.md`). Note one existing wrinkle: `hooks.ts` re-exports four `api.ts` wire types (`Activity`, `Invoice`, `Relay`, `Tenant`), so each is importable from two paths — **prefer `@/lib/api`** for wire types. `hooks.ts` also re-exports `ProfileContent`, but that type comes from `applesauce-core/helpers/profile` (not `api.ts`), so import it from there.
- **Strict TS.** `verbatimModuleSyntax` is on, so type-only symbols need `import type` or an inline `type` specifier, e.g. `import { invoiceStatus, type Invoice } from "@/lib/api"`. `noUnusedLocals`/`noUnusedParameters` and `erasableSyntaxOnly` are also on.
- **`tenant_pubkey` naming.** Name a tenant's pubkey `tenant_pubkey` on FK fields/inputs; the exception is already-tenant-scoped contexts (`Tenant.pubkey`, `getTenant(pubkey)`). See root `AGENTS.md`.
- **Don't over-DRY.** Extract a helper only when it's a distinct concern, repeated 3+ times, or complex enough that naming it clarifies the flow (root `AGENTS.md`). `relayFlags.ts` is the canonical example — it was extracted because the 0/1↔bool conversion recurred across toggle UI and mutations.
A strong idiom: keep **pure decision logic** (payload shapes, validation ladders, discriminated-union decisions) in dedicated `lib` modules with no signals/effects/awaits (`relayPlanFlow.ts`, `loginInput.ts`), and keep the effect layer in the importing hook/component. These modules carry a header comment stating they are pure.
## The data layer: api.ts + createResource
Every backend call goes through `lib/api.ts`. A generic `callApi(method, path, body)` attaches a cached NIP-98 auth header, unwraps the `{ data, code }` envelope (returning `.data`), and throws `ApiError(message, status)` on a non-2xx response. Each endpoint is a thin exported wrapper with explicit generics; add new endpoints the same way (use an `undefined` request type for bodyless requests — both GETs and parameterless POSTs). The lone exception is `listPlans`, which bypasses `callApi` and does its own raw `fetch` with no auth machinery. (The public/unauthed routes are `/plans` and `/plans/:id` — the two backend handlers that omit the `AuthedPubkey` extractor every other route requires; `getPlan` still goes through `callApi`, which simply omits the `Authorization` header when logged out.)
Wire types are snake_case. Two modeling choices to respect: relay boolean settings are **numeric 0/1 flags**, not JS booleans — toggle them through the `relayFlags` helpers; and an invoice's lifecycle is **derived from nullable timestamps** via `invoiceStatus`, not read from a status column.
Screen reads expose a `use*` `createResource` hook in `hooks.ts` (again: not TanStack Query). Pass an accessor as the resource source when it should refetch on id change; pass a thunk reading `account()!.pubkey` for active-tenant reads. Render with `ResourceState` + `useMinLoading` + `Show`/`For`, defaulting the value with `?? []`.
Mutations are optimistic and follow one shape: `mutate(next)``await update``await refetch()`, and on error `mutate(previous)` + `setToastMessage(...)` to roll back and report.
The deeper mechanics — the `ApiOk` envelope and `ApiError` shape, NIP-98 auth caching, and the `api.ts``state.ts` circular-import DI seam — are in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Global state, the session, and lazy tenant provisioning
`state.ts` holds app-lifetime singletons and global reactive state: the `account` signal, the `plans`/`identity` resources, the `billing*` resources, the toast signals, and `PLATFORM_NAME`.
The one ordering rule you must respect: **a tenant row is provisioned lazily.** `ensureSessionTenant()` does a `POST /tenants`, run by the account-activation subscriber on every account switch (it's idempotent — later runs just return the existing row). Some tenant-scoped reads 404 before the row exists: `getTenant` and `getDraftInvoice` 404 via `get_tenant_or_404`. (Note `listTenantInvoices` and `listTenantRelays` do *not* 404 — they query directly and return empty results — so the gate mainly protects the tenant/draft reads.) That is why the billing resources are gated on a separate `billingPubkey` signal rather than directly on `account()``billingPubkey` is cleared (set to `undefined`) on every account switch and re-set only once the tenant is ensured. Convention: gate any new tenant-scoped resource behind `billingPubkey` (or run `ensureSessionTenant()` first), and after a billing-affecting mutation call `refetchBilling()` where the flow needs it.
For the billing *domain* (why proration, dunning, churn, and reactivation behave as they do), read the [billing-model skill](../billing-model/SKILL.md) — that skill owns the intent. This skill only documents the frontend data-layer wiring. The detailed account-switch lifecycle is in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Nostr integration (applesauce)
Most agents touch this rarely. Three app-lifetime applesauce singletons live in `state.ts`: `eventStore`, `pool` (a `RelayPool`), and `accountManager`. The reactive `account` signal mirrors `accountManager.active$` — never set `account` directly; activate accounts through `accountManager`.
To reach the singletons: inside a SolidJS reactive scope use `const nostr = useNostr()` (the DI seam in `nostr.ts`, which falls back to the module singletons when no provider is mounted); outside one (event handlers, plain async, `api.ts`) import the module singletons directly.
Render profiles through the `useProfileMetadata` / `useProfileMetadataMap` / `useProfilePicture` hooks, which subscribe to `eventStore.profile(...)` and optionally prime over the network — never hand-roll relay queries. When a parent batch-primes a list, children subscribe with `{ prime: false }` so they don't each re-prime.
Pubkeys are hex strings; shorten them for display with the canonical `shortenPubkey` in `lib/pubkey.ts`. Gotcha: `AppShell.tsx` has its own *duplicate* `shortenPubkey` with a different format (8/6 split with a `…` character vs. the canonical 8/8 split with `...`) — **prefer the canonical `lib/pubkey.ts` one.** Auth is a session-style NIP-98 variant: a kind-27235 event signed by the active account, with a `u`-tag equal to `VITE_API_URL`, cached ~10 minutes.
## Routing and auth gating
All routes are declared centrally in `App.tsx` under one `Router` with `root={Layout}`. Protected pages are wrapped in `requireTenant()` (logged in) or `requireAdmin()` (`identity().is_admin`), both built on a `requireCondition` HOC that redirects to `/` when the check fails and waits on the `identity` resource to finish loading. A new gated page must be wired into `App.tsx` with the right wrapper. `AppShell` wraps the authenticated paths (matched by a path regex in `Layout`).
The gate is **rendering-only** (it just conditionally renders the page and client-redirects). Real security is server-side in two distinct layers: (1) **authentication** — the backend decodes the NIP-98 `Authorization` header, verifies the kind-27235 event's signature, and checks the `u`-tag equals `SERVER_URL` for host affinity, recovering the signer pubkey; (2) **authorization** — each handler then calls `require_admin` / `require_tenant` / `require_admin_or_tenant` to compare that pubkey against the configured admin pubkeys or the tenant's pubkey. The `u`-tag/`server_url` check is authentication/host-binding, not the authorization decision. And note: the README's `/login` route does not exist — login is a modal/embedded component, not a route.
## Styling: Tailwind v4 + the brand remap + shared kit
Styling is inline Tailwind v4 utility strings in `class=` — no CSS modules, no `@apply`, no `tailwind.config.js` (config is CSS-first, in `src/index.css`, wired via the `@tailwindcss/vite` plugin).
**THE pitfall:** `index.css` remaps the entire `--color-blue-*` scale to a brown brand palette, so `bg-blue-600`, `text-blue-700`, and `focus:ring-blue-500` render **brown** (`#c18254`) and *are* the intended brand accent. Do not "fix" them to a brown hex, and do reach for blue utilities for any primary accent.
Compose from the shared kit rather than bespoke markup: `PageContainer` (page wrapper), `ResourceState`/`LoadingState` (paired with `createResource` + `useMinLoading`), `Modal` plus `ConfirmDialog`/`PaymentDialog`, and the form primitives `Field`/`ToggleField`/`Checkbox`/`SearchInput`/`ToggleButton`/`RelayForm`. Most transient/global errors and successes go through `setToastMessage(message, variant)` (a single bottom-right `Toast` in `components/Toast.tsx`, mounted once in `App.tsx`; default variant `"error"` = red, `"success"` = green) — prefer it over ad-hoc alert UI. It is not the only error surface, though: contextual/inline errors bypass the toast — e.g. Login's local `error()` signal (`views/Login.tsx`), the payment flow (`PaymentDialog` bolt11 error, `PaymentSetupShell`), resource-load failures (`ResourceState` errorText), relay provisioning/sync errors (`RelayCardHeader`), and the `PromptBanner` banner.
Preline's `HSStaticMethods.autoInit()` is re-run on pathname changes by a `createEffect` in `Layout` (`App.tsx`). However, this codebase does not actually use any `data-hs-*` markup — all dropdowns, tabs, and overlays are hand-rolled with SolidJS signals and manual listeners (see `RelayCardHeader.tsx`, `LoginTabsScreen.tsx`, `PaymentSetup.tsx`). Don't assume `data-hs-*` needs no wiring: the effect fires only on full-path changes (not same-path query/hash navigation), and because SolidJS renders DOM lazily via `<Show>`, any Preline markup that mounts after the effect last ran will be uninitialized. If you do use Preline `data-hs-*` components, wire initialization explicitly rather than relying on this route-level effect.
## Building and verifying a change
There is no lint or test step for the frontend. The verification gate is the **build**, which runs a strict typecheck (`tsc -b`, project references, `noEmit`) then a production bundle (`vite build`). Verify with `just build-frontend` (which runs `bun i && bun run build`) or, inside `frontend/`, `bun run build`. **Bun is the package manager** (`bun.lock`; no npm/yarn/pnpm lockfile) — ignore the README's `npm` instructions. `just build` also compiles the Rust backend, so prefer `build-frontend` to verify only frontend changes.
Three `VITE_*` vars (`VITE_API_URL`, `VITE_RELAY_DOMAIN`, optional `VITE_PLATFORM_NAME`) are read once into named lib constants (`API_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) and baked at build time. A fourth, `VITE_PORT`, is read only by `vite.config.ts` and is undocumented (absent from both `.env.template` and `global.d.ts`). Adding a new client env var means: prefix it `VITE_`, declare it in `src/global.d.ts`, add it to `.env.template`, and (for Docker) wire the placeholder + entrypoint `sed` substitution. The full env/Docker substitution matrix is in [references/build-env-and-docker.md](references/build-env-and-docker.md).
@@ -0,0 +1,53 @@
# Build, TypeScript config, env vars, and Docker (deep detail)
Lookup-depth companion to the SKILL.md "Building and verifying" section.
## Build and typecheck
The only `package.json` scripts are `dev` (`vite`), `build` (`tsc -b && vite build`), and `preview` (`vite preview`). There is no lint or test script — the build *is* the verification gate.
`tsc -b` builds via TypeScript **project references**: the root `tsconfig.json` has `files: []` and references `tsconfig.app.json` (compiles `src`) and `tsconfig.node.json` (compiles `vite.config.ts`). Both set `noEmit: true`, so `tsc -b` is purely a typecheck; the actual emit comes from `vite build`.
The strict flags a change must satisfy (in `tsconfig.app.json`): `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`, plus `verbatimModuleSyntax` and `moduleDetection: force`. JSX is `preserve` with `jsxImportSource: solid-js`. The `@/*``src/*` path alias is declared here (and mirrored in `vite.config.ts`).
## Commands and the package manager
Bun is the package manager: `bun.lock` is present and there is no `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`.
- `just build-frontend``cd frontend && bun i && bun run build` — the canonical way to verify a frontend change compiles.
- `just build``build-backend build-frontend` — also compiles the Rust backend, so it needs a working Rust toolchain. Use `build-frontend` to verify only frontend changes.
- `just dev-frontend``cd frontend && bun run dev`.
- There is **no** frontend lint or test target in the `justfile`; the aggregate `lint`/`test`/`fmt` tasks are backend-only.
## The four VITE_* env vars
| Var | Declared in `global.d.ts`? | In `.env.template`? | Where it's read | Role |
| --- | --- | --- | --- | --- |
| `VITE_API_URL` | yes | yes (`http://127.0.0.1:2892`) | `api.ts``API_URL` | backend base URL for requests; also the NIP-98 `u`-tag value |
| `VITE_RELAY_DOMAIN` | yes | yes (`spaces.coracle.social`) | `subdomain.ts``RELAY_DOMAIN` | relay subdomain suffix; must match the backend's `RELAY_DOMAIN` |
| `VITE_PLATFORM_NAME` | yes (optional) | yes (`Caravel`) | `state.ts``PLATFORM_NAME` | platform display name (title, login, invoice PDF) |
| `VITE_PORT` | **no** | **no** | `vite.config.ts` only (`Number(env.VITE_PORT) || 5173`) | dev-server port; never reaches client code |
Convention: read each client var once at module top-level into a named `const` typed via `global.d.ts`'s `ImportMetaEnv`, rather than scattering `import.meta.env` reads through components. A new client-readable var must be `VITE_`-prefixed, declared in `ImportMetaEnv` in `src/global.d.ts`, and added to `.env.template` with a default. Non-client build-tool vars (like `VITE_PORT`) are read only in `vite.config.ts` via `loadEnv(mode, process.cwd(), '')`.
`global.d.ts` is the single ambient-declarations file: it also declares `Window.HSStaticMethods` (Preline) and `Window.nostr?` (NIP-07). Extend the existing interfaces there rather than re-declaring elsewhere.
## Build-time bake vs. Docker runtime substitution
`VITE_*` values are **baked into the bundle at build time**, so editing `frontend/.env` requires a rebuild to take effect.
In Docker this is sidestepped: the frontend is built once with sentinel placeholder values (`VITE_API_URL=__VITE_API_URL__`, etc.). The container entrypoint then maps runtime env (`SERVER_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) onto those placeholders and `sed`-substitutes them into every `*.js`/`*.html` in `/app/dist` at startup, before serving with `serve -s /app/dist -l 3000`. So one image can be deployed with any config without a rebuild, but runtime config there does *not* require a rebuild — unlike a local `.env` change.
When adding a new build-time-substituted `VITE_` var intended for Docker, mirror the existing pattern: declare the placeholder `ENV` in the build stage and add the matching `sed -e` mapping in the entrypoint.
## Sources
- scripts, no lint/test — `frontend/package.json:6-10`
- project references, `noEmit``frontend/tsconfig.json:1-7`
- strict flags + alias — `frontend/tsconfig.app.json:12-30`
- bun lockfile — `frontend/bun.lock` (no other lockfile present)
- `just` targets — `justfile:11-12,32-43`
- env var declarations — `frontend/src/global.d.ts:17-31`
- env defaults — `frontend/.env.template:1-8`
- `VITE_PORT` dev-server port — `frontend/vite.config.ts:6-18`
- Docker placeholder build + entrypoint `sed``Dockerfile` frontend-build stage and entrypoint
@@ -0,0 +1,60 @@
# Data and state lifecycle (deep detail)
This is the lookup-depth companion to the SKILL.md "data layer" and "global state" sections. It documents the exact mechanics of the API client, NIP-98 auth, the circular-import seam, the session/provisioning lifecycle, and the optimistic-mutation flow. All of it lives in `frontend/src/lib/api.ts`, `state.ts`, and `useRelayToggles.ts`.
## The API envelope and errors
`callApi<TRequest, TResponse>(method, path, body?)` is the single fetch wrapper (`api.ts`). It calls `makeAuth()`, builds the URL with `new URL(path, API_URL)`, sets `Content-Type: application/json` and an `Authorization` header *only when auth is present*, and JSON-stringifies the body — an `undefined` body sends no body at all.
Responses are unwrapped from an `ApiOk<T> = { data: T; code: string }` envelope: `callApi` returns `payload.data`, not the raw payload. A `204` returns `undefined` cast to `TResponse`.
On a non-2xx response it throws `new ApiError(message, response.status)`. `ApiError extends Error` and carries a numeric `status`. The message comes from the JSON body's `error` field when present, otherwise `Request failed (<status>)`.
`listPlans()` deliberately bypasses `callApi`: it does its own raw `fetch` with no auth and unwraps `{ data: Plan[] }` directly. It is the only wrapper that bypasses `callApi`, but not the only public/unauthed endpoint — `/plans` and `/plans/:id` are both public (their backend handlers omit the `AuthedPubkey` extractor); `getPlan` reaches its public route through `callApi`, which omits the `Authorization` header when logged out.
## NIP-98 auth and the 10-minute cache
`makeAuth()` builds a session-style NIP-98 header. It reads the active account via the injected getter (see the DI seam below), and if none is logged in it returns `undefined` — so `callApi` silently sends an *unauthenticated* request rather than failing fast, and authed endpoints then 401 from the backend.
When an account is present it signs a **kind-27235** event with empty content and a single `["u", API_URL]` tag, base64-encodes the JSON, and returns `Nostr <base64>`. The result is cached per-pubkey for **10 minutes** (`expiresAt = now + 10 * 60 * 1000`). Switching accounts invalidates the cache via the pubkey check. Within the cache window the event's `created_at` is fixed at sign time, so freshness relies on the backend's own NIP-98 window.
## The api.ts ↔ state.ts circular-import seam
`api.ts` needs the active account to build auth, but `state.ts` (which owns the `account` signal) imports from `api.ts` — a cycle. It is broken by dependency injection: `api.ts` holds a private module-level `getAccount` and exposes `registerAccountGetter(fn)`; `state.ts` calls `registerAccountGetter(account)` to inject the signal.
Side-effecting initialization in `state.ts` (localStorage hydration + the `accountManager.active$` subscription) is wrapped in `queueMicrotask(...)` to defer it past module evaluation and avoid temporal-dead-zone errors from the cycle. When adding new cross-module state that both files touch, follow the same pattern: a `register*Getter` injection plus deferred init.
## Session and lazy tenant provisioning
The tenant row is created lazily, which drives the whole session ordering.
`ensureSessionTenant()` is idempotent lazy provisioning: it reads the active pubkey, `await`s `createTenant()` (`POST /tenants`), then — only if the active pubkey is still the same — sets `billingPubkey` to unlock billing reads. The in-flight promise is shared per-pubkey via a module-level `tenantEnsure` object, so the login flow and the activation subscriber don't double-provision; `createTenant` is itself idempotent.
The `accountManager.active$` subscriber runs on **every account switch** and performs this ordering: `setAccount(account)` → persist accounts + active id to `localStorage``refetchIdentity()``setBillingPubkey(undefined)` to **lock** billing reads → `void ensureSessionTenant()` to re-unlock once provisioned. Locking first is what stops billing reads from firing against a not-yet-provisioned tenant during signup.
Login's `completeLogin` (`views/Login.tsx`) `await`s `ensureSessionTenant()` and rolls back via `accountManager.removeAccount(...)` if it throws — so a failed provision aborts the login.
This is why the billing resources (`billingTenant`, `billingInvoices`, `billingRelays`, `billingDraftInvoice`) are gated on a `billingPubkey` signal, not on `account()` directly. The gate matters because `getTenant` and `getDraftInvoice` (which use `get_tenant_or_404`) 404 before the row exists; `listTenantInvoices` and `listTenantRelays` instead return empty results, so the gate primarily protects the tenant/draft reads.
`refetchBilling()` refetches all four via `Promise.allSettled`, and on any rejected result it `console.error`s and toasts `"Failed to refresh billing data"`. It is the pure billing refresh that callers invoke after select billing-affecting flows (payment dialog/setup close, paid relay create, `autopayBilling`'s `finally` block) — it is **not** called after every billing-affecting mutation: relay deactivate/reactivate, plan toggles, and plan changes (including downgrade-to-free) in `useRelayToggles` only run the per-relay `refetch()`, and creating a free relay in `Home.tsx` skips it.
## The optimistic mutation flow
The canonical mutation shape (`useRelayToggles.ts`):
1. `mutate(next)` — optimistically push the new value into the resource.
2. `await updateRelayById(...)` — perform the API write.
3. `await refetch()` — reconcile against the server's truth.
4. On error: `mutate(previous)` to roll back, and `setToastMessage(e instanceof Error ? e.message : <fallback>)`.
`setToastMessage` defaults the variant to `"error"`; pass `"success"` for confirmations and `setToastMessage("")` to clear. Rethrow only when a caller needs to react (e.g. the plan-upgrade flow continues into `resolvePostPaidFlow()` for paid upgrades).
## Sources
- API client, envelope, `ApiError`, `listPlans` bypass — `frontend/src/lib/api.ts:9-32,208-248`
- NIP-98 `makeAuth` + 10-minute cache — `frontend/src/lib/api.ts:181-206`
- circular-import DI seam — `frontend/src/lib/api.ts:3-7` and `frontend/src/lib/state.ts:48,168-211`
- billing-resource gating + `refetchBilling``frontend/src/lib/state.ts:74-103`
- `ensureSessionTenant` + activation ordering — `frontend/src/lib/state.ts:149-165,187-208`
- login rollback — `frontend/src/views/Login.tsx:57-67`
- optimistic mutation — `frontend/src/lib/useRelayToggles.ts:27-36`
@@ -0,0 +1,57 @@
# File inventory and route wiring
A concrete map of `frontend/src`, so the SKILL.md "where things live" section can stay prose-only. Use this when you need to find the right existing module instead of guessing.
## Entry points
- `index.tsx` — bootstrap. Imports `index.css`, dynamically imports Preline, then `render`s `<App />`.
- `App.tsx``Router` + central route table + the auth-gate HOCs (see "Routes" below).
- `index.css` — Tailwind v4 CSS-first config and the brand color remap.
- `global.d.ts` — ambient declarations (`ImportMetaEnv`, `Window.HSStaticMethods`, `Window.nostr`).
## `pages/` — route components
- top level: `Home.tsx`, `Account.tsx`
- `relays/` (tenant relay screens): `RelayList`, `RelayNew`, `RelayDetail`, `RelayEdit`
- `admin/` (admin console): `AdminTenantList`, `AdminTenantDetail`, `AdminRelayList`, `AdminRelayDetail`, `AdminRelayEdit`, `AdminInvoiceList`, `AdminInvoiceDetail`
## `views/` — the lone exception
- `Login.tsx` — the login flow, the only thing under `views/` (not `pages/`).
## `components/` — shared UI
- shared kit: `PageContainer`, `ResourceState`, `LoadingState`, `Modal`, `ConfirmDialog`, `PaymentDialog`, `Field`, `ToggleField`, `Checkbox`, `SearchInput`, `ToggleButton`, `RelayForm`, `Toast`, `PromptBanner`, `PricingTable`, `BackLink`, plus small icon components
- `AppShell` — the authenticated layout shell (note: carries a duplicate local `shortenPubkey`; prefer `lib/pubkey.ts`)
- feature subfolders:
- `login/``LoginTabsScreen`, `LoginKeyScreen`, `LoginSignerScreen`, `QrScannerOverlay`
- `payment/``InvoiceItemsList`, `LightningPayBody`
- `account/``InvoiceListItem`, `PaymentMethodRow`
- `relay/``PlanGatedToggle`, `RelayCardHeader`
- plus billing/payment/relay cards at the components top level (`BillingPrompts`, `InvoiceDetailCard`, `RelayDetailCard`, `RelayListItem`, `PaymentSetup*`, `ActivityFeed`, `AdminInvoiceListItem`)
## `lib/` — non-UI logic
- **pillars:** `api.ts` (wire types + fetch wrappers), `state.ts` (singletons + global signals/resources), `hooks.ts` (`use*` read hooks + active-tenant action helpers), `nostr.ts` (`useNostr` DI seam)
- **`use*` hooks:** `useMinLoading`, `useRelayToggles`, `usePaymentSetup`, `useInvoicePdf`
- **pure helpers:** `relayPlanFlow`, `relayFlags`, `paymentMethod`, `loginInput`, `subdomain`, `slugify`, `search`, `format`, `clipboard`, `validation`, `pubkey`, `billing`
## Routes (`App.tsx`)
All declared under one `Router root={Layout}`:
- `/``Home` (ungated)
- `/relays`, `/relays/new`, `/relays/:id`, `/relays/:id/edit``requireTenant(...)`
- `/account``requireTenant(Account)`
- `/admin/tenants`, `/admin/tenants/:id`, `/admin/relays`, `/admin/relays/:id`, `/admin/relays/:id/edit`, `/admin/invoices`, `/admin/invoices/:id``requireAdmin(...)`
`requireTenant(Page)` and `requireAdmin(Page)` are both built on a `requireCondition(Page, condition)` HOC: it waits for the `identity` resource to finish loading, redirects to `/` when the condition is false, and otherwise renders the page inside a `<Show>`. `requireTenant` checks `Boolean(identity())`; `requireAdmin` checks `Boolean(identity()?.is_admin)`. `Layout` wraps the page in `AppShell` when the path matches `/^\/(relays|account|admin)/` and identity has loaded.
There is **no `/login` route** — login is rendered as a modal/embedded component (`views/Login.tsx`, mounted as a `<Modal>` in `pages/Home.tsx`), contrary to `frontend/README.md` (not the repo-root README), which lists a `/login` route.
## Sources
- src tree — verified live listing of `frontend/src`
- route table + guards — `frontend/src/App.tsx:43-81`
- entry bootstrap — `frontend/src/index.tsx:3-13`
- `AppShell` duplicate `shortenPubkey``frontend/src/components/AppShell.tsx:11-14`
+74
View File
@@ -0,0 +1,74 @@
---
name: rebuild-skill
description: Overhaul an existing skill by becoming an expert in what it does, then rewriting it from verified ground truth. Use whenever the user wants to rebuild, improve, fix, refresh, or modernize a specific skill — e.g. "/rebuild-skill billing-model", "this skill is stale, fix it", "make the deploy skill correct again", "the X skill's instructions are wrong". Kicks off a dynamic multi-agent workflow that researches the skill's domain before touching its prose, so improvements rest on what's actually true rather than a copyedit of what was already there.
---
# Rebuild a skill
You are handed the name of an existing skill and asked to make it genuinely better. The argument is the skill name (e.g. `billing-model` from `/rebuild-skill billing-model`). If no name was given, ask which skill to rebuild before going further.
The trap to avoid: treating this as a writing task. A skill encodes how to do something well, so you can only improve it as far as you actually understand the thing it does. A rewrite that polishes the prose but never checks the claims against reality usually makes the skill *worse* — more confident and more wrong. So the order here is deliberate: gain real expertise and gather ground truth first, rewrite from that position second, and verify every claim against its source third.
The bulk of that work fans out across many agents, so this skill runs as a **dynamic Workflow** — "dynamic" because the prerequisite research is shaped by what the skill actually does, which you discover by reading it. You scout inline to find the skill and figure out *what expertise the rebuild needs*, then hand that work-list to a Workflow. Invoking the Workflow tool here is expected — these instructions are your opt-in.
## Step 0 — Locate the skill
Find the skill directory before anything else. Skills live under `.agents/skills`:
```bash
NAME="<the-name-argument>"
find .agents/skills -maxdepth 2 -type d -name "$NAME" 2>/dev/null
```
If exactly one match, that's your target. If none, stop and ask the user — rebuilding the wrong copy wastes the whole run. Note the directory and the `name:` in its frontmatter; both are preserved unchanged through the rebuild.
## Step 1 — Understand it (inline)
Read the target `SKILL.md` end to end, plus every bundled resource (`scripts/`, `references/`, `assets/`). Build a clear picture of:
- **Purpose** — what capability is this supposed to give Claude?
- **Triggering** — when is it meant to fire? Is the description tuned for that, or does it over/under-trigger?
- **Claims** — what factual or procedural assertions does it make? (API behaviors, commands, file layouts, domain rules, step sequences.) These are what you'll verify.
- **Domain** — what does Claude need to be expert in to do this task well? This is the seed for the prerequisite work.
## Step 2 — Scope the prerequisite work (inline)
Decide *what gaining expertise looks like for this particular skill*, and break it into independent facets — one investigable chunk each. The right facets depend entirely on the skill's type:
- A skill that **documents a codebase domain** (like `billing-model`) → trace the actual code paths it describes; confirm each documented rule still matches the implementation.
- A skill that **runs a procedure** (deploy, release, migration) → walk the real procedure and its tools; find steps that are stale, reordered, or missing.
- A skill that **relies on an external API or library** → fetch the authoritative docs and verify every behavior the skill claims. The user expects third-party behavior backed by docs, never asserted from memory.
- A skill that **produces an artifact** (a doc, chart, config) → actually produce one with its current instructions and note where it breaks or falls short.
Most skills are a blend. Aim for 36 facets that together cover the skill's claims and its domain. Each facet should be answerable by one agent reading real sources (code, docs, tools) — not by guessing.
## Step 3 — Run the dynamic workflow
Read [references/workflow-template.js](references/workflow-template.js), adapt the `facets` and prompts to the skill you scoped, and run it with the Workflow tool. Pass the skill path, name, and facets via the `args` field so the script stays generic.
The workflow runs these phases:
1. **Investigate** (fan-out, one agent per facet) — each agent reads real sources to gather ground truth: what is actually true, what the skill currently claims, and exactly where the two diverge. Every finding cites a file path or URL.
2. **Synthesize** — one agent folds the findings and the current skill into a concrete rebuild plan (what to fix, add, cut, restructure), grounded in the verified facts and in skill-writing principles.
3. **Draft** — one agent rewrites `SKILL.md` and any resources in place, preserving the directory and the `name`. It returns the list of claims a reviewer should check, each with its supporting evidence.
4. **Verify** (fan-out, one agent per claim) — each agent adversarially tries to *refute* its claim by going back to the cited source. Anything unsupported, outdated, or contradicted is flagged.
5. **Correct** (only if anything was refuted) — one agent fixes the flagged claims in the files: correct them to match ground truth, or cut them.
The workflow returns the files written and the verification verdicts. This is "ensuring correctness" made concrete: nothing survives in the rebuilt skill that an independent skeptic couldn't confirm against its source.
## Step 4 — Review with the user
Snapshot the original before the workflow writes anything (`cp -r <skill-dir> /tmp/<name>-before`) so you can show a clean diff. After the workflow returns:
- Show `git diff` (or a diff against the snapshot) of the skill files.
- Summarize what changed and the ground truth behind each substantive change — especially anything the verify phase had to correct.
- Surface any claim the workflow *couldn't* confirm rather than quietly keeping it.
If the user wants quantitative confidence that the rebuild triggers and performs better, offer to hand off to the `skill-creator` skill, which runs the eval/benchmark loop. This skill's job is the correctness-grounded rewrite; `skill-creator` is the measurement loop.
## Principles to preserve
- **Expertise before prose.** Never edit a claim you haven't grounded in a real source. The investigate phase exists so the rewrite comes from knowledge, not from rephrasing the old text.
- **Cite or cut.** Every technical claim in the rebuilt skill traces to a file path or URL. If verification can't confirm it, fix it or remove it — don't ship confident guesses.
- **Preserve identity.** Keep the directory name and the frontmatter `name` so the skill stays the same skill. Improve the description for triggering; don't rename the thing.
- **Apply skill-writing craft.** Progressive disclosure (lean `SKILL.md`, detail in `references/`), explain the *why* rather than piling on MUSTs, and a description that states both what it does and when to fire.
@@ -0,0 +1,194 @@
// Dynamic workflow template for the rebuild-skill skill.
//
// Adapt `facets` to the skill being rebuilt (see SKILL.md Step 2), then run with the Workflow
// tool. Keep the script generic and pass per-run values through `args`:
//
// Workflow({
// scriptPath: "<this file, or a copy you edited>",
// args: {
// skillPath: "/abs/path/to/skills/<name>",
// skillName: "<name>",
// facets: [
// { key: "code-paths", prompt: "Trace the billing event-replay code this skill describes ..." },
// { key: "api-docs", prompt: "Fetch the Lightning/LNbits docs and verify the payment-cascade claims ..." },
// // 36 facets that together cover the skill's claims and its domain
// ],
// },
// })
//
// Phase shape: Investigate (fan-out) -> Synthesize -> Draft -> Verify (fan-out) -> Correct (conditional).
// The point of the structure: gather ground truth before rewriting, then let independent skeptics
// confirm every claim against its source. Nothing unverifiable survives.
export const meta = {
name: 'rebuild-skill',
description: 'Investigate a skill\'s domain from real sources, rewrite it from verified ground truth, and adversarially verify every claim',
phases: [
{ title: 'Investigate', detail: 'one agent per facet gathers ground truth from code/docs/tools' },
{ title: 'Synthesize', detail: 'fold findings + current skill into a grounded rebuild plan' },
{ title: 'Draft', detail: 'rewrite SKILL.md and resources in place' },
{ title: 'Verify', detail: 'one skeptic per claim tries to refute it against its source' },
{ title: 'Correct', detail: 'fix or cut any refuted claim (only if needed)' },
],
}
const { skillPath, skillName, facets } = args
const FINDINGS = {
type: 'object',
additionalProperties: false,
required: ['truths', 'divergences'],
properties: {
truths: {
type: 'array',
description: 'Ground-truth facts established by reading real sources.',
items: {
type: 'object',
additionalProperties: false,
required: ['fact', 'source'],
properties: {
fact: { type: 'string' },
source: { type: 'string', description: 'file path with line, or URL' },
},
},
},
divergences: {
type: 'array',
description: 'Places where the skill\'s current text is stale, vague, wrong, or missing.',
items: {
type: 'object',
additionalProperties: false,
required: ['kind', 'skillSays', 'realitySays', 'source'],
properties: {
kind: { type: 'string', enum: ['stale', 'vague', 'wrong', 'missing'] },
skillSays: { type: 'string' },
realitySays: { type: 'string' },
source: { type: 'string' },
},
},
},
},
}
const PLAN = {
type: 'object',
additionalProperties: false,
required: ['changes', 'descriptionAdvice'],
properties: {
changes: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['action', 'what', 'groundedOn'],
properties: {
action: { type: 'string', enum: ['fix', 'add', 'cut', 'restructure'] },
what: { type: 'string' },
groundedOn: { type: 'string', description: 'the verified fact + source this change rests on' },
},
},
},
descriptionAdvice: { type: 'string', description: 'how to tune the frontmatter description for correct triggering' },
},
}
const DRAFT = {
type: 'object',
additionalProperties: false,
required: ['files', 'claims'],
properties: {
files: { type: 'array', items: { type: 'string' }, description: 'absolute paths written' },
claims: {
type: 'array',
description: 'Every technical claim in the rebuilt skill a reviewer should check.',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'claim', 'evidence'],
properties: {
id: { type: 'string' },
claim: { type: 'string' },
evidence: { type: 'string', description: 'file path with line, or URL, supporting the claim' },
},
},
},
},
}
const VERDICT = {
type: 'object',
additionalProperties: false,
required: ['refuted', 'reason'],
properties: {
refuted: { type: 'boolean', description: 'true if the claim is unsupported, outdated, or contradicted by its source' },
reason: { type: 'string' },
shouldSayInstead: { type: 'string', description: 'the correct statement, if refuted' },
},
}
phase('Investigate')
// Fan out: each agent gains the expertise for one facet by reading real sources, never guessing.
const findings = (await parallel(
facets.map(f => () =>
agent(
`You are gaining the expertise needed to judge and rewrite the "${skillName}" skill at ${skillPath}.\n` +
`Facet: ${f.prompt}\n\n` +
`Read the ACTUAL code, docs, and tools — do not speculate or rely on memory for third-party behavior. ` +
`Gather ground truth: what is genuinely true, what the skill currently claims, and every place the two diverge ` +
`(stale, vague, wrong, or missing). Cite a file path (with line) or URL for every fact and every divergence.`,
{ label: `investigate:${f.key}`, phase: 'Investigate', schema: FINDINGS },
).then(r => ({ facet: f.key, ...r })),
),
)).filter(Boolean)
log(`Investigated ${findings.length}/${facets.length} facets; ${findings.reduce((n, f) => n + f.divergences.length, 0)} divergences found.`)
phase('Synthesize')
// One agent needs ALL findings + the current skill together to plan a coherent rebuild — a real barrier.
const plan = await agent(
`Read the current skill at ${skillPath} and these investigation findings:\n${JSON.stringify(findings)}\n\n` +
`Produce a concrete plan to rebuild the skill: what to fix, add, cut, and restructure, with the verified fact ` +
`(and its source) each change rests on. Apply skill-writing principles: progressive disclosure (lean SKILL.md, ` +
`detail in references/), explain the WHY instead of stacking MUSTs, and a description tuned for correct triggering. ` +
`Preserve the directory and the frontmatter \`name\`.`,
{ phase: 'Synthesize', schema: PLAN },
)
phase('Draft')
// Single writer, so no worktree isolation needed. It enumerates the claims it makes for verification.
const draft = await agent(
`Rebuild the "${skillName}" skill at ${skillPath} per this plan:\n${JSON.stringify(plan)}\n\n` +
`Edit SKILL.md and any bundled resources in place; preserve the directory and the frontmatter \`name\`. ` +
`Every technical claim must trace to ground truth from the findings — cite or cut. ` +
`Return the files you wrote and the full set of factual claims a reviewer should check, each with its supporting evidence.`,
{ phase: 'Draft', schema: DRAFT },
)
phase('Verify')
// Fan out: each claim gets an independent skeptic prompted to refute it against its own source.
const verdicts = (await parallel(
draft.claims.map(c => () =>
agent(
`Adversarially check this claim from the rebuilt "${skillName}" skill: "${c.claim}".\n` +
`Evidence cited: ${c.evidence}\n\n` +
`Go read the cited source yourself and TRY TO REFUTE the claim. Default to refuted=true if you cannot ` +
`independently confirm it from the source. If it is unsupported, outdated, or contradicted, say what the skill should say instead.`,
{ label: `verify:${c.id}`, phase: 'Verify', schema: VERDICT },
).then(v => ({ ...c, ...v })),
),
)).filter(Boolean)
const broken = verdicts.filter(v => v.refuted)
log(`Verified ${verdicts.length} claims; ${broken.length} refuted.`)
if (broken.length) {
phase('Correct')
await agent(
`These claims in the rebuilt skill at ${skillPath} were refuted during verification:\n${JSON.stringify(broken)}\n\n` +
`Fix each one in the skill files: correct the statement to match ground truth (use \`shouldSayInstead\`), or cut it. ` +
`Do not introduce any new unverified claim.`,
{ phase: 'Correct', schema: { type: 'object', additionalProperties: false, required: ['fixed'], properties: { fixed: { type: 'array', items: { type: 'string' } } } } },
)
}
return { skillPath, filesWritten: draft.files, claimsChecked: verdicts.length, refuted: broken }
+6
View File
@@ -0,0 +1,6 @@
.git
**/node_modules
**/.env
**/.DS_Store
frontend/dist
backend/target
+3 -12
View File
@@ -6,6 +6,7 @@ on:
env:
REGISTRY: gitea.coracle.social
IMAGE: coracle/caravel
jobs:
build-and-push-image:
@@ -14,16 +15,6 @@ jobs:
contents: read
packages: write
strategy:
matrix:
include:
- component: frontend
image: coracle/caravel-frontend
context: frontend
- component: backend
image: coracle/caravel-backend
context: backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -39,7 +30,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.image }}
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
@@ -52,7 +43,7 @@ jobs:
id: push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
+2
View File
@@ -4,5 +4,7 @@ node_modules
target
data
.env
.claude
**/.env
.playwright-cli
.agents/settings.local.json
+50
View File
@@ -0,0 +1,50 @@
# Style guide
## Comments
Keep comments minimal, one line if possible.
There is one right place to document any given information:
- Functions should have a doc comment explaining the purpose of the function, not its implementation.
- Only very strange behavior should be documented using non-doc comments.
- Models and their fields are documented in models.rs, not in migrations, implementations, or anywhere on the frontend.
- Database indexes are documented in migrations.
## Data Modeling
When naming a foreign key, always use `{model}_{pk}`, for example `relay.tenant_pubkey`.
When referring to a tenant's pubkey, always name it `tenant_pubkey`, not `tenant` or `pubkey`. The exception to this is when we're in a context where we're already talking about tenants, e.g. `tenant.pubkey`, `get_tenant(pubkey)` or any tenant-related routes.
## Migrations
Pre-release: squash schema changes into `0001_init.sql` rather than adding new migration files. Once released, migrations become append-only.
Document indexes (what use cases they support), but not tables (those are documented in `models.rs`).
## Markdown
Do not hard-break markdown files at a certain number of characters. Allow readers to implement line wrapping naturally instead.
## Rust
Prefer `&str` over `&String` for function parameters — `&str` accepts both `&String` (via deref coercion) and string literals, so it's strictly more flexible. Only take `String` when you need ownership (storing in a struct, mutating, or transferring ownership).
Avoid passing `&mut` to functions. The performance improvement often comes at the cost of poor abstraction boundaries and error prone business logic. Instead, return results to the caller which can manage mutability itself, or re-calculate/fetch mutable data.
Don't be overly DRY. Deep call trees are harder to read; factoring functions into many tiny pieces means that function boundaries are defined less by the domain or the responsibility of a given piece of code than by coincidental similarity. New functions should be created when 1. they represent a different concern that is the responsibility of a different part of the codebase, 2. the contained logic is repeated 3+ times, or 3. the contents of the function are complex and naming them makes the logical flow of the code easier to follow.
## Typescript
Don't add re-export shims to preserve old import paths. When something moves, import it from its new canonical location at every call site and update all the imports. Each symbol has exactly one place it's exported from.
## Verification
Check justfile and frontend/package.json for common commands for linting/building.
## Skills
Skills should be created and maintained when updating the codebase. Before creating a skill, check to see if a relevant one already exists.
Avoid including code in skills unless the purpose of the skill is to illustrate coding principles; architecture and domain should be explained in plain English and provide a high-level overview of the topic without going into implementation details.
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
# ---------- Build the Rust backend ----------
FROM rust:1.94-bookworm AS backend-build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
COPY backend/migrations ./migrations
RUN cargo build --release
# ---------- Build the frontend with placeholder config ----------
# The frontend is compiled once, here, with sentinel VITE_* values. The runtime
# entrypoint find-and-replaces those sentinels with the real configuration when
# the container starts, so one image can be deployed with any config — no rebuild
# and no build step at startup.
FROM node:20-slim AS frontend-build
RUN npm install -g bun
WORKDIR /app/frontend
COPY frontend/package.json frontend/bun.lock ./
RUN bun install
COPY frontend ./
ENV VITE_API_URL=__VITE_API_URL__ \
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
RUN bun run build
# ---------- Runtime ----------
# node:20-slim is bookworm-based, so it is ABI-compatible with the backend binary.
# No bun / frontend sources here — just the prebuilt bundle and a static server.
FROM node:20-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g serve
WORKDIR /app
COPY --from=backend-build /app/target/release/backend /app/backend
COPY --from=frontend-build /app/frontend/dist /app/dist
# Single entrypoint: substitute the real config into the prebuilt bundle, then
# run both processes and exit (so the orchestrator restarts us) if either dies.
RUN cat > /app/entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
VITE_API_URL="${SERVER_URL:-}"
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
# Escape characters that are special in a sed replacement.
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
echo "Applying runtime configuration to the frontend bundle..."
while IFS= read -r -d '' f; do
sed -i \
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
"$f"
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
echo "Starting backend (:2892) and frontend (:3000)..."
/app/backend &
backend_pid=$!
serve -s /app/dist -l 3000 &
serve_pid=$!
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
# Exit as soon as either process exits.
wait -n
EOF
RUN chmod +x /app/entrypoint.sh
EXPOSE 2892 3000
CMD ["/app/entrypoint.sh"]
+58 -1
View File
@@ -2,7 +2,64 @@
A multi-tenant platform for hosting Nostr community relays, built on top of [zooid](https://github.com/coracle-social/zooid).
## Quick Start (Local Development)
## Deployment
Caravel ships as a single Docker image, built from the repository-root [`Dockerfile`](./Dockerfile) and published by [`.gitea/workflows/docker-publish.yml`](./.gitea/workflows/docker-publish.yml) as `gitea.coracle.social/coracle/caravel`. One container runs both services: the backend API on port `2892` and the frontend on port `3000`.
Both the backend and the frontend are compiled into the image at build time. The frontend is built with placeholder config that the entrypoint replaces with the real values when the container starts, so one image can be deployed with any configuration — no rebuild required.
Caravel needs a reachable [zooid](https://github.com/coracle-social/zooid) instance (the [Local Development](#local-development) section below shows how to run one). Substitute your own values for the placeholders below:
```sh
docker run -d \
--name caravel \
-p 2892:2892 \
-p 3000:3000 \
-v my-caravel-data:/app/data \
-e PLATFORM_NAME=Caravel \
-e RELAY_DOMAIN=example.com \
-e APP_URL=https://example.com \
-e ZOOID_API_URL=http://zooid:3334 \
-e DATABASE_URL=sqlite://data/caravel.db \
-e SERVER_URL=https://api.example.com \
-e SERVER_PORT=2892 \
-e SERVER_ADMIN_PUBKEYS=<your-hex-pubkey> \
-e SERVER_ALLOW_ORIGINS=https://example.com \
-e ROBOT_SECRET=<hex-nostr-secret-key> \
-e ROBOT_NAME=Caravel \
-e ROBOT_DESCRIPTION="Relay manager bot" \
-e ROBOT_PICTURE=https://example.com/robot.png \
-e ROBOT_WALLET=<nwc-connection-uri> \
-e ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol \
-e ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social \
-e ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub \
-e BLOSSOM_S3_ENDPOINT=https://s3.example.com \
-e BLOSSOM_S3_REGION=us-east-1 \
-e BLOSSOM_S3_BUCKET=caravel-blossom \
-e BLOSSOM_S3_ACCESS_KEY=<s3-access-key> \
-e BLOSSOM_S3_SECRET_KEY=<s3-secret-key> \
-e LIVEKIT_URL=wss://livekit.example.com \
-e LIVEKIT_API_KEY=<livekit-api-key> \
-e LIVEKIT_API_SECRET=<livekit-api-secret> \
-e STRIPE_SECRET_KEY=<stripe-secret-key> \
gitea.coracle.social/coracle/caravel
```
Notes:
- Every backend variable above is **required** — the server exits on startup if any is missing or empty. See [`backend/.env.template`](./backend/.env.template) for what each one means.
- `ROBOT_SECRET` is the robot account's hex nostr secret key; its pubkey must be in zooid's `API_WHITELIST` (the backend signs zooid requests with it via NIP-98).
- `SERVER_ALLOW_ORIGINS` must include the frontend's public origin, or browsers will be blocked by CORS.
- The frontend's `VITE_` prefixed env variables are automatically populated from the provided env variables.
- `-v my-caravel-data:/app/data` persists the SQLite database.
To build the image yourself instead of pulling it:
```sh
docker build -t caravel .
```
## Local Development
### Prerequisites
+4 -2
View File
@@ -1,9 +1,11 @@
# Server
SERVER_HOST=127.0.0.1
SERVER_URL=http://127.0.0.1:2892
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
SERVER_ADMIN_PUBKEYS=
APP_URL=http://localhost:5173
# Frontend
APP_URL=http://127.0.0.1:5173
# Database
DATABASE_URL=sqlite://data/caravel.db
-31
View File
@@ -1,31 +0,0 @@
FROM rust:1.85-bookworm AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend
EXPOSE 2892
CMD ["/app/backend"]
+14 -15
View File
@@ -16,17 +16,16 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
backend/
migrations/
0001_init.sql
spec/ # Module-by-module design notes
src/
main.rs # App bootstrap: load Env, build services, serve + spawn workers
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
web.rs # HTTP response envelope + helpers
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe)
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices)
models.rs # Domain models + sqlite rows
query.rs # Database reads
command.rs # Database writes + activity broadcast
pool.rs # SQLite pool + migrations
db.rs # SQLite pool, migrations, activity broadcast channel
billing.rs # Stripe subscription reconciliation + Lightning collection worker
stripe.rs # Thin Stripe REST client
wallet.rs # NWC wallet handle (NIP-47)
@@ -43,10 +42,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain |
| `SERVER_PORT` | API bind port |
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) |
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
**Robot identity**
@@ -84,18 +84,15 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
**Billing (Stripe)**
| Variable | Description |
| ----------------------- | ----------------------------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
| Variable | Description |
| ------------------- | ----------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
## Schema and Architecture
See [spec](spec) for more details
The database schema lives in [migrations](migrations); see the module-level doc comments in `src/` for architecture details.
## API Routes
@@ -105,7 +102,6 @@ Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin)
@@ -122,16 +118,19 @@ Public exceptions:
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
- `GET /tenants/:pubkey/invoices/draft` — get the tenant's synthetic draft invoice for the current period, or `null` if nothing is due (admin or same tenant)
- `GET /tenants/:pubkey/invoices/draft/items` — list the draft invoice's line items (admin or same tenant)
- `GET /invoices/:id` — get invoice (admin or same tenant)
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
- `GET /invoices/:id/items` — list invoice line items (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (same tenant only)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`.
- Backend verifies event kind, signature, and that `u` equals configured `SERVER_URL`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+23 -1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS invoice (
created_at INTEGER NOT NULL,
paid_at INTEGER,
voided_at INTEGER,
notified_at INTEGER,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
@@ -68,6 +69,7 @@ CREATE TABLE IF NOT EXISTS invoice_item (
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
voided_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
@@ -86,7 +88,21 @@ CREATE TABLE IF NOT EXISTS bolt11 (
CREATE TABLE IF NOT EXISTS intent (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
payment_method_id TEXT NOT NULL,
payment_intent_id TEXT,
created_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE TABLE IF NOT EXISTS checkout (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
session_id TEXT NOT NULL,
url TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
@@ -105,9 +121,15 @@ CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_a
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_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL AND voided_at IS NULL;
-- At most one line item per billable activity to ensure no double-billing.
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
CREATE INDEX IF NOT EXISTS idx_checkout_invoice_created ON checkout (invoice_id, created_at);
-- At most one unsettled write-ahead intent per invoice: enforces the invariant
-- and is the ON CONFLICT target for the get-or-create in `ensure_pending_intent`.
CREATE UNIQUE INDEX IF NOT EXISTS idx_intent_unsettled ON intent (invoice_id) WHERE settled_at IS NULL;
+26 -12
View File
@@ -18,8 +18,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow, ensure};
use axum::{
Router,
async_trait,
Router, async_trait,
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
routing::{get, post},
@@ -32,18 +31,21 @@ use crate::env;
use crate::models::{Relay, Tenant};
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::routes::identity::get_identity;
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, get_tenant_latest_invoice};
use crate::routes::invoices::{
ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices,
reconcile_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::tenants::{
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
list_tenants, update_tenant,
create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items,
list_tenant_invoices, list_tenant_relays, list_tenants, reconcile_tenant, update_tenant,
};
use crate::stripe::Stripe;
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
#[derive(Clone)]
@@ -73,19 +75,28 @@ impl Api {
.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/draft", get(get_draft_invoice))
.route(
"/tenants/:pubkey/invoices/latest",
get(get_tenant_latest_invoice),
"/tenants/:pubkey/invoices/draft/items",
get(list_draft_invoice_items),
)
.route("/tenants/:pubkey/reconcile", post(reconcile_tenant))
.route(
"/tenants/:pubkey/stripe/session",
get(create_stripe_session),
)
.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("/invoices", get(list_invoices))
.route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
.route("/invoices/:id/reconcile", post(reconcile_invoice))
.route("/invoices/:id/checkout", post(ensure_invoice_checkout))
.route("/invoices/:id/bolt11", post(ensure_invoice_bolt11))
.route("/invoices/:id/items", get(list_invoice_items))
.with_state(api)
}
@@ -175,7 +186,7 @@ impl Api {
ensure!(event.kind == Kind::HttpAuth, "invalid nip98 kind");
event.verify()?;
let got_u = event
let actual_u = event
.tags
.iter()
.filter_map(|tag| {
@@ -185,7 +196,10 @@ impl Api {
.last()
.ok_or_else(|| anyhow!("missing u tag"))?;
ensure!(got_u == env::get().server_host, "authorization host mismatch");
ensure!(
actual_u == env::get().server_url,
"authorization host mismatch"
);
Ok(event.pubkey.to_hex())
}
+318 -120
View File
@@ -5,7 +5,7 @@ use crate::bitcoin;
use crate::command;
use crate::env;
use crate::models::{
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
};
use crate::query;
use crate::robot::Robot;
@@ -14,9 +14,13 @@ use crate::wallet::Wallet;
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60;
/// Hold the manual-payment DM until an open invoice is at least this old. A freshly
/// issued invoice is surfaced to the tenant in-app first (e.g. right after they
/// create a relay), so we don't also nag by DM on the first dunning cycles.
const FRESH_INVOICE_DM_GRACE_SECS: i64 = 24 * 60 * 60;
const MANUAL_PAYMENT_DM_INTERVAL_SECS: i64 = 12 * 24 * 60 * 60;
const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
const USER_ERROR_PREFIX: &str = "Auto-payment failed:";
const USER_ERROR_MAX_CHARS: usize = 240;
const CHURN_DM: &str = "Your relay subscription is past due, so your relays have been paused. You can restore service any time by adding a payment method or paying an invoice from your dashboard:";
/// Owns subscription billing: it reconciles tenant activity into invoice items,
/// renews subscriptions each period, and collects payment (Lightning, then a
@@ -54,10 +58,13 @@ impl Billing {
async fn reconcile_subscriptions(&self) -> Result<()> {
let tenants = query::list_tenants().await?;
tracing::info!(tenant_count = tenants.len(), "reconciling all subscriptions");
tracing::info!(
tenant_count = tenants.len(),
"reconciling all subscriptions"
);
for tenant in tenants {
if let Err(error) = self.reconcile_subscription(&tenant).await {
if let Err(error) = self.reconcile_subscription(&tenant, true).await {
tracing::error!(
tenant = %tenant.pubkey,
error = ?error,
@@ -73,19 +80,24 @@ impl Billing {
/// Reconciles a tenant's billing: re-activates them if a churned tenant has
/// re-engaged, folds billable activity into line items (setting the billing
/// anchor on the first), renews the current period if due, claims outstanding
/// items onto an invoice, and then collects every open invoice — churning the
/// tenant if one has gone unpaid past the grace period.
pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
/// anchor based on the first one), renews the current period if due, and claims
/// outstanding items onto an invoice.
pub async fn reconcile_subscription(
&self,
tenant: &Tenant,
attempt_payment: bool,
) -> Result<()> {
let mut tenant = tenant.clone();
let activities = query::list_billable_activity(&tenant.pubkey).await?;
let mut activities = query::list_billable_activity(&tenant.pubkey).await?;
// A churned tenant with fresh billable activity is using the service
// again: re-activate billing (and restore their relays) before billing it.
// Reactivation records a billable activity for each restored relay; fold
// those into this pass so their prorated charges land on the same invoice.
if tenant.churned_at.is_some() && !activities.is_empty() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::reactivate_tenant(&tenant.pubkey, &relays).await?;
activities.extend(command::reactivate_tenant(&tenant.pubkey, &relays).await?);
tenant.churned_at = None;
}
@@ -110,9 +122,26 @@ impl Billing {
command::create_invoice(&tenant, &period).await?;
}
// Retry payment on every open invoice (this also pays one just created),
// churning the tenant if the oldest has aged past the grace period.
self.collect_open_invoices(&tenant).await?;
// Fetch the tenant's open invoices once
let invoices = query::list_open_invoices(&tenant.pubkey).await?;
// If the tenant is past due, churn them
if self.maybe_churn_tenant(&tenant, &invoices).await? {
return Ok(());
}
// If we're going to try to collect, make sure we have an updated payment method
if attempt_payment {
tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?;
}
// Reconcile out-of-band payments (and, when collecting, charge) on every
// open invoice. The out-of-band checks run even when attempt_payment is
// false, so a checkout or bolt11 paid out of band settles on any reconcile.
for invoice in &invoices {
self.reconcile_payments(&tenant, invoice, attempt_payment, true)
.await?;
}
Ok(())
}
@@ -127,12 +156,12 @@ impl Billing {
self.make_prorated_item(tenant, activity, 1, "New relay created")
.await?
}
"activate_relay" => {
"activate_relay" | "unmark_relay_delinquent" => {
self.make_prorated_item(tenant, activity, 1, "Relay reactivated")
.await?
}
"deactivate_relay" => {
self.make_prorated_item(tenant, activity, -1, "Relay deactivated (prorated credit)")
self.make_prorated_item(tenant, activity, -1, "Relay deactivated")
.await?
}
"update_relay" => self.make_plan_change_item(tenant, activity).await?,
@@ -140,7 +169,7 @@ impl Billing {
};
match invoice_item {
Some(ref item) => command::insert_invoice_item_for_activity(&item, &activity.id).await,
Some(ref item) => command::insert_invoice_item_for_activity(item, &activity.id).await,
None => command::mark_activity_billed(&activity.id).await,
}
}
@@ -176,6 +205,7 @@ impl Billing {
amount,
description: description.to_string(),
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -222,6 +252,7 @@ impl Billing {
amount,
description,
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -242,7 +273,11 @@ impl Billing {
else {
continue;
};
let Snapshot::Relay { plan: plan_id, status, .. } = &*activity.snapshot;
let Snapshot::Relay {
plan: plan_id,
status,
..
} = &*activity.snapshot;
if status != RELAY_STATUS_ACTIVE {
continue;
}
@@ -260,6 +295,7 @@ impl Billing {
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period.start,
voided_at: None,
});
}
@@ -268,48 +304,115 @@ impl Billing {
command::insert_invoice_items_for_renewal(&line_items, period).await
}
// --- Payments ---
// --- Auto-churn ---
/// Collect an invoice via NWC, then a saved card, then a manual DM. A failing
/// method's error is stored on the tenant (to warn them in the UI) but never
/// aborts the cascade or future retries; a method's error is cleared when it
/// next succeeds.
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let mut error_message: Option<String> = None;
/// Churn a tenant whose oldest open invoice has blown past the grace period:
/// pause their relays and DM them once, on the transition into churn. Returns
/// whether the tenant is past due, so the caller can skip collecting this pass.
async fn maybe_churn_tenant(&self, tenant: &Tenant, invoices: &[Invoice]) -> Result<bool> {
let Some(oldest) = invoices.first() else {
return Ok(false);
};
// 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}")),
let now = chrono::Utc::now().timestamp();
if now - oldest.created_at < GRACE_PERIOD_SECS {
return Ok(false);
}
// Past due. Churn once (the guard fires a single time on the transition)
// and notify the tenant, logging and continuing on DM failure.
if tenant.churned_at.is_none() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
}
}
// 2. Out-of-band lightning: catches partially failed NWC or manual payment
Ok(true)
}
// --- Payments ---
/// Collect an invoice. We check the out-of-band rails first — a Lightning
/// invoice or Checkout session the tenant may have already paid — and only
/// then initiate a fresh charge (NWC, then a saved card), so a payment that's
/// already in flight is never duplicated. Falling all the way through sends a
/// manual-payment DM (when `notify`). A failing charge's error is stored on
/// the tenant (to warn them in the UI) but never aborts the cascade or future
/// retries; it's cleared when a method next succeeds. Caller-initiated
/// payments pass `notify = false` to skip the dunning DM, since the failure
/// is already surfaced on screen.
pub async fn reconcile_payments(
&self,
tenant: &Tenant,
invoice: &Invoice,
autopay: bool,
notify: bool,
) -> Result<()> {
let mut error_message: Option<String> = None;
// 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a
// manual QR scan, or an NWC pay that completed but failed to record).
// Checked before any charge so we never bill on top of it.
if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await?
&& bolt11.settled_at.is_none()
&& self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false)
{
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
return self.cleanup_pending_payments(invoice).await;
}
// 2. Hosted Checkout: settle an invoice the tenant paid (and
// authenticated) on a Stripe Checkout session that has since completed.
// Also checked before any charge so we never bill on top of it.
if let Some(checkout) = query::get_checkout_for_invoice(&invoice.id).await?
&& checkout.settled_at.is_none()
&& self
.stripe
.is_checkout_paid(&checkout.session_id)
.await
.unwrap_or(false)
{
command::settle_invoice_via_checkout(&tenant.pubkey, &checkout.id, &invoice.id).await?;
return self.cleanup_pending_payments(invoice).await;
}
if !autopay {
return Ok(());
}
// 3. 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?
{
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
if !tenant.nwc_url.is_empty() {
match self.attempt_payment_using_nwc(tenant, invoice).await {
Ok(()) => return self.cleanup_pending_payments(invoice).await,
Err(e) => error_message = Some(format!("{e}")),
}
}
// 4. Payment method on file: charge the tenant's cached Stripe payment
// method, kept fresh by sync_stripe_payment_method before collection.
if let Some(payment_method) = &tenant.stripe_payment_method_id {
match self
.attempt_payment_using_stripe(tenant, invoice, &payment_method)
.attempt_payment_using_stripe(tenant, invoice, payment_method)
.await
{
Ok(()) => return Ok(()),
Ok(()) => return self.cleanup_pending_payments(invoice).await,
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
}
}
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
let summary = error_message.as_deref().and_then(summarize_error_message);
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
if !notify {
return Ok(());
}
// 5. Manual payment: DM a link to the in-app payment page for this invoice.
if let Err(e) = self
.attempt_payment_using_dm(tenant, invoice, error_message)
.await
{
tracing::error!(
tenant = %tenant.pubkey,
error = %e,
@@ -324,7 +427,7 @@ impl Billing {
let result: Result<()> = async {
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
let bolt11 = self.ensure_bolt11(invoice).await?;
let bolt11 = self.ensure_bolt11_for_invoice(invoice).await?;
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
@@ -347,71 +450,98 @@ impl Billing {
invoice: &Invoice,
payment_method_id: &str,
) -> Result<()> {
let result: Result<()> = async {
let intent_id = self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
payment_method_id,
&invoice.id,
invoice.amount,
"usd",
)
.await?;
let intent = command::ensure_pending_intent(&invoice.id, payment_method_id).await?;
command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await
}
.await;
let payment_intent_id = match self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
&intent.payment_method_id,
&invoice.id,
invoice.amount,
"usd",
)
.await
{
Ok(id) => id,
// Drop the attempt so the next pass retries cleanly on the tenant's
// current method, and record the failure to warn the user in the UI.
Err(error) => {
command::delete_intent(&intent.id).await?;
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
return Err(error);
}
};
// Record the failure on the tenant (to warn them in the UI) but still
// surface it, so the cascade can fall through and summarize it in the DM.
if let Err(error) = &result {
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
}
result
command::settle_invoice_via_intent(
&tenant.pubkey,
&intent.id,
&payment_intent_id,
&invoice.id,
)
.await
}
async fn attempt_payment_using_dm(
&self,
tenant: &Tenant,
invoice: &Invoice,
error_message: Option<String>,
error: Option<String>,
) -> Result<()> {
let now = chrono::Utc::now().timestamp();
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
if now - invoice.created_at < FRESH_INVOICE_DM_GRACE_SECS {
return Ok(());
}
// The dunning poll runs hourly; avoid excessive reminder DMs.
if invoice
.notified_at
.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS)
{
return Ok(());
}
// Build the message
let invoice_id = &invoice.id;
let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match error_message {
Some(error_message) if !error_message.is_empty() => {
format!("{base}\n\n{USER_ERROR_PREFIX} {error_message}")
let dm_message = match error {
Some(error) if !error.is_empty() => {
let limit: usize = 240;
let summary = error
.chars()
.take(limit.saturating_sub(3))
.collect::<String>();
format!("{base}\n\nAuto-payment failed: {summary}")
}
_ => base,
};
self.robot.send_dm(&tenant.pubkey, &dm_message).await
// Send via NIP 17
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
// Record the send to avoid spammy notifications.
command::mark_invoice_notified(invoice_id).await
}
// --- Dunning ---
/// Dunning pass over a tenant's open invoices: if the oldest has been unpaid
/// past the grace period, churn the tenant; otherwise retry payment on each.
async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> {
let open = query::list_open_invoices(&tenant.pubkey).await?;
let Some(oldest) = open.first() else {
return Ok(());
};
let now = chrono::Utc::now().timestamp();
if now - oldest.created_at >= GRACE_PERIOD_SECS {
if tenant.churned_at.is_none() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
/// Run after an invoice is settled to invalidate out-of-band payment methods
/// so the tenant can't pay twice. Only Stripe Checkout sessions can actually
/// be invalidated (by expiring them); Lightning/NWC has no such mechanism.
/// The session that just paid (if any) was marked settled in its settle
/// transaction, so it's excluded here and not needlessly expired.
async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> {
for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? {
if let Err(error) = self.stripe.expire_checkout_session(&checkout.session_id).await {
tracing::debug!(
invoice = %invoice.id,
checkout = %checkout.id,
error = %error,
"could not expire checkout session"
);
}
return Ok(());
}
for invoice in &open {
self.attempt_payment(tenant, invoice).await?;
}
Ok(())
@@ -419,9 +549,17 @@ impl Billing {
// --- Bolt11 utils ---
pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Bolt11> {
let now = chrono::Utc::now().timestamp();
// A resolved (paid or voided) invoice must never mint a new payable
// bolt11; return the existing one if present, otherwise refuse.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return query::get_bolt11_for_invoice(&invoice.id)
.await?
.ok_or_else(|| anyhow!("invoice is not open"));
}
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
&& (existing.settled_at.is_none() || now < existing.expires_at)
{
@@ -438,22 +576,88 @@ impl Billing {
.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 record;
/// otherwise return it unchanged. Meant to run before presenting a payable
/// invoice so we never hand back one that's already been paid.
pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
let bolt11 = self.ensure_bolt11(invoice).await?;
// --- Checkout utils ---
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
/// Idempotently produce a hosted Stripe Checkout session for an open invoice,
/// reusing an unsettled, unexpired one if present — the on-session card
/// counterpart to [`Self::ensure_bolt11_for_invoice`]. Checkout lets the
/// tenant clear a 3D Secure challenge the off-session card charge can't. On
/// success Stripe returns the tenant to their account page, where collection
/// is reconciled (here and on the dunning poll) once the session reads paid.
pub async fn ensure_checkout_for_invoice(
&self,
tenant: &Tenant,
invoice: &Invoice,
) -> Result<Checkout> {
let now = chrono::Utc::now().timestamp();
// Re-fetch so the caller sees that it's been settled.
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
} else {
Ok(bolt11)
// Never open a Checkout for an invoice that's already resolved.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return Err(anyhow!("invoice is not open"));
}
// Reuse a still-valid pending session so repeated clicks land on one page.
if let Some(existing) = query::get_checkout_for_invoice(&invoice.id).await?
&& now < existing.expires_at
{
if existing.settled_at.is_some() {
return Err(anyhow!(
"a checkout has already been settled for this invoice"
));
}
return Ok(existing);
}
// Stripe returns the tenant to their account page on success or cancel.
// The landing page reconciles the tenant, which now settles a paid
// Checkout out of band, so the URL needs no per-invoice marker.
let return_url = format!("{}/account", env::get().app_url);
let (session_id, url, expires_at) = self
.stripe
.create_checkout_session(
&tenant.stripe_customer_id,
&invoice.id,
invoice.amount,
"usd",
&return_url,
&return_url,
)
.await?;
command::insert_checkout(&invoice.id, &session_id, &url, expires_at)
.await?
.ok_or_else(|| anyhow!("failed to insert checkout"))
}
// --- Stripe utils ---
/// Refresh stripe-related state for a tenant, returning the synced payment
/// method id (the tenant's existing one on failure). Fails gracefully.
pub async fn sync_stripe_customer(&self, tenant: &Tenant) -> Result<Option<String>> {
match self.sync_stripe_payment_method(tenant).await {
Ok(payment_method_id) => Ok(payment_method_id),
Err(error) => {
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
Ok(tenant.stripe_payment_method_id.clone())
}
}
}
/// Refresh the cached Stripe payment method from Stripe so collection can charge
/// it directly and the UI reflects cards added via the portal.
async fn sync_stripe_payment_method(&self, tenant: &Tenant) -> Result<Option<String>> {
let payment_method_id = self
.stripe
.get_saved_payment_method(&tenant.stripe_customer_id)
.await?;
if payment_method_id != tenant.stripe_payment_method_id {
command::set_tenant_stripe_payment_method(&tenant.pubkey, &payment_method_id).await?;
}
Ok(payment_method_id)
}
}
@@ -468,7 +672,7 @@ pub struct BillingPeriod {
impl BillingPeriod {
/// The period containing `chrono::Utc::now()` for `tenant`. `None` when the
/// tenant has no `billing_anchor` yet — i.e. no billable activity has been seen.
fn current(tenant: &Tenant) -> Option<Self> {
pub fn current(tenant: &Tenant) -> Option<Self> {
Self::at(tenant, chrono::Utc::now().timestamp())
}
@@ -493,7 +697,13 @@ impl BillingPeriod {
months += 1;
}
let end = start.checked_add_months(Months::new(1)).unwrap_or(start);
// Derive `end` from the anchor (the next walk boundary), not `start + 1
// month`: month-add clamps to the last valid day, so re-adding from a
// clamped `start` lands short of `anchor + months` and leaves non-tiling
// gap days for day-2931 anchors. `months` is already that next boundary.
let end = anchor_dt
.checked_add_months(Months::new(months))
.unwrap_or(start);
Some(Self {
start: start.timestamp(),
@@ -502,13 +712,17 @@ impl BillingPeriod {
}
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// prorating a mid-period charge or credit.
/// prorating a mid-period charge or credit. The remaining time is rounded to
/// the nearest hour so proration tracks whole hours rather than exact seconds.
fn fraction_remaining(&self, at: i64) -> f64 {
const HOUR: i64 = 60 * 60;
let len = (self.end - self.start) as f64;
if len <= 0.0 {
return 1.0;
}
(((self.end - at) as f64) / len).clamp(0.0, 1.0)
let remaining = ((self.end - at) + HOUR / 2) / HOUR * HOUR;
(remaining as f64 / len).clamp(0.0, 1.0)
}
/// Prorate a minor-unit `amount` by the fraction of this period remaining
@@ -517,19 +731,3 @@ impl BillingPeriod {
(amount as f64 * self.fraction_remaining(at)).round() as i64
}
}
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)
}
+1 -2
View File
@@ -27,8 +27,7 @@ pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
body
.data
body.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
+367 -165
View File
@@ -5,8 +5,8 @@ use sqlx::{Sqlite, Transaction};
use crate::billing::BillingPeriod;
use crate::db::{pool, publish, with_tx};
use crate::models::{
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
Activity, Bolt11, Checkout, Intent, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE,
RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
};
// --- Tenants ---
@@ -25,8 +25,10 @@ pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
Ok(())
}
/// Update a tenant's NWC credentials, clearing any stored NWC error so a fresh
/// wallet starts from a clean slate (it re-errors on the next charge if invalid).
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
sqlx::query("UPDATE tenant SET nwc_url = ?, nwc_error = NULL WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(pool())
@@ -34,6 +36,24 @@ pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
Ok(())
}
/// Cache the tenant's Stripe payment method id (or clear it with `None`) and clear
/// any stored Stripe error. Called when a card is (re)attached via the portal or
/// detected during reconciliation, so collection can charge it directly and the UI
/// reflects the change.
pub async fn set_tenant_stripe_payment_method(
pubkey: &str,
payment_method_id: &Option<String>,
) -> Result<()> {
sqlx::query(
"UPDATE tenant SET stripe_payment_method_id = ?, stripe_error = NULL WHERE pubkey = ?",
)
.bind(payment_method_id)
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
.bind(tenant.billing_anchor)
@@ -70,9 +90,13 @@ pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Re
let mut activities = Vec::new();
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE {
let activity =
set_relay_status_tx(tx, relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
.await?;
let activity = set_relay_status_tx(
tx,
relay,
RELAY_STATUS_DELINQUENT,
"mark_relay_delinquent",
)
.await?;
activities.push(activity);
}
}
@@ -90,8 +114,10 @@ pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Re
}
/// Atomically re-activate a churned tenant: clear the churn marker, restore every
/// delinquent relay to active, and void any still-open invoices.
pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<()> {
/// delinquent relay to active, and void any still-open invoices. Returns the
/// `unmark_relay_delinquent` activities recorded for the restored relays, so the
/// caller can fold their prorated charges into the same reconcile pass.
pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<Vec<Activity>> {
let activities = with_tx(async |tx| {
set_tenant_churned_at_tx(tx, tenant_pubkey, None).await?;
@@ -111,10 +137,10 @@ pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<
})
.await?;
for activity in activities {
publish(activity);
for activity in &activities {
publish(activity.clone());
}
Ok(())
Ok(activities)
}
// --- Relays ---
@@ -252,7 +278,10 @@ pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
/// Persist a reconciled activity's line item and mark the activity billed in one
/// transaction, so a recovery pass never re-bills it.
pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activity_id: &str) -> Result<()> {
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| {
@@ -282,12 +311,11 @@ pub async fn insert_invoice_items_for_renewal(
with_tx(async |tx| {
// Re-read the marker inside the transaction so the guard and the writes
// commit together — this ensures idempotency so we don't double-invoice.
let renewed_at = sqlx::query_scalar::<_, Option<i64>>(
"SELECT renewed_at FROM tenant WHERE pubkey = ?",
)
.bind(tenant_pubkey)
.fetch_one(&mut **tx)
.await?;
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(());
@@ -322,28 +350,30 @@ pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
// --- Invoices ---
/// Claim all of a tenant's outstanding items onto a new invoice. A non-positive
/// balance leaves the items outstanding so the credit carries to the next positive
/// invoice. Returns the invoice, or `None` when there's nothing to bill.
/// Claim a tenant's outstanding items onto a new invoice once the balance clears
/// the minimum.
pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> 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",
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&tenant.pubkey)
.fetch_one(&mut **tx)
.await?;
if total <= 0 {
// Stripe's minimum charge is $0.50 USD; $1 leaves margin so a later
// small credit can't drop a fresh invoice under that floor. Leave
// items outstanding and carry to a later invoice.
if total <= 100 {
return Ok(None);
}
let invoice = insert_invoice_tx(tx, &tenant, &period, total).await?;
let invoice = insert_invoice_tx(tx, tenant, period, total).await?;
sqlx::query(
"UPDATE invoice_item SET invoice_id = ?
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&invoice.id)
.bind(&tenant.pubkey)
@@ -357,6 +387,16 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
// --- Payment settlement ---
/// Atomically record a Lightning settlement that happened out of band.
pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> {
with_tx(async |tx| {
mark_bolt11_settled_tx(tx, bolt11_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "oob").await?;
Ok(())
})
.await
}
/// Atomically record an NWC-settled invoice: clear the tenant's stored NWC error,
/// mark the bolt11 settled, and mark the invoice paid.
pub async fn settle_invoice_via_nwc(
@@ -367,35 +407,62 @@ pub async fn settle_invoice_via_nwc(
with_tx(async |tx| {
clear_tenant_nwc_error_tx(tx, tenant_pubkey).await?;
mark_bolt11_settled_tx(tx, bolt11_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "nwc").await
mark_invoice_paid_tx(tx, invoice_id, "nwc").await?;
Ok(())
})
.await
}
/// Atomically record a Lightning settlement that happened out of band.
pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> {
with_tx(async |tx| {
mark_bolt11_settled_tx(tx, bolt11_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "oob").await
})
.await
}
/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear
/// the tenant's stored Stripe error, and mark the invoice paid.
pub async fn settle_invoice_via_stripe(
/// Atomically settle an invoice paid off-session: stamp the write-ahead intent
/// with the Stripe PaymentIntent that confirmed it, clear the tenant's stored
/// Stripe error, and mark the invoice paid. `intent_id` is our row id (from
/// [`insert_pending_intent`]); `payment_intent_id` is the Stripe `pi_…`.
pub async fn settle_invoice_via_intent(
tenant_pubkey: &str,
intent_id: &str,
payment_intent_id: &str,
invoice_id: &str,
) -> Result<()> {
with_tx(async |tx| {
insert_intent_tx(tx, intent_id, invoice_id).await?;
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
mark_invoice_paid_tx(tx, invoice_id, "stripe").await
mark_intent_settled_tx(tx, intent_id, payment_intent_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
Ok(())
})
.await
}
/// Atomically record an invoice paid via a hosted Checkout session: stamp the
/// checkout settled, clear the tenant's stored Stripe error, and mark the invoice
/// paid. The checkout was inserted unsettled by [`insert_checkout`]. `checkout_id`
/// is our row id, not the Stripe Checkout Session id.
pub async fn settle_invoice_via_checkout(
tenant_pubkey: &str,
checkout_id: &str,
invoice_id: &str,
) -> Result<()> {
with_tx(async |tx| {
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
mark_checkout_settled_tx(tx, checkout_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
Ok(())
})
.await
}
/// Stamp an invoice with the time its manual-payment reminder DM was sent, so
/// dunning sends that DM once instead of on every hourly poll.
pub async fn mark_invoice_notified(invoice_id: &str) -> Result<()> {
let notified_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE invoice SET notified_at = ? WHERE id = ?")
.bind(notified_at)
.bind(invoice_id)
.execute(pool())
.await?;
Ok(())
}
// --- Bolt11 records ---
pub async fn insert_bolt11(
@@ -421,8 +488,38 @@ pub async fn insert_bolt11(
.await?)
}
// --- Checkout records ---
/// Record a pending Stripe Checkout session for an invoice, returning the stored
/// [`Checkout`]. Mirrors [`insert_bolt11`]: created unsettled with our own id,
/// then stamped by [`settle_invoice_via_checkout`] once the session is paid.
pub async fn insert_checkout(
invoice_id: &str,
session_id: &str,
url: &str,
expires_at: i64,
) -> Result<Option<Checkout>> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Checkout>(
"INSERT INTO checkout (id, invoice_id, session_id, url, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(session_id)
.bind(url)
.bind(created_at)
.bind(expires_at)
.fetch_optional(pool())
.await?)
}
// --- Internal utils that take an explicit transaction ---
// --- Activities ---
async fn insert_activity_tx(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
@@ -469,49 +566,6 @@ async fn insert_activity_tx(
})
}
async fn insert_invoice_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant: &Tenant,
period: &BillingPeriod,
amount: i64,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
let invoice_id = uuid::Uuid::new_v4().to_string();
Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(invoice_id)
.bind(&tenant.pubkey)
.bind(amount)
.bind(&period.start)
.bind(period.end)
.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_id, 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_id)
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Claim an activity as billed. Returns `true` if this call set the marker, and
/// `false` if it was already set — e.g. a concurrent reconcile pass won the race —
/// so callers can skip work that would otherwise double-bill.
@@ -520,77 +574,16 @@ async fn mark_activity_billed_tx(
activity_id: &str,
billed_at: i64,
) -> Result<bool> {
let result = sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ? AND billed_at IS NULL")
.bind(billed_at)
.bind(activity_id)
.execute(&mut **tx)
.await?;
let result =
sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ? AND billed_at IS NULL")
.bind(billed_at)
.bind(activity_id)
.execute(&mut **tx)
.await?;
Ok(result.rows_affected() > 0)
}
/// Set a relay's status (and flag it for re-sync), recording the matching
/// activity. Returns the activity so the caller can `publish` it after the
/// enclosing transaction commits.
async fn set_relay_status_tx(
tx: &mut Transaction<'_, Sqlite>,
relay: &Relay,
status: &str,
activity_type: &str,
) -> Result<Activity> {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: status.to_string(),
};
insert_activity_tx(tx, activity_type, &relay.id, snapshot).await
}
async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, 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(&mut **tx)
.await?;
Ok(())
}
async fn mark_invoice_paid_tx(
tx: &mut Transaction<'_, Sqlite>,
invoice_id: &str,
method: &str,
) -> Result<()> {
let paid_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?")
.bind(method)
.bind(paid_at)
.bind(invoice_id)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Void all of a tenant's open invoices, forgiving the balance — used when a
/// tenant churns or re-activates, so old debt never has to be collected.
async fn void_open_invoices_tx(tx: &mut Transaction<'_, Sqlite>, tenant_pubkey: &str) -> Result<()> {
let voided_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE invoice SET voided_at = ?
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
}
// --- Tenants ---
/// Set or clear the tenant's churn marker. Set when an invoice ages past the
/// grace period, cleared when billing is re-activated.
@@ -615,7 +608,10 @@ async fn clear_tenant_nwc_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &st
Ok(())
}
async fn clear_tenant_stripe_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> {
async fn clear_tenant_stripe_error_tx(
tx: &mut Transaction<'_, Sqlite>,
pubkey: &str,
) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&mut **tx)
@@ -623,23 +619,229 @@ async fn clear_tenant_stripe_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey:
Ok(())
}
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
/// PaymentIntent id, so it's idempotent.
async fn insert_intent_tx(
tx: &mut Transaction<'_, Sqlite>,
intent_id: &str,
invoice_id: &str,
) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
// --- Relays ---
sqlx::query(
"INSERT INTO intent (id, invoice_id, created_at)
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
/// Set a relay's status (and flag it for re-sync), recording the matching
/// activity. Returns the activity so the caller can `publish` it after the
/// enclosing transaction commits.
async fn set_relay_status_tx(
tx: &mut Transaction<'_, Sqlite>,
relay: &Relay,
status: &str,
activity_type: &str,
) -> Result<Activity> {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: status.to_string(),
};
insert_activity_tx(tx, activity_type, &relay.id, snapshot).await
}
// --- Invoices ---
async fn insert_invoice_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant: &Tenant,
period: &BillingPeriod,
amount: i64,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
let invoice_id = uuid::Uuid::new_v4().to_string();
Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(intent_id)
.bind(invoice_id)
.bind(created_at)
.bind(&tenant.pubkey)
.bind(amount)
.bind(period.start)
.bind(period.end)
.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_id, amount, description, created_at, voided_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice_id)
.bind(&item.activity_id)
.bind(&item.tenant_pubkey)
.bind(&item.relay_id)
.bind(&item.plan_id)
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.bind(item.voided_at)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Mark an invoice paid, but only while it is still open — a late Lightning
/// payment never flips a voided/forgiven invoice to paid, and a Stripe-paid
/// invoice never has its provenance overwritten by a later bolt11.
async fn mark_invoice_paid_tx(
tx: &mut Transaction<'_, Sqlite>,
invoice_id: &str,
method: &str,
) -> Result<()> {
let paid_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE invoice SET method = ?, paid_at = ?
WHERE id = ? AND paid_at IS NULL AND voided_at IS NULL",
)
.bind(method)
.bind(paid_at)
.bind(invoice_id)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Void all of a tenant's open invoices and unpaid line items, forgiving the
/// balance — used when a tenant churns or re-activates, so old debt never has to
/// be collected. Voiding the items too (both outstanding ones and those on the
/// just-voided invoices) keeps a credit from bleeding into a future invoice and
/// lets a re-billed period start from a clean ledger. Items on a paid invoice are
/// left untouched.
async fn void_open_invoices_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant_pubkey: &str,
) -> Result<()> {
let voided_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE invoice SET voided_at = ?
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
// Run after voiding the invoices above, so the `paid_at IS NULL` subquery
// catches their now-voided items along with the still-outstanding ones.
sqlx::query(
"UPDATE invoice_item SET voided_at = ?
WHERE tenant_pubkey = ? AND voided_at IS NULL
AND (invoice_id IS NULL OR invoice_id IN (
SELECT id FROM invoice WHERE paid_at IS NULL
))",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
}
// --- Bolt11 ---
/// Stamp a bolt11 as settled but don't overwrite an existing settled_at.
async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, bolt11_id: &str) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ? AND settled_at IS NULL")
.bind(settled_at)
.bind(bolt11_id)
.execute(&mut **tx)
.await?;
Ok(())
}
// --- Checkouts ---
/// Stamp a checkout as settled but don't overwrite an existing settled_at, so a
/// re-reconcile of the same session is a no-op. Keyed by our row id.
async fn mark_checkout_settled_tx(
tx: &mut Transaction<'_, Sqlite>,
checkout_id: &str,
) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE checkout SET settled_at = ? WHERE id = ? AND settled_at IS NULL")
.bind(settled_at)
.bind(checkout_id)
.execute(&mut **tx)
.await?;
Ok(())
}
// --- Intents ---
/// Write-ahead an off-session charge attempt before confirming it with Stripe,
/// returning the stored [`Intent`]. Records the payment method so a retry after a
/// lost settle re-confirms the same (idempotent) PaymentIntent; settled later by
/// [`settle_invoice_via_intent`], or dropped by [`delete_intent`] if it declines.
///
/// Get-or-create, atomically: if the invoice already has an unsettled intent,
/// returns it unchanged (keeping its original `payment_method_id`, so the retry
/// re-confirms the same charge); otherwise inserts a fresh one. The partial
/// unique index on (invoice_id) WHERE settled_at IS NULL makes this race-free —
/// concurrent reconciles converge on one intent instead of two.
pub async fn ensure_pending_intent(invoice_id: &str, payment_method_id: &str) -> Result<Intent> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Intent>(
"INSERT INTO intent (id, invoice_id, payment_method_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(invoice_id) WHERE settled_at IS NULL
DO UPDATE SET payment_method_id = intent.payment_method_id
RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(payment_method_id)
.bind(created_at)
.fetch_one(pool())
.await?)
}
/// Stamp an off-session intent settled with the Stripe PaymentIntent that
/// confirmed it, but don't overwrite an existing settled_at — so reconciling the
/// same attempt twice is a no-op.
async fn mark_intent_settled_tx(
tx: &mut Transaction<'_, Sqlite>,
intent_id: &str,
payment_intent_id: &str,
) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE intent SET settled_at = ?, payment_intent_id = ?
WHERE id = ? AND settled_at IS NULL",
)
.bind(settled_at)
.bind(payment_intent_id)
.bind(intent_id)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Drop a write-ahead intent whose charge didn't go through, so the next attempt
/// starts clean (on the tenant's current method). A charge that confirmed but
/// whose settle failed is instead left unsettled, for reconcile to re-confirm.
pub async fn delete_intent(intent_id: &str) -> Result<()> {
sqlx::query("DELETE FROM intent WHERE id = ?")
.bind(intent_id)
.execute(pool())
.await?;
Ok(())
}
+2 -2
View File
@@ -21,7 +21,7 @@ pub fn get() -> &'static Env {
#[derive(Clone)]
pub struct Env {
pub server_host: String,
pub server_url: String,
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
@@ -55,7 +55,7 @@ impl Env {
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_host: require_str("SERVER_HOST"),
server_url: require_str("SERVER_URL"),
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
+10 -2
View File
@@ -74,7 +74,11 @@ async fn reconcile_relay_state(source: &str) -> Result<()> {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
tracing::info!(
source,
relay_count = relays.len(),
"reconciling pending relay state"
);
for relay in relays {
if relay.sync_error.trim().is_empty() {
@@ -229,7 +233,11 @@ async fn try_sync_relay(relay: &Relay) -> Result<()> {
);
}
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
let method = if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
};
request(method, &format!("relay/{}", relay.id), Some(&body)).await?;
Ok(())
}
+1 -1
View File
@@ -2,10 +2,10 @@ pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod db;
pub mod env;
pub mod infra;
pub mod models;
pub mod db;
pub mod query;
pub mod robot;
pub mod routes;
+6 -9
View File
@@ -2,10 +2,10 @@ mod api;
mod billing;
mod bitcoin;
mod command;
mod db;
mod env;
mod infra;
mod models;
mod db;
mod query;
mod robot;
mod routes;
@@ -16,7 +16,7 @@ mod web;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use crate::api::Api;
use crate::billing::Billing;
@@ -61,13 +61,10 @@ async fn main() -> Result<()> {
billing.start().await;
});
let listener =
tokio::net::TcpListener::bind(format!(
"{}:{}",
env::get().server_host,
env::get().server_port
))
.await?;
let url = format!("127.0.0.1:{}", env::get().server_port);
let listener = tokio::net::TcpListener::bind(url).await?;
axum::serve(listener, app).await?;
Ok(())
}
+71 -29
View File
@@ -39,7 +39,8 @@ pub struct Tenant {
pub nwc_url: String,
/// Last NWC auto-payment error, or `None` when the wallet last paid (or has
/// never been tried). Surfaced in the UI to warn the user; it never blocks a
/// retry — the next reconcile attempts payment again regardless.
/// retry — the next reconcile attempts payment again regardless. Also cleared
/// when the tenant updates their NWC credentials.
pub nwc_error: Option<String>,
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
pub stripe_error: Option<String>,
@@ -122,40 +123,81 @@ impl Default for Relay {
/// balance forgiven when the tenant churns).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice {
pub id: String,
pub tenant_pubkey: String,
/// The total owed, fixed when the invoice is cut from its outstanding line
/// items, so collection never has to re-sum them.
pub amount: i64,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
pub id: String,
pub tenant_pubkey: String,
/// The total owed, fixed when the invoice is cut from its outstanding line
/// items, so collection never has to re-sum them.
pub amount: i64,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
/// When the manual-payment reminder DM was sent for this invoice, or `None` if
/// it hasn't been sent in order to avoid duplicate reminders for the same invoice.
pub notified_at: Option<i64>,
/// How the invoice was paid — `nwc`, `stripe`, or `oob` (out-of-band
/// Lightning) — set when it is marked paid; `None` while open or void.
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
/// `None` while outstanding; set once the item is claimed onto an invoice.
pub invoice_id: Option<String>,
/// `None` for renewal items, which have no source activity.
pub activity_id: Option<String>,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan_id: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
pub id: String,
/// `None` while outstanding; set once the item is claimed onto an invoice.
pub invoice_id: Option<String>,
/// `None` for renewal items, which have no source activity.
pub activity_id: Option<String>,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan_id: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
/// Set when the item is forgiven — the tenant churned or reactivated — so it
/// is never billed or carried into a later invoice; `None` while live. Applies
/// whether or not the item has been claimed onto an invoice.
pub voided_at: Option<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>,
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>,
}
/// A hosted Stripe Checkout session opened to pay an invoice on-session (so a 3D
/// Secure challenge can be cleared), shaped like [`Bolt11`]: created pending and
/// stamped `settled_at` once paid. `id` is our uuid; `session_id` is the Stripe
/// Checkout Session (`cs_…`), used to reconcile and expire it; `url` is the
/// hosted page we redirect the tenant to.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Checkout {
pub id: String,
pub invoice_id: String,
pub session_id: String,
pub url: String,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
/// A write-ahead record of an off-session card charge: inserted before the Stripe
/// call and stamped `settled_at` once the charge confirms and the invoice is paid.
/// `payment_method_id` is the method it charges, so a retry after a lost settle
/// re-confirms the same (idempotent) PaymentIntent rather than charging a second
/// one; `payment_intent_id` is the Stripe `pi_…` that settled it.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent {
pub id: String,
pub invoice_id: String,
pub payment_method_id: String,
pub payment_intent_id: Option<String>,
pub created_at: i64,
pub settled_at: Option<i64>,
}
+75 -26
View File
@@ -1,7 +1,7 @@
use anyhow::{Result, anyhow};
use crate::models::{Activity, Bolt11, Invoice, Plan, Relay, Tenant};
use crate::db::pool;
use crate::models::{Activity, Bolt11, Checkout, Invoice, InvoiceItem, Plan, Relay, Tenant};
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
@@ -62,10 +62,12 @@ pub async fn list_tenants() -> Result<Vec<Tenant>> {
}
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?)
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(pool())
.await?,
)
}
// --- Relays ---
@@ -85,10 +87,12 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
}
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?,
)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
@@ -119,13 +123,23 @@ pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option
// --- 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?)
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>> {
pub async fn list_invoices() -> Result<Vec<Invoice>> {
Ok(
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice ORDER BY created_at DESC")
.fetch_all(pool())
.await?,
)
}
pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
)
@@ -134,12 +148,28 @@ pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
.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",
/// The line items claimed onto an invoice, oldest first. Used to render an
/// invoice's contents (and its downloadable copy) from what was actually billed.
pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item WHERE invoice_id = ? ORDER BY created_at ASC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?)
}
/// A tenant's outstanding line items — created but not yet claimed onto an
/// invoice — oldest first. These are exactly what `create_invoice` would bill,
/// and what a draft invoice presents before the balance is cut.
pub async fn list_unbilled_invoice_items(tenant_pubkey: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_optional(pool())
.fetch_all(pool())
.await?)
}
@@ -158,13 +188,6 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
// --- Bolt11 ---
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
.bind(bolt11_id)
.fetch_optional(pool())
.await?)
}
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>(
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
@@ -174,6 +197,31 @@ pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>>
.await?)
}
// --- Checkouts ---
/// The most recent Checkout session for an invoice, regardless of `settled_at`,
/// so a session can still be expired on Stripe after we've locally marked it
/// settled. Mirrors [`get_bolt11_for_invoice`]; callers gate on `settled_at`.
pub async fn get_checkout_for_invoice(invoice_id: &str) -> Result<Option<Checkout>> {
Ok(sqlx::query_as::<_, Checkout>(
"SELECT * FROM checkout WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
/// Every still-pending (unsettled) Checkout session for an invoice — the ones to
/// expire on Stripe once the invoice has been paid another way.
pub async fn list_pending_checkouts_for_invoice(invoice_id: &str) -> Result<Vec<Checkout>> {
Ok(sqlx::query_as::<_, Checkout>(
"SELECT * FROM checkout WHERE invoice_id = ? AND settled_at IS NULL ORDER BY created_at DESC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?)
}
// --- Activity ---
/// Billable activity for a tenant not yet folded into an invoice. The
@@ -185,7 +233,8 @@ pub async fn list_billable_activity(tenant_pubkey: &str) -> Result<Vec<Activity>
"WHERE tenant_pubkey = ?
AND billed_at IS NULL
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay',
'unmark_relay_delinquent'
)
ORDER BY created_at ASC",
))
+16 -9
View File
@@ -43,10 +43,7 @@ impl Robot {
Ok(client)
}
async fn publish_identity(
&self,
) -> Result<()> {
async fn publish_identity(&self) -> Result<()> {
let mut metadata = Metadata::new();
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
@@ -65,7 +62,8 @@ impl Robot {
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = env::get().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<_>, _>>()?;
@@ -73,7 +71,8 @@ impl Robot {
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = env::get().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<_>, _>>()?;
@@ -100,7 +99,9 @@ impl Robot {
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.make_client(&dm_relays).await?;
client.send_private_msg(recipient_pubkey, message, []).await?;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
Ok(())
}
@@ -132,8 +133,14 @@ 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(&env::get().robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let client = self
.make_client(&env::get().robot_indexer_relays)
.await
.ok()?;
let events = client
.fetch_events(filter, Duration::from_secs(5))
.await
.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
+94 -19
View File
@@ -6,26 +6,16 @@ use crate::api::{Api, AuthedPubkey};
use crate::query;
use crate::web::{ApiResult, internal, not_found, ok};
/// 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(
pub async fn list_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
api.require_admin(&auth)?;
api.billing.reconcile_subscription(&tenant).await.map_err(internal)?;
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
ok(invoice)
ok(query::list_invoices().await.map_err(internal)?)
}
/// Read a single invoice
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -41,9 +31,47 @@ pub async fn get_invoice(
ok(invoice)
}
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
/// needed and first settling it if it was already paid out of band.
pub async fn get_invoice_bolt11(
/// Reconcile and collect an open invoice
pub async fn reconcile_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
// Nothing to collect on an already-resolved invoice.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return ok(invoice);
}
let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?;
api.billing
.ensure_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
api.billing
.reconcile_payments(&tenant, &invoice, true, false)
.await
.map_err(internal)?;
// Re-read so the caller sees the possibly now-paid invoice.
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
ok(invoice)
}
/// Idempotently create a payable Lightning invoice (bolt11)
pub async fn ensure_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
@@ -57,9 +85,56 @@ pub async fn get_invoice_bolt11(
let bolt11 = api
.billing
.ensure_and_reconcile_bolt11(&invoice)
.ensure_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
ok(serde_json::json!(bolt11))
ok(bolt11)
}
/// Open a hosted Stripe Checkout session to pay a single open invoice by card,
/// returning the URL to redirect the tenant to. Unlike the off-session card
/// charge, Checkout can satisfy a 3D Secure authentication challenge; the
/// resulting payment is reconciled by `reconcile_invoice` (or the dunning poll).
pub async fn ensure_invoice_checkout(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?;
let checkout = api
.billing
.ensure_checkout_for_invoice(&tenant, &invoice)
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": checkout.url }))
}
/// The line items billed on an invoice
pub async fn list_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let items = query::list_invoice_items_for_invoice(&invoice_id)
.await
.map_err(internal)?;
ok(items)
}
+19 -17
View File
@@ -9,14 +9,12 @@ use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::{command, infra, query};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::web::{
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
parse_bool_default, unprocessable,
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
unprocessable,
};
use crate::{command, infra, query};
pub async fn list_relays(
State(api): State<Arc<Api>>,
@@ -196,10 +194,7 @@ pub async fn update_relay(
if plan_changed {
let selected_plan = query::get_plan(&relay.plan_id).map_err(internal)?;
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&relay)
.await
.map_err(internal)?
.len() as i64;
let current_members = fetch_relay_members(&relay).await.map_err(internal)?.len() as i64;
if current_members > limit {
let message = format!(
@@ -231,12 +226,13 @@ pub async fn deactivate_relay(
}
if relay.status == RELAY_STATUS_INACTIVE {
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
return Err(bad_request(
"relay-is-inactive",
"relay is already inactive",
));
}
command::deactivate_relay(&relay)
.await
.map_err(internal)?;
command::deactivate_relay(&relay).await.map_err(internal)?;
ok(())
}
@@ -282,15 +278,21 @@ static SUBDOMAIN_RE: LazyLock<Regex> =
/// premium features, and coerce the boolean columns to 0/1.
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str())
{
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = query::get_plan(&relay.plan_id)
.map_err(|_| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
if (!plan.blossom && relay.blossom_enabled == 1)
|| (!plan.livekit && relay.livekit_enabled == 1)
{
return Err(unprocessable(
"premium-feature",
"feature requires a paid plan",
));
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
+102 -13
View File
@@ -8,7 +8,8 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::billing::BillingPeriod;
use crate::models::{Invoice, Tenant};
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
@@ -56,6 +57,19 @@ pub async fn list_tenants(
.collect::<Vec<_>>())
}
/// Fetch a tenant by pubkey.
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
/// Create the tenant row for the calling pubkey and provision its Stripe
/// customer. Idempotent: an existing tenant (including one created by a
/// concurrent unique-constraint race) is returned as-is.
@@ -99,16 +113,6 @@ pub async fn create_tenant(
}
}
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
@@ -136,6 +140,7 @@ pub async fn update_tenant(
ok(TenantResponse::from(tenant))
}
/// List a tenant's relays.
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -149,7 +154,7 @@ pub async fn list_tenant_relays(
ok(relays)
}
/// List a tenant's invoices, most recent first.
/// List a tenant's invoices.
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -157,13 +162,97 @@ pub async fn list_tenant_invoices(
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let invoices = query::list_invoices(&pubkey)
let invoices = query::list_invoices_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(invoices)
}
/// Reconcile a tenant's subscription
pub async fn reconcile_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing
.sync_stripe_customer(&tenant)
.await
.map_err(internal)?;
api.billing
.reconcile_subscription(&tenant, false)
.await
.map_err(internal)?;
// Re-read so the response reflects the synced method and any billing anchor.
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
/// outstanding line items for the current period. It mirrors what `create_invoice`
/// would bill once the balance turns positive.
pub async fn get_draft_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let draft = match BillingPeriod::current(&tenant) {
Some(period) => {
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
if items.is_empty() {
None
} else {
Some(Invoice {
id: "draft".to_string(),
amount: items.iter().map(|item| item.amount).sum(),
tenant_pubkey: tenant.pubkey,
period_start: period.start,
period_end: period.end,
created_at: Utc::now().timestamp(),
paid_at: None,
voided_at: None,
notified_at: None,
method: None,
})
}
}
None => None,
};
ok(draft)
}
/// The outstanding line items behind a tenant's draft invoice — the current
/// period's not-yet-billed charges. Mirrors `list_invoice_items` for a real
/// invoice (the draft's sentinel id can't be looked up there) so the UI can
/// itemize the draft in the same PDF.
pub async fn list_draft_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
ok(items)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
+77 -3
View File
@@ -104,8 +104,10 @@ impl Stripe {
/// 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.
/// The charge is made idempotent on `invoice_id` and `payment_method_id`,
/// so a retried collection against the same method reuses the same charge
/// instead of billing twice, while a fall-back to a different method issues
/// a distinct charge instead of colliding on the original key.
pub async fn create_payment_intent(
&self,
customer_id: &str,
@@ -119,13 +121,14 @@ impl Stripe {
.post("/payment_intents")
.header(
"Idempotency-Key",
self.idempotency_key(&["payment_intent", invoice_id]),
self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]),
)
.form(&[
("amount", amount.as_str()),
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("metadata[invoice_id]", invoice_id),
("off_session", "true"),
("confirm", "true"),
])
@@ -146,6 +149,77 @@ impl Stripe {
.ok_or_else(|| anyhow!("missing payment intent id"))
}
// --- Checkout ---
/// Open a hosted Stripe Checkout session that charges `amount` (in the
/// currency's minor units) for a single invoice on-session, so the customer
/// can satisfy a 3D Secure authentication that an off-session saved-card
/// charge can't. Returns the session id, its hosted URL, and its expiry. The
/// session and the PaymentIntent it creates both carry `invoice_id` in
/// metadata so the charge is traceable back to our ledger.
pub async fn create_checkout_session(
&self,
customer_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
success_url: &str,
cancel_url: &str,
) -> Result<(String, String, i64)> {
let amount = amount.to_string();
let body = self
.post("/checkout/sessions")
.form(&[
("mode", "payment"),
("customer", customer_id),
("success_url", success_url),
("cancel_url", cancel_url),
("line_items[0][quantity]", "1"),
("line_items[0][price_data][currency]", currency),
("line_items[0][price_data][unit_amount]", amount.as_str()),
(
"line_items[0][price_data][product_data][name]",
"Relay subscription",
),
("payment_intent_data[metadata][invoice_id]", invoice_id),
("metadata[invoice_id]", invoice_id),
])
.send_json()
.await?;
let session_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing checkout session id"))?;
let url = body["url"]
.as_str()
.ok_or_else(|| anyhow!("missing checkout session url"))?;
let expires_at = body["expires_at"]
.as_i64()
.ok_or_else(|| anyhow!("missing checkout session expiry"))?;
Ok((session_id.to_string(), url.to_string(), expires_at))
}
/// Whether a Checkout session has been paid. Used to reconcile an invoice
/// once the customer returns from (or later completes) the hosted page.
pub async fn is_checkout_paid(&self, session_id: &str) -> Result<bool> {
let body = self
.get(&format!("/checkout/sessions/{session_id}"))
.send_json()
.await?;
Ok(body["payment_status"].as_str() == Some("paid"))
}
/// Expire a Checkout session so it can no longer be completed. Used to close
/// out a still-open session once its invoice has been paid another way,
/// preventing a double charge. Errors if the session isn't open (already
/// completed or expired), which the caller treats as best-effort.
pub async fn expire_checkout_session(&self, session_id: &str) -> Result<()> {
self.post(&format!("/checkout/sessions/{session_id}/expire"))
.send_ok()
.await?;
Ok(())
}
// --- Portal ---
/// Open a Stripe billing-portal session for the customer, returning the URL
+3 -1
View File
@@ -14,7 +14,9 @@ pub struct Wallet {
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self { url: url.parse::<NostrWalletConnectURI>()? })
Ok(Self {
url: url.parse::<NostrWalletConnectURI>()?,
})
}
pub async fn make_invoice(
+5 -1
View File
@@ -71,7 +71,11 @@ pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
}
pub fn unauthorized(reason: impl Display) -> ApiError {
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
err(
StatusCode::UNAUTHORIZED,
"unauthorized",
&reason.to_string(),
)
}
pub fn forbidden(message: &str) -> ApiError {
+3
View File
@@ -1,5 +1,8 @@
# Backend API base URL
VITE_API_URL=http://127.0.0.1:2892
# Domain that relay subdomains live under (must match the backend's RELAY_DOMAIN)
VITE_RELAY_DOMAIN=spaces.coracle.social
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
-21
View File
@@ -1,21 +0,0 @@
FROM node:20-slim
RUN npm install -g bun
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install
COPY . .
ARG VITE_API_URL
ARG VITE_PLATFORM_NAME
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_PLATFORM_NAME=$VITE_PLATFORM_NAME
RUN bun run build
EXPOSE 3000
CMD ["npx", "serve", "dist", "-s"]
+1
View File
@@ -33,6 +33,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default |
|---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
| `VITE_RELAY_DOMAIN` | Domain relay subdomains live under (match backend `RELAY_DOMAIN`) | `spaces.coracle.social` |
## Running
+21 -14
View File
@@ -14,7 +14,10 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
import AdminRelayList from "@/pages/admin/AdminRelayList"
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
import { identity } from "@/lib/state"
import AdminInvoiceList from "@/pages/admin/AdminInvoiceList"
import AdminInvoiceDetail from "@/pages/admin/AdminInvoiceDetail"
import { account, eventStore, identity, pool } from "@/lib/state"
import { NostrProvider } from "@/lib/nostr"
function Layout(props: { children?: any }) {
const location = useLocation()
@@ -58,18 +61,22 @@ export default function App() {
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
return (
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
</Router>
<NostrProvider value={{ account, eventStore, pool }}>
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
<Route path="/admin/invoices" component={requireAdmin(AdminInvoiceList)} />
<Route path="/admin/invoices/:id" component={requireAdmin(AdminInvoiceDetail)} />
</Router>
</NostrProvider>
)
}
@@ -0,0 +1,59 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type AdminInvoiceListItemProps = {
invoice: Invoice
href: string
showTenant?: boolean
}
export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch,
// so this subscription does not prime on its own.
const metadata = useProfileMetadata(() => (props.showTenant ? props.invoice.tenant_pubkey : undefined), { prime: false })
const status = () => invoiceStatus(props.invoice)
return (
<li>
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-900">{formatUsd(props.invoice.amount)}</p>
<p class="text-xs text-gray-500">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</p>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</div>
</Show>
</div>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
</A>
</li>
)
}
+13 -94
View File
@@ -1,18 +1,12 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import Fuse from "fuse.js"
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
import { invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
import { account, eventStore, identity } from "@/lib/state"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, identity } from "@/lib/state"
import { fuzzySearch } from "@/lib/search"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import serverIcon from "@/assets/server.svg"
import Modal from "@/components/Modal"
import PaymentDialog from "@/components/PaymentDialog"
type Profile = {
name?: string
display_name?: string
nip05?: string
}
import BillingPrompts from "@/components/BillingPrompts"
function shortenPubkey(pubkey?: string) {
if (!pubkey) return ""
@@ -35,74 +29,25 @@ function RelayIcon() {
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
const [tenant] = useTenant()
const metadata = useProfileMetadata(() => account()?.pubkey)
const [tenantRelays] = useTenantRelays()
const [profile, setProfile] = createSignal<Profile>({})
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [pastDueInvoice, setPastDueInvoice] = createSignal<Invoice | undefined>()
const [showPaymentDialog, setShowPaymentDialog] = createSignal(false)
createEffect(async () => {
const t = tenant()
if (!t?.churned_at) {
setPastDueInvoice(undefined)
return
}
try {
const invoices = await listTenantInvoices(t.pubkey)
const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open")
setPastDueInvoice(openInvoice)
} catch {
// ignore
}
})
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => metadata()?.nip05)
const searchedRelays = createMemo<Relay[]>(() => {
const list = tenantRelays() ?? []
const query = searchQuery().trim()
if (!query) return list
const fuse = new Fuse(list, {
keys: ["info_name", "subdomain"],
threshold: 0.35,
ignoreLocation: true,
})
return fuse.search(query).map((result) => result.item)
})
createEffect(() => {
const pubkey = account()?.pubkey
if (!pubkey) {
setProfile({})
return
}
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
setProfile({
name: metadata?.name,
display_name: metadata?.display_name,
nip05: metadata?.nip05,
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
profileSub.unsubscribe()
primeSub.unsubscribe()
})
return fuzzySearch(list, ["info_name", "subdomain"], query)
})
const myResources = [{ href: "/relays", label: "My Relays" }]
const adminResources = [
{ href: "/admin/tenants", label: "Tenants" },
{ href: "/admin/relays", label: "Relays" },
{ href: "/admin/invoices", label: "Invoices" },
]
const navItemClass = (href: string) => {
@@ -158,36 +103,10 @@ export default function AppShell(props: { children?: any }) {
</aside>
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
<Show when={tenant()?.churned_at}>
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
<span>Your account is past due and some relays are paused. Update your payment method to restore service.</span>
<Show when={pastDueInvoice()}>
<button
type="button"
onClick={() => setShowPaymentDialog(true)}
class="font-medium underline hover:no-underline"
>
Pay now
</button>
</Show>
</div>
</Show>
<BillingPrompts variant="banner" />
<main>{props.children}</main>
</div>
<Show when={pastDueInvoice() && showPaymentDialog()}>
{(_) => {
const invoice = pastDueInvoice()!
return (
<PaymentDialog
invoice={invoice}
open={true}
onClose={() => setShowPaymentDialog(false)}
/>
)
}}
</Show>
<nav
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
@@ -269,7 +188,7 @@ export default function AppShell(props: { children?: any }) {
onClick={closeSearchModal}
>
<p class="text-sm font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-xs text-gray-500">{relay.subdomain}.spaces.coracle.social</p>
<p class="text-xs text-gray-500">{relay.subdomain}.{RELAY_DOMAIN}</p>
</A>
</li>
)}
+15 -2
View File
@@ -1,14 +1,27 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
type BackLinkProps = {
href: string
// Omit to pop the previous history entry instead of navigating to a fixed
// route — for pages reachable from several places, "back" should land the
// user wherever they came from.
href?: string
label: string
}
export default function BackLink(props: BackLinkProps) {
return (
<div class="flex items-center gap-2 mb-6">
<A href={props.href} class="text-gray-500 hover:text-gray-700"> {props.label}</A>
<Show
when={props.href}
fallback={
<button type="button" onClick={() => history.back()} class="text-gray-500 hover:text-gray-700">
{props.label}
</button>
}
>
{(href) => <A href={href()} class="text-gray-500 hover:text-gray-700"> {props.label}</A>}
</Show>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, type Invoice } from "@/lib/api"
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
import { account, billingFlowActive } from "@/lib/state"
type BillingPromptsProps = {
// "banner" sits in the dashboard shell (mounted on every page except the
// billing page); "inline" is shown contextually on the billing page itself.
// Only one is ever mounted at a time, so each can own its own modals + deep link.
variant?: "banner" | "inline"
}
export default function BillingPrompts(props: BillingPromptsProps) {
const status = useBillingStatus()
const [dismissed, setDismissed] = createSignal<Set<BillingPromptKind>>(new Set())
const [payInvoice, setPayInvoice] = createSignal<Invoice | undefined>()
const [setupOpen, setSetupOpen] = createSignal(false)
const [setupTab, setSetupTab] = createSignal<"nwc" | "card">("nwc")
// Deep link: /...?invoice=<id> (e.g. from the billing DM) opens the payment
// dialog on whatever dashboard page the link lands on.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinked] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
const prompt = createMemo(() =>
activeBillingPrompt(
{
tenant: status.tenant(),
openInvoice: status.openInvoice(),
hasPaidSubscription: status.hasPaidSubscription(),
},
{ suppressInline: billingFlowActive() },
),
)
const visiblePrompt = createMemo(() => {
const p = prompt()
if (!p || dismissed().has(p.kind)) return undefined
return p
})
function openSetup(tab: "nwc" | "card") {
setSetupTab(tab)
setSetupOpen(true)
}
const actions = createMemo<PromptBannerAction[]>(() => {
const p = visiblePrompt()
if (!p) return []
const open = status.openInvoice()
switch (p.kind) {
case "churned":
return open
? [
{ label: "Pay now", onClick: () => setPayInvoice(open) },
{ label: "Update payment method", onClick: () => openSetup("nwc") },
]
: [{ label: "Update payment method", onClick: () => openSetup("nwc") }]
case "pay_invoice":
return open ? [{ label: "Pay now", onClick: () => setPayInvoice(open) }] : []
case "update_method":
return [{ label: "Update payment method", onClick: () => openSetup(status.tenant()?.nwc_error ? "nwc" : "card") }]
case "setup_autopay":
return [{ label: "Set up autopay", onClick: () => openSetup("nwc") }]
}
return []
})
const dismissible = () => visiblePrompt()?.kind === "setup_autopay"
function dismiss() {
const p = visiblePrompt()
if (p) setDismissed((prev) => new Set([...prev, p.kind]))
}
function clearDeepLink() {
if (searchParams.invoice) setSearchParams({ invoice: undefined })
}
// After paying or saving a method, reconcile + sync + (auto)collect, then
// refresh — so a card added inside the dialog actually settles the invoice.
function refreshBilling() {
const pubkey = account()?.pubkey
if (pubkey) void status.autopay(pubkey)
}
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
return (
<>
<Show when={visiblePrompt()}>
{(p) => (
<PromptBanner
severity={p().severity}
message={p().message}
actions={actions()}
onDismiss={dismissible() ? dismiss : undefined}
class={outerClass()}
/>
)}
</Show>
{/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={() => {
const wasDeepLink = !payInvoice()
setPayInvoice(undefined)
if (wasDeepLink) clearDeepLink()
refreshBilling()
}}
/>
)}
</Show>
<PaymentSetup
open={setupOpen()}
initialTab={setupTab()}
onClose={() => {
setSetupOpen(false)
refreshBilling()
}}
/>
</>
)
}
@@ -0,0 +1,68 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import Field from "@/components/Field"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type InvoiceDetailCardProps = {
invoice: Invoice
}
export default function InvoiceDetailCard(props: InvoiceDetailCardProps) {
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey, like the admin list item.
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
const status = () => invoiceStatus(props.invoice)
return (
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
<div class="flex items-center justify-between gap-3">
<p class="text-2xl font-bold text-gray-900">{formatUsd(props.invoice.amount)}</p>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
<hr class="border-gray-200" />
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
<Field label="Billing period">
<span class="text-gray-900">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</span>
</Field>
<Field label="Created">
<span class="text-gray-900">{new Date(props.invoice.created_at * 1000).toLocaleDateString()}</span>
</Field>
<Show when={props.invoice.method}>
<Field label="Payment method">
<span class="uppercase text-gray-900">{props.invoice.method}</span>
</Field>
</Show>
<Field label="Tenant">
<A href={`/admin/tenants/${props.invoice.tenant_pubkey}`} class="group flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="truncate text-blue-600 group-hover:underline">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</A>
</Field>
</dl>
</div>
)
}
+111 -115
View File
@@ -1,13 +1,20 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createResource, createSignal, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
import { useTenantRelays } from "@/lib/hooks"
import { plans } from "@/lib/state"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import LightningPayBody from "@/components/payment/LightningPayBody"
import { setToastMessage } from "@/lib/state"
import { copyToClipboard } from "@/lib/clipboard"
import { useInvoiceCheckout } from "@/lib/usePaymentSetup"
import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { billingTenant } from "@/lib/state"
import { formatUsd, formatPeriod } from "@/lib/format"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PayMethod = "lightning" | "card"
type PaymentInvoice = Pick<Invoice, "id" | "amount"> &
Partial<Pick<Invoice, "period_start" | "period_end">>
@@ -24,17 +31,24 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [payMethod, setPayMethod] = createSignal<PayMethod>("lightning")
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [relays] = useTenantRelays()
const [items] = createResource(
() => (props.open ? props.invoice.id : undefined),
listInvoiceItems,
)
const billedRelays = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan_id) }))
.filter((entry) => Boolean(entry.plan?.amount))
})
// Paying by card opens a Stripe Checkout session scoped to this invoice (which
// can clear a 3D Secure challenge the off-session charge can't), then returns
// here where the payment is reconciled. Distinct from PaymentSetup, which
// manages the recurring card on file via the billing portal.
const checkout = useInvoiceCheckout(() => props.invoice.id)
const hasAutopay = () => {
const t = billingTenant()
return t ? autopayConfigured(t) : false
}
async function loadBolt11() {
if (!props.invoice.id) return
@@ -44,9 +58,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setQrDataUrl("")
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
const { lnbc } = await ensureInvoiceBolt11(props.invoice.id)
setBolt11(lnbc)
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
@@ -59,46 +73,47 @@ export default function PaymentDialog(props: PaymentDialogProps) {
void loadBolt11()
})
// The checkout redirect lives in a shared hook, so surface its failures here
// by mirroring its error signal into the toast.
createEffect(() => {
const err = checkout.error()
if (err) setToastMessage(err)
})
function copyBolt11() {
void navigator.clipboard.writeText(bolt11())
void copyToClipboard(bolt11(), { successMessage: "Invoice copied" })
}
async function checkPayment() {
setPayStatus("loading")
setPayError("")
try {
const invoice = await getInvoice(props.invoice.id)
const invoice = await reconcileInvoice(props.invoice.id)
if (invoice.paid_at != null) {
setPayStatus("success")
} else {
setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.")
setToastMessage("Payment not yet confirmed. Please try again after sending.")
}
} catch (e) {
setPayStatus("error")
setPayError(e instanceof Error ? e.message : "Failed to check payment status")
setToastMessage(e instanceof Error ? e.message : "Failed to check payment status")
}
}
function handleClose() {
setPayStatus("idle")
setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setPayMethod("lightning")
checkout.reset()
props.onClose()
}
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
const amountLabel = () => formatUsd(props.invoice.amount)
const periodLabel = () => {
const { period_start, period_end } = props.invoice
if (!period_start || !period_end) return ""
const start = new Date(period_start * 1000).toLocaleDateString()
const end = new Date(period_end * 1000).toLocaleDateString()
return `${start} ${end}`
}
const periodLabel = () => formatPeriod(props.invoice.period_start, props.invoice.period_end)
return (
<>
@@ -137,79 +152,63 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-4">
{/* What's being paid for */}
<Show when={billedRelays().length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
<ul class="space-y-1.5">
<For each={billedRelays()}>
{({ relay, plan }) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
<span class="flex-shrink-0 text-xs text-gray-500">
{plan?.name ?? relay.plan_id}
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
</span>
</li>
)}
</For>
</ul>
</div>
{/* What's being paid for — the invoice's actual line items */}
<Show when={(items() ?? []).length > 0}>
<InvoiceItemsList items={items() ?? []} />
</Show>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide text-center">Pay with Lightning</p>
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
{/* Method switcher */}
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm transition-colors ${payMethod() === "lightning" ? "bg-gray-900 text-white" : "text-gray-700 hover:bg-gray-50"}`}
onClick={() => setPayMethod("lightning")}
>
Lightning
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm transition-colors ${payMethod() === "card" ? "bg-gray-900 text-white" : "text-gray-700 hover:bg-gray-50"}`}
onClick={() => setPayMethod("card")}
>
Card
</button>
</div>
{/* Lightning: pay this invoice via a bolt11 QR */}
<Show when={payMethod() === "lightning"}>
<LightningPayBody
bolt11Status={bolt11Status}
bolt11={bolt11}
qrDataUrl={qrDataUrl}
bolt11Error={bolt11Error}
onRetry={() => void loadBolt11()}
onCopy={copyBolt11}
/>
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
{/* Card: redirect to a Stripe Checkout session for this invoice */}
<Show when={payMethod() === "card"}>
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">
Pay this invoice on Stripe's secure checkout. You'll be redirected and brought back here once it's done.
</p>
<button
type="button"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
onClick={checkout.openCheckout}
disabled={checkout.redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Retry
{checkout.redirecting() ? "Redirecting..." : `Pay ${amountLabel()} by card`}
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
{/* Card / automatic payment alternative */}
<div class="border-t border-gray-100 pt-3 text-center">
<p class="text-xs text-gray-500 mb-1">Prefer to pay with a card?</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
Pay with card or set up automatic payments
</button>
</div>
</div>
}
>
@@ -221,24 +220,19 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
<Show when={!hasAutopay()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Set up automatic payments
</button>
</Show>
</div>
</Show>
</div>
{/* Error */}
<Show when={payError()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{payError()}</p>
</div>
</Show>
{/* Footer */}
<div class="px-6 py-4 flex justify-between gap-3 border-t border-gray-100">
<Show
@@ -262,14 +256,16 @@ export default function PaymentDialog(props: PaymentDialogProps) {
>
Pay Later
</button>
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
</button>
<Show when={payMethod() === "lightning"}>
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
</button>
</Show>
</Show>
</div>
</Modal>
+29 -145
View File
@@ -1,8 +1,6 @@
import { createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { updateActiveTenant } from "@/lib/hooks"
import { createPortalSession } from "@/lib/api"
import { account } from "@/lib/state"
import { createEffect, createSignal, Show } from "solid-js"
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody, CardSetupBody } from "@/components/PaymentSetupShell"
import { useCardPortal, useNwcSetup } from "@/lib/usePaymentSetup"
type Tab = "nwc" | "card"
@@ -10,77 +8,41 @@ type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// Which method to show first. Lightning/NWC is the default; pass "card" to land
// a tenant on the card tab (e.g. when their card is the method that failed).
initialTab?: Tab
}
export default function PaymentSetup(props: PaymentSetupProps) {
const [tab, setTab] = createSignal<Tab>("nwc")
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
const [error, setError] = createSignal("")
const [redirecting, setRedirecting] = createSignal(false)
const [tab, setTab] = createSignal<Tab>(props.initialTab ?? "nwc")
async function saveNwc() {
const url = nwcUrl().trim()
if (!url) return
setSaving(true)
setError("")
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
props.onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
setSaving(false)
}
}
// Reset to the requested tab each time the dialog opens.
createEffect(() => {
if (props.open) setTab(props.initialTab ?? "nwc")
})
async function openPortal() {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
setRedirecting(false)
}
}
const nwc = useNwcSetup(() => props.onSaved?.())
const card = useCardPortal()
// Surface only the active tab's error so a stale failure on one method doesn't
// bleed into the other.
const error = () => (tab() === "nwc" ? nwc.error() : card.error())
function handleClose() {
setNwcUrl("")
setSaved(false)
setError("")
nwc.reset()
card.reset()
props.onClose()
}
return (
<Modal
<PaymentSetupShell
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
title="Set Up Payments"
description="Choose how you'd like to pay once invoices are issued for your relay."
error={error()}
footer={<SetupFooter saved={nwc.saved()} cancelLabel="Set up later" onClose={handleClose} />}
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
</div>
<button
type="button"
onClick={handleClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 pt-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Pay with</p>
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
@@ -101,92 +63,14 @@ export default function PaymentSetup(props: PaymentSetupProps) {
</div>
</div>
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
<PaymentSetupBody>
<Show when={tab() === "nwc"}>
<Show
when={!saved()}
fallback={
<div class="text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
</div>
}
>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={saveNwc}
disabled={saving() || !nwcUrl().trim()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
</Show>
<NwcSetupBody nwc={nwc} />
</Show>
<Show when={tab() === "card"}>
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p>
<button
type="button"
onClick={openPortal}
disabled={redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{redirecting() ? "Redirecting..." : "Add a payment card"}
</button>
</div>
<CardSetupBody card={card} />
</Show>
</div>
<Show when={error()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{error()}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">
<Show when={saved()}>
<div class="flex justify-end">
<button
type="button"
onClick={handleClose}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
Done
</button>
</div>
</Show>
<Show when={!saved()}>
<button
type="button"
onClick={handleClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
Set up later
</button>
</Show>
</div>
</Modal>
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,42 @@
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, CardSetupBody } from "@/components/PaymentSetupShell"
import { useCardPortal } from "@/lib/usePaymentSetup"
type PaymentSetupCardProps = {
open: boolean
onClose: () => void
// The tenant already has a card on file, so the copy frames this as managing
// the existing one rather than adding a first card.
isUpdate?: boolean
}
// Focused card dialog. PaymentSetup offers both methods behind tabs for the
// general setup flow; here the entry point is explicitly "manage your card", so
// there's no method switcher — adding/updating a card is a redirect to the
// Stripe billing portal, which returns to wherever it was opened from.
export default function PaymentSetupCard(props: PaymentSetupCardProps) {
const card = useCardPortal()
function handleClose() {
card.reset()
props.onClose()
}
return (
<PaymentSetupShell
open={props.open}
onClose={handleClose}
title={props.isUpdate ? "Manage Card" : "Add a Card"}
description={
props.isUpdate
? "Manage your saved card in the Stripe billing portal."
: "Add a card via the Stripe billing portal to pay invoices automatically."
}
error={card.error()}
footer={<SetupFooter cancelLabel="Cancel" onClose={handleClose} />}
>
<PaymentSetupBody>
<CardSetupBody card={card} isUpdate={props.isUpdate} />
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,43 @@
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody } from "@/components/PaymentSetupShell"
import { useNwcSetup } from "@/lib/usePaymentSetup"
type PaymentSetupNWCProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// The tenant already has a wallet connected, so the copy frames this as
// replacing it (the stored URL is write-only and never sent back).
isUpdate?: boolean
}
// Focused Lightning/NWC connect dialog. PaymentSetup offers both methods behind
// tabs for the general setup flow; here the entry point is explicitly "connect a
// Lightning wallet", so there's no method switcher — the card path lives on its
// own row that redirects to Stripe.
export default function PaymentSetupNWC(props: PaymentSetupNWCProps) {
const nwc = useNwcSetup(() => props.onSaved?.())
function handleClose() {
nwc.reset()
props.onClose()
}
return (
<PaymentSetupShell
open={props.open}
onClose={handleClose}
title={props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"}
description={
props.isUpdate
? "Paste a new Nostr Wallet Connect URL to replace your connected wallet."
: "Paste your Nostr Wallet Connect URL to pay invoices automatically over Lightning."
}
error={nwc.error()}
footer={<SetupFooter saved={nwc.saved()} cancelLabel="Cancel" onClose={handleClose} />}
>
<PaymentSetupBody>
<NwcSetupBody nwc={nwc} />
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,159 @@
import { Show, type JSX } from "solid-js"
import Modal from "@/components/Modal"
import type { CardPortal, NwcSetup } from "@/lib/usePaymentSetup"
type PaymentSetupShellProps = {
open: boolean
onClose: () => void
title: string
description: string
error?: string
footer: JSX.Element
children: JSX.Element
}
// Shared chrome for the payment-setup dialogs: the modal frame, the
// title/description header with a close button, the error line, and the footer
// container. Each caller supplies the body (children) and footer buttons.
export function PaymentSetupShell(props: PaymentSetupShellProps) {
return (
<Modal
open={props.open}
onClose={props.onClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{props.title}</h2>
<p class="text-sm text-gray-500 mt-1">{props.description}</p>
</div>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
{props.children}
<Show when={props.error}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{props.error}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">{props.footer}</div>
</Modal>
)
}
// The fixed-height content region between the header and footer.
export function PaymentSetupBody(props: { children: JSX.Element }) {
return <div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">{props.children}</div>
}
// Footer for every payment-setup dialog: a "Done" confirm once an action has
// succeeded, otherwise a secondary dismiss button. Card setup never "saves" (it
// redirects away), so it always shows the dismiss button.
export function SetupFooter(props: { saved?: boolean; cancelLabel: string; onClose: () => void }) {
return (
<Show
when={props.saved}
fallback={
<button
type="button"
onClick={props.onClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
{props.cancelLabel}
</button>
}
>
<div class="flex justify-end">
<button
type="button"
onClick={props.onClose}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
Done
</button>
</div>
</Show>
)
}
// Lightning/NWC body: the URL input + save, or the success state once saved.
export function NwcSetupBody(props: { nwc: NwcSetup }) {
const nwc = props.nwc
return (
<Show
when={!nwc.saved()}
fallback={
<div class="text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
</div>
}
>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
<input
type="text"
value={nwc.nwcUrl()}
onInput={(e) => nwc.setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={nwc.save}
disabled={nwc.saving() || !nwc.nwcUrl().trim()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{nwc.saving() ? "Saving..." : "Save"}
</button>
</div>
</Show>
)
}
// Card body: an explanation plus the button that redirects to the Stripe portal.
// `isUpdate` adjusts the copy for tenants who already have a card on file.
export function CardSetupBody(props: { card: CardPortal; isUpdate?: boolean }) {
return (
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">
{props.isUpdate
? "Update or remove your card in the Stripe billing portal. We'll retry any due invoice after you're done."
: "Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup."}
</p>
<button
type="button"
onClick={props.card.openPortal}
disabled={props.card.redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{props.card.redirecting() ? "Redirecting..." : props.isUpdate ? "Manage card" : "Add a payment card"}
</button>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { For, Show } from "solid-js"
export type PromptBannerAction = {
label: string
onClick: () => void
}
type Severity = "error" | "warn" | "info"
type PromptBannerProps = {
severity: Severity
message: string
actions?: PromptBannerAction[]
onDismiss?: () => void
class?: string
}
const containerStyles: Record<Severity, string> = {
error: "border-red-200 bg-red-50 text-red-800",
warn: "border-amber-200 bg-amber-50 text-amber-800",
info: "border-blue-200 bg-blue-50 text-blue-800",
}
const actionStyles: Record<Severity, string> = {
error: "text-red-800 hover:text-red-900",
warn: "text-amber-800 hover:text-amber-900",
info: "text-blue-800 hover:text-blue-900",
}
export default function PromptBanner(props: PromptBannerProps) {
return (
<div class={`rounded-lg border p-4 flex items-start justify-between gap-4 ${containerStyles[props.severity]} ${props.class ?? ""}`.trim()}>
<p class="text-sm min-w-0">{props.message}</p>
<div class="flex items-center gap-3 shrink-0">
<For each={props.actions ?? []}>
{(action) => (
<button
type="button"
onClick={action.onClick}
class={`text-sm font-medium underline hover:no-underline whitespace-nowrap ${actionStyles[props.severity]}`}
>
{action.label}
</button>
)}
</For>
<Show when={props.onDismiss}>
<button
type="button"
onClick={() => props.onDismiss?.()}
aria-label="Dismiss"
class={`shrink-0 opacity-70 hover:opacity-100 ${actionStyles[props.severity]}`}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
)
}
+43 -199
View File
@@ -1,30 +1,17 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
function DetailSection(props: { title: string; children: any }) {
return (
<div>
@@ -70,16 +57,13 @@ type RelayDetailCardProps = {
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const flag = (value: number, fallback: boolean) => {
if (value === 0) return false
if (value === 1) return true
return fallback
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
// This subscription stays in the parent so the header doesn't double-subscribe.
const tenantProfile = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined))
const memberLimitLabel = () => {
const p = plans().find(p => p.id === r().plan_id)
@@ -118,7 +102,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
@@ -140,129 +123,31 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
setPendingAction(null)
}
createEffect(() => {
if (!menuOpen()) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
openActionDialog("deactivate")
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
openActionDialog("reactivate")
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<RelayCardHeader
relay={r}
showTenant={props.showTenant}
tenantProfile={tenantProfile}
editHref={props.editHref}
deactivating={props.deactivating}
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
/>
<hr class="border-gray-200" />
<DetailSection title="Policy">
<ToggleField label="Public join">
<ToggleButton
enabled={flag(r().policy_public_join, false)}
enabled={flagToBool(r().policy_public_join, false)}
onToggle={props.onTogglePublicJoin}
/>
</ToggleField>
<ToggleField label="Strip signatures">
<ToggleButton
enabled={flag(r().policy_strip_signatures, false)}
enabled={flagToBool(r().policy_strip_signatures, false)}
onToggle={props.onToggleStripSignatures}
/>
</ToggleField>
@@ -273,79 +158,43 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<DetailSection title="Features">
<ToggleField label="Rooms">
<ToggleButton
enabled={flag(r().groups_enabled, true)}
enabled={flagToBool(r().groups_enabled, true)}
onToggle={props.onToggleGroups}
/>
</ToggleField>
<ToggleField label="Management API">
<ToggleButton
enabled={flag(r().management_enabled, true)}
enabled={flagToBool(r().management_enabled, true)}
onToggle={props.onToggleManagement}
/>
</ToggleField>
<ToggleField label="Push notifications">
<ToggleButton
enabled={flag(r().push_enabled, true)}
enabled={flagToBool(r().push_enabled, true)}
onToggle={props.onTogglePushNotifications}
/>
</ToggleField>
<ToggleField label="Media storage">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().blossom_enabled, false)} onToggle={props.onToggleMediaStorage} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().blossom_enabled, true)}
onToggle={props.onToggleMediaStorage}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().blossom_enabled, true)}
fallbackEnabled={flagToBool(r().blossom_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleMediaStorage}
/>
</ToggleField>
<ToggleField label="LiveKit support">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().livekit_enabled, false)} onToggle={props.onToggleLivekitSupport} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().livekit_enabled, true)}
onToggle={props.onToggleLivekitSupport}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().livekit_enabled, true)}
fallbackEnabled={flagToBool(r().livekit_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleLivekitSupport}
/>
</ToggleField>
</DetailSection>
@@ -358,11 +207,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<Field label="Member limit">
<span class="text-gray-900">{memberLimitLabel()}</span>
</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant_pubkey}</span>
</Field>
</Show>
</MembershipSection>
<Show when={showPlanActions()}>
+32 -26
View File
@@ -1,8 +1,8 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast"
import { RELAY_DOMAIN, validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/lib/state"
import { plans } from "@/lib/state"
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id">
@@ -10,12 +10,16 @@ export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon
type RelayFormProps = {
initialValues?: Partial<RelayFormValues>
syncSubdomainWithName?: boolean
// The plan can't be changed by editing a relay (only at creation or via the
// detail card), so the edit form hides the selector by passing false here.
showPlanSelector?: boolean
onSubmit: (values: RelayFormValues) => Promise<void> | void
submitLabel: string
submittingLabel: string
}
export default function RelayForm(props: RelayFormProps) {
const showPlanSelector = () => props.showPlanSelector ?? true
const defaultPlanId = createMemo(() => props.initialValues?.plan_id ?? plans()[0]?.id ?? "free")
const [planId, setPlanId] = createSignal(defaultPlanId())
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
@@ -27,7 +31,7 @@ export default function RelayForm(props: RelayFormProps) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!planId()) {
if (showPlanSelector() && !planId()) {
setToastMessage("Please select a plan")
return
}
@@ -84,7 +88,7 @@ export default function RelayForm(props: RelayFormProps) {
required
class="flex-1 px-3 py-2"
/>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.spaces.coracle.social</span>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.{RELAY_DOMAIN}</span>
</div>
</div>
<div>
@@ -105,28 +109,30 @@ export default function RelayForm(props: RelayFormProps) {
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
<Show when={showPlanSelector()}>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<button
type="submit"
disabled={submitting()}
+27 -6
View File
@@ -1,6 +1,10 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
type RelayListItemProps = {
relay: Relay
@@ -9,16 +13,33 @@ type RelayListItemProps = {
}
export default function RelayListItem(props: RelayListItemProps) {
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch,
// so this subscription does not prime on its own.
const metadata = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined), { prime: false })
return (
<li>
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div>
<div class="min-w-0">
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
{props.showTenant && (
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant_pubkey}</p>
)}
<p class="text-xs text-gray-500">{props.relay.subdomain}.{RELAY_DOMAIN}</p>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.relay.tenant_pubkey)}</span>
</div>
</Show>
</div>
<Show
when={props.relay.sync_error}
@@ -28,7 +49,7 @@ export default function RelayListItem(props: RelayListItemProps) {
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
Failed to sync
</span>
</Show>
</div>
+1 -10
View File
@@ -1,14 +1,5 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
type ToastVariant = "error" | "success"
const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
import { toastMessage, toastVariant, setToastMessage } from "@/lib/state"
export default function Toast() {
const [visible, setVisible] = createSignal(false)
@@ -0,0 +1,64 @@
import { Show } from "solid-js"
import { formatUsd } from "@/lib/format"
// Presentational invoice/draft row for the Payment History list. Status label,
// style, and period label are computed by the parent (Account.tsx) and passed in;
// PDF/pay actions are surfaced as callbacks. Props are reactive only when read
// lazily, so access props.* inside JSX, never destructure at the top.
type InvoiceListItemProps = {
amount: number
statusLabel: string
statusStyle: string
periodLabel: string
method?: string
isDraft?: boolean
isOpen?: boolean
onPay?: () => void
onPrintPdf: () => void
printing: boolean
}
export default function InvoiceListItem(props: InvoiceListItemProps) {
return (
<li class={`rounded-lg border p-4 text-sm ${props.isDraft ? "border-dashed border-gray-300" : "border-gray-200"}`}>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{formatUsd(props.amount)}</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${props.statusStyle}`}>
{props.statusLabel}
</span>
<Show when={props.method}>
<span class="text-xs text-gray-500">· paid via {props.method}</span>
</Show>
</div>
<Show when={props.periodLabel}>
<p class="text-xs text-gray-500 mt-0.5">{props.periodLabel}</p>
</Show>
<Show when={props.isDraft}>
<p class="text-xs text-gray-400 mt-1">Charges accruing this period. You'll be invoiced once a balance is due.</p>
</Show>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={props.isOpen}>
<button
type="button"
onClick={() => props.onPay?.()}
class="py-1 px-3 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Pay now
</button>
</Show>
<button
type="button"
onClick={props.onPrintPdf}
disabled={props.printing}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,53 @@
import { Show } from "solid-js"
import type { PaymentMethodState } from "@/lib/paymentMethod"
// Style/label lookups for a payment method's state, co-located with the row that
// consumes them so any future remodel of PaymentMethodState is a single-file
// change.
const methodStatusStyles: Record<PaymentMethodState["kind"], string> = {
not_set_up: "bg-gray-100 text-gray-500 border-gray-200",
ok: "bg-green-50 text-green-700 border-green-200",
error: "bg-red-50 text-red-700 border-red-200",
}
const methodStatusLabels: Record<PaymentMethodState["kind"], string> = {
not_set_up: "not set up",
ok: "ok",
error: "error",
}
// Presentational payment-method list row (title, optional error line, status
// badge, Set up/Update CTA). Props are reactive only when read lazily, so access
// props.* inside JSX, never destructure at the top.
type PaymentMethodRowProps = {
title: string
state: PaymentMethodState
onAction: () => void
}
export default function PaymentMethodRow(props: PaymentMethodRowProps) {
return (
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">{props.title}</p>
<Show when={props.state.kind === "error"}>
<p class="text-xs text-red-600 mt-0.5 break-words">{(props.state as { message: string }).message}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[props.state.kind]}`}>
{methodStatusLabels[props.state.kind]}
</span>
<button
type="button"
onClick={props.onAction}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{props.state.kind === "not_set_up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,94 @@
import { Show } from "solid-js"
type KeyTab = "plaintext" | "encrypted"
// Presentational key-login panel. The actual login (loginWithKeyMaterial) stays
// in Login.tsx and is invoked via onSubmit; preventDefault is handled here so the
// parent only supplies the async login. Props are reactive only when read lazily,
// so access props.* inside JSX, never destructure signal-bearing props at the top.
type LoginKeyScreenProps = {
keyTab: () => KeyTab
setKeyTab: (tab: KeyTab) => void
nsecValue: () => string
setNsecValue: (value: string) => void
ncryptsecValue: () => string
setNcryptsecValue: (value: string) => void
password: () => string
setPassword: (value: string) => void
loading: () => boolean
onBack: () => void
onSubmit: () => void
}
export default function LoginKeyScreen(props: LoginKeyScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "plaintext" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("plaintext")}
>
Plaintext
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "encrypted" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("encrypted")}
>
Encrypted
</button>
</div>
<form
class="space-y-3"
onSubmit={(e) => {
e.preventDefault()
props.onSubmit()
}}
>
<Show when={props.keyTab() === "plaintext"}>
<input
value={props.nsecValue()}
onInput={(e) => props.setNsecValue(e.currentTarget.value)}
placeholder="nsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<Show when={props.keyTab() === "encrypted"}>
<input
value={props.ncryptsecValue()}
onInput={(e) => props.setNcryptsecValue(e.currentTarget.value)}
placeholder="ncryptsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="password"
value={props.password()}
onInput={(e) => props.setPassword(e.currentTarget.value)}
placeholder="Password"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading()}
>
Log in
</button>
</form>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import { Show } from "solid-js"
type SignerTab = "qr" | "paste"
// Presentational NIP-46 signer panel. No signers are created here; QR/URI are
// generated in Login.tsx and passed down, and actions are surfaced as callbacks.
// Props are reactive only when read lazily, so access props.* inside JSX, never
// destructure signal-bearing props at the top.
type LoginSignerScreenProps = {
signerTab: () => SignerTab
setSignerTab: (tab: SignerTab) => void
qrDataUrl: () => string
nostrConnectUri: () => string
bunkerUrl: () => string
setBunkerUrl: (value: string) => void
loading: () => boolean
onBack: () => void
onCopyUri: () => void
onScan: () => void
onConnectBunker: () => void
}
export default function LoginSignerScreen(props: LoginSignerScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "qr" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("qr")}
>
Use QR Code
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "paste" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("paste")}
>
Paste Link
</button>
</div>
<Show when={props.signerTab() === "qr"}>
<Show when={props.qrDataUrl()} fallback={
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
{props.loading() ? "Generating..." : "Loading QR code..."}
</div>
}>
<img src={props.qrDataUrl()} alt="Nostrconnect QR code" class="mx-auto rounded-lg" />
</Show>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.nostrConnectUri()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopyUri}
title="Copy link"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</Show>
<Show when={props.signerTab() === "paste"}>
<div class="flex rounded-lg border border-gray-300">
<input
value={props.bunkerUrl()}
onInput={(e) => props.setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://..."
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-sm bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onScan}
title="Scan QR code"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
</div>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading() || !props.bunkerUrl().trim()}
onClick={props.onConnectBunker}
>
Connect to Signer
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,84 @@
import { Show } from "solid-js"
import type { Tab } from "@/lib/loginInput"
// Presentational tab-selection panel for the login screen. All login logic stays
// in Login.tsx; this only renders tabs and surfaces continue callbacks. Props are
// reactive only when read lazily, so access props.* inside JSX, never destructure
// signal-bearing props at the top.
type LoginTabsScreenProps = {
tab: () => Tab
setTab: (tab: Tab) => void
loading: () => boolean
hasExtension: boolean
onContinueExtension: () => void
onContinueSigner: () => void
onContinueKey: () => void
}
export default function LoginTabsScreen(props: LoginTabsScreenProps) {
return (
<>
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<div class="mt-6 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip07" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip07")}
disabled={!props.hasExtension}
>
Extension
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip46" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip46")}
>
Signer
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "key" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("key")}
>
Key
</button>
</div>
<Show when={props.tab() === "nip07"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={!props.hasExtension || props.loading()}
onClick={props.onContinueExtension}
>
{props.loading() ? "Connecting..." : <>Continue with extension <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></>}
</button>
</Show>
<Show when={props.tab() === "nip46"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueSigner}
>
Continue with signer <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
<Show when={props.tab() === "key"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueKey}
>
Continue with key <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,29 @@
import { Show } from "solid-js"
// Presentational scanner overlay chrome. The <video> ref is owned by the parent
// via the videoRef setter so the QrScanner instance and its lifecycle stay in
// Login.tsx (openScanner waits a microtask for this element to mount). Props are
// reactive only when read lazily, so access props.* inside JSX.
type QrScannerOverlayProps = {
open: boolean
onClose: () => void
videoRef: (el: HTMLVideoElement) => void
}
export default function QrScannerOverlay(props: QrScannerOverlayProps) {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={props.onClose}>
<div class="relative w-full max-w-sm rounded-2xl bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Scan QR Code</h3>
<button type="button" class="text-gray-400 hover:text-gray-700" onClick={props.onClose}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<video ref={props.videoRef} class="w-full rounded-lg" />
</div>
</div>
</Show>
)
}
@@ -0,0 +1,36 @@
import { For, Show, createMemo } from "solid-js"
import type { InvoiceItem } from "@/lib/api"
import { formatUsd } from "@/lib/format"
const MAX_VISIBLE_ITEMS = 8
// Presentational "On this invoice" line-items block. The createResource that
// fetches items stays in PaymentDialog; this only renders the parsed list. Props
// are reactive only when read lazily, so access props.* inside JSX/derivations.
type InvoiceItemsListProps = {
items: InvoiceItem[]
}
export default function InvoiceItemsList(props: InvoiceItemsListProps) {
const visibleItems = createMemo(() => props.items.slice(0, MAX_VISIBLE_ITEMS))
return (
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={visibleItems()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">{formatUsd(item.amount)}</span>
</li>
)}
</For>
<Show when={props.items.length > MAX_VISIBLE_ITEMS}>
<li class="text-xs text-gray-500">
+ {props.items.length - MAX_VISIBLE_ITEMS} more
</li>
</Show>
</ul>
</div>
)
}
@@ -0,0 +1,66 @@
import { Show } from "solid-js"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
// Presentational lightning payment body: loading/error/ready states, the bolt11
// QR + input, and copy. All fetching/QR generation stays in PaymentDialog and is
// surfaced via accessors/callbacks. Props are reactive only when read lazily, so
// access props.* inside JSX, never destructure signal-bearing props at the top.
type LightningPayBodyProps = {
bolt11Status: () => Bolt11Status
bolt11: () => string
qrDataUrl: () => string
bolt11Error: () => string
onRetry: () => void
onCopy: () => void
}
export default function LightningPayBody(props: LightningPayBodyProps) {
return (
<>
<Show when={props.bolt11Status() === "idle" || props.bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
<Show when={props.bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{props.bolt11Error()}</p>
<button
type="button"
onClick={props.onRetry}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Retry
</button>
</div>
</Show>
<Show when={props.bolt11Status() === "ready"}>
<img src={props.qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={props.bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopy}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<p class="text-xs text-gray-500 text-center">
Scan this QR code with a Bitcoin Lightning wallet to pay.
</p>
</Show>
</>
)
}
@@ -0,0 +1,48 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import ToggleButton from "@/components/ToggleButton"
// Pure presentational toggle that gates a feature behind plan limits. Props are
// reactive only when read lazily, so access props.* inside JSX/derivations and
// never destructure at the top.
type PlanGatedToggleProps = {
enabled: boolean
fallbackEnabled: boolean
planLimited: boolean
showPlanActions: boolean
canUpdatePlan: boolean
editHref?: string
onToggle?: () => void
}
export default function PlanGatedToggle(props: PlanGatedToggleProps) {
return (
<Show
when={!props.planLimited}
fallback={
<Show when={props.showPlanActions} fallback={<ToggleButton enabled={props.fallbackEnabled} onToggle={props.onToggle} />}>
<Show
when={props.canUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={props.enabled}
onToggle={props.onToggle}
/>
</Show>
)
}
@@ -0,0 +1,177 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
export function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
// Presentational header for RelayDetailCard: icon/title/status, the WSS link, the
// optional tenant profile link, the sync_error banner, and the actions dropdown.
// The dropdown owns its own open/close UI state and document listeners; all relay
// mutations are surfaced as callbacks. Props are reactive only when read lazily,
// so access props.* (and accessor props like props.relay()) inside JSX, never
// destructure at the top.
type RelayCardHeaderProps = {
relay: () => Relay
showTenant?: boolean
tenantProfile: () => ProfileContent | undefined
editHref?: string
deactivating?: boolean
reactivating?: boolean
onRequestDeactivate?: () => void
onRequestReactivate?: () => void
}
export default function RelayCardHeader(props: RelayCardHeaderProps) {
const r = () => props.relay()
const metadata = () => props.tenantProfile()
const [menuOpen, setMenuOpen] = createSignal(false)
let menuContainerRef: HTMLDivElement | undefined
createEffect(() => {
if (!menuOpen()) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
return (
<>
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.{RELAY_DOMAIN}
</a>
<Show when={props.showTenant}>
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || r().tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-sm text-blue-600 group-hover:underline truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(r().tenant_pubkey)}</span>
</A>
</Show>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onRequestDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestDeactivate?.()
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onRequestReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestReactivate?.()
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
</>
)
}
+1
View File
@@ -22,6 +22,7 @@ declare global {
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_RELAY_DOMAIN: string
readonly VITE_PLATFORM_NAME?: string
}
+3
View File
@@ -23,4 +23,7 @@
--color-blue-500: #c18254;
--color-blue-600: #c18254;
--color-blue-700: #a66d46;
--color-blue-800: #8a5a39;
--color-blue-900: #6f4730;
--color-blue-950: #45291a;
}
+92 -3
View File
@@ -97,6 +97,7 @@ export type Tenant = {
pubkey: string
nwc_is_set: boolean
created_at: number
billing_anchor: number | null
stripe_customer_id: string
stripe_payment_method_id: string | null
nwc_error: string | null
@@ -104,6 +105,12 @@ export type Tenant = {
churned_at: number | null
}
// Internal aliases derived from the wire shapes below — pure naming, no payload
// change. InvoiceMethod is the non-null members of Invoice.method; InvoiceStatus
// is the lifecycle status derived from the paid_at/voided_at timestamps.
export type InvoiceMethod = "nwc" | "stripe" | "oob"
export type InvoiceStatus = "open" | "paid" | "void"
export type Invoice = {
id: string
tenant_pubkey: string
@@ -113,17 +120,51 @@ export type Invoice = {
created_at: number
paid_at: number | null
voided_at: number | null
method: InvoiceMethod | null
}
export type InvoiceItem = {
id: string
invoice_id: string | null
activity_id: string | null
tenant_pubkey: string
relay_id: string
plan_id: string
amount: number
description: string
created_at: number
voided_at: number | null
}
export type Bolt11 = {
id: string
invoice_id: string
lnbc: string
msats: number
created_at: number
expires_at: number
settled_at: number | null
}
// The backend models an invoice's lifecycle as timestamps rather than a status
// field, so derive the display status from them: paid once paid_at is set, void
// once voided_at is set, otherwise still open.
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): "open" | "paid" | "void" {
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): InvoiceStatus {
if (invoice.paid_at != null) return "paid"
if (invoice.voided_at != null) return "void"
return "open"
}
// The single invoice autopay collects and the dashboard surfaces as "Pay now":
// the OLDEST open invoice with a positive balance, matching the backend's
// dunning order so the UI pays the same one collection targets. undefined when
// nothing is due. Canonical pick shared by useBillingStatus and autopayBilling.
export function selectPayableInvoice(invoices: Invoice[]): Invoice | undefined {
return invoices
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => a.created_at - b.created_at)[0]
}
export type Activity = {
id: string
tenant_pubkey: string
@@ -235,6 +276,26 @@ export function listTenantInvoices(pubkey: string) {
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
}
// Reconcile a tenant's billing: sync the Stripe payment method (picking up a
// card added via the portal), fold billable activity into invoice items, renew
// the current period, and cut an invoice for any outstanding balance. Does not
// attempt payment. Returns the reconciled tenant.
export function reconcileTenant(pubkey: string) {
return callApi<undefined, Tenant>("POST", `/tenants/${pubkey}/reconcile`)
}
// The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing
// the current period's not-yet-billed items, or null when there's nothing due.
export function getDraftInvoice(pubkey: string) {
return callApi<undefined, Invoice | null>("GET", `/tenants/${pubkey}/invoices/draft`)
}
// The draft's line items, fetched by tenant since its sentinel id has no row in
// the per-invoice items endpoint. Lets the draft render an itemized PDF.
export function listDraftInvoiceItems(pubkey: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/tenants/${pubkey}/invoices/draft/items`)
}
export function updateTenant(pubkey: string, input: UpdateTenantInput) {
return callApi<UpdateTenantInput, Tenant>("PUT", `/tenants/${pubkey}`, input)
}
@@ -243,6 +304,10 @@ export function listRelays() {
return callApi<undefined, Relay[]>("GET", "/relays")
}
export function listInvoices() {
return callApi<undefined, Invoice[]>("GET", "/invoices")
}
export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
@@ -264,8 +329,32 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
}
export function getInvoiceBolt11(invoiceId: string) {
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
// Idempotently create a payable bolt11 for an invoice (reusing a valid existing
// one) and return it. No reconciliation — settlement is detected by
// reconcileInvoice. The lnbc string is the data the QR needs.
export function ensureInvoiceBolt11(invoiceId: string) {
return callApi<undefined, Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
}
// Open a hosted Stripe Checkout session to pay a single invoice by card,
// reusing a valid pending one. Unlike the off-session charge, Checkout can
// satisfy a 3D Secure challenge. Returns the URL to redirect to; the payment is
// reconciled by reconcileInvoice once the tenant returns (or by the poll). The
// return URL is fixed to the account page server-side.
export function createInvoiceCheckout(invoiceId: string) {
return callApi<undefined, { url: string }>("POST", `/invoices/${invoiceId}/checkout`)
}
// Reconcile and collect an open invoice: ensure a payable bolt11 exists, then
// run the payment cascade (NWC, then an out-of-band Lightning settle, then a
// saved card). Caller-initiated, so no dunning DM and no churn. Returns the
// refreshed invoice (paid_at set once collected).
export function reconcileInvoice(invoiceId: string) {
return callApi<undefined, Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
}
export function listInvoiceItems(invoiceId: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
}
export function createRelay(input: CreateRelayInput) {
+140
View File
@@ -0,0 +1,140 @@
import { createMemo } from "solid-js"
import { indexBy } from "@welshman/lib"
import { invoiceStatus, selectPayableInvoice, type Invoice, type Tenant } from "@/lib/api"
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
import { autopayBilling, billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
export type BillingPrompt = {
kind: BillingPromptKind
severity: "error" | "warn" | "info"
message: string
}
export type BillingStatusSnapshot = {
tenant: Tenant | undefined
openInvoice: Invoice | undefined
hasPaidSubscription: boolean
}
// The single billing read shared by the dashboard shell and the billing page.
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
// dunning order so the UI pays the same one collection targets.
export function useBillingStatus() {
const tenant = () => billingTenant()
const invoices = () => billingInvoices() ?? []
// The current period's in-progress bill (outstanding items not yet cut into a
// real invoice), or undefined when nothing is due. Shown as a "draft" row.
const draftInvoice = () => billingDraftInvoice() ?? undefined
const openInvoices = createMemo(() =>
invoices().filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0),
)
// The autopay/dunning target — the same pick autopayBilling collects.
const openInvoice = () => selectPayableInvoice(invoices())
// Amount due: the total of all open invoices.
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
const hasPaidSubscription = createMemo(() => {
const planById = indexBy((p) => p.id, plans())
return (billingRelays() ?? []).some((relay) => {
const plan = planById.get(relay.plan_id)
return Boolean(plan && plan.amount > 0 && relay.status === "active")
})
})
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling, autopay: autopayBilling }
}
// Pure priority selector: returns the single highest-priority billing prompt to
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
// and method errors.
export function activeBillingPrompt(
s: BillingStatusSnapshot,
opts?: { suppressInline?: boolean },
): BillingPrompt | null {
const tenant = s.tenant
if (!tenant) return null
if (tenant.churned_at) {
return {
kind: "churned",
severity: "error",
message:
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
}
}
const hasAutopay = autopayConfigured(tenant)
const nwc = nwcState(tenant)
const card = cardState(tenant)
const methodError = nwc.kind === "error" || card.kind === "error"
const suppressInline = opts?.suppressInline ?? false
// Any open invoice gets a "Pay now" surface, even with autopay configured:
// autopay may not have fired yet or may have failed without setting an error,
// and the user still needs a way to pay manually. Only the inline create/upgrade
// flow (suppressInline) handles its own invoice, so defer to it there.
if (s.openInvoice && !suppressInline) {
return {
kind: "pay_invoice",
severity: "warn",
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
}
}
if (methodError) {
return {
kind: "update_method",
severity: "warn",
message: nwc.kind === "error"
? "Your Lightning wallet couldn't be charged. Update your payment method."
: "Your card couldn't be charged. Update your payment method.",
}
}
if (s.hasPaidSubscription && !hasAutopay && !s.openInvoice && !suppressInline) {
return {
kind: "setup_autopay",
severity: "info",
message: "Set up automatic payments so your subscription renews without interruption.",
}
}
return null
}
export type AccountStatus = "active" | "inactive" | "delinquent"
// Coarse account-health summary for the status badge. Pure function of the same
// snapshot `activeBillingPrompt` consumes, so the badge can never disagree with
// the prompt. Mutually exclusive and total:
// - delinquent: churned_at is set — the ONLY frontend-visible signal of a real
// suspension (churn_tenant is the single place relays are paused). An open
// invoice alone is NOT delinquency: the tenant has a 7-day grace window and
// autopay may simply not have fired yet. Matches the sole severity:"error"
// branch in activeBillingPrompt.
// - active: not churned AND there is paid business to keep running — an active
// paid relay, an open balance, or a configured payment method. A failed method
// (nwc_error/stripe_error) or an unpaid invoice within grace stays "active";
// the per-method rows and the inline prompt carry that detail.
// - inactive: not churned and nothing billable — no paid relay, no balance, no
// method. The brand-new or free-only tenant (typically billing_anchor == null).
export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
const tenant = s.tenant
if (!tenant) return "inactive"
if (tenant.churned_at) return "delinquent"
const hasAutopay = autopayConfigured(tenant)
const hasOpenInvoice = Boolean(s.openInvoice)
if (s.hasPaidSubscription || hasOpenInvoice || hasAutopay) return "active"
return "inactive"
}
+16
View File
@@ -0,0 +1,16 @@
import { setToastMessage } from "@/lib/state"
export async function copyToClipboard(
text: string,
opts?: { successMessage?: string; errorMessage?: string },
): Promise<boolean> {
if (!text) return false
try {
await navigator.clipboard.writeText(text)
setToastMessage(opts?.successMessage ?? "Copied to clipboard", "success")
return true
} catch {
setToastMessage(opts?.errorMessage ?? "Couldn't copy to clipboard")
return false
}
}
+4
View File
@@ -0,0 +1,4 @@
export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
export const formatPeriod = (startSecs?: number, endSecs?: number) =>
(!startSecs || !endSecs) ? "" : `${new Date(startSecs * 1000).toLocaleDateString()} ${new Date(endSecs * 1000).toLocaleDateString()}`
+94 -21
View File
@@ -1,5 +1,8 @@
import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { uniq } from "@welshman/lib"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { map, of } from "rxjs"
@@ -7,14 +10,17 @@ import {
createRelay,
deactivateRelay,
reactivateRelay,
getInvoice,
getRelay,
getTenant,
invoiceStatus,
listInvoices,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
reconcileTenant,
updateRelay,
updateTenant,
type Activity,
@@ -24,56 +30,100 @@ import {
type Tenant,
type UpdateRelayInput,
} from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { decidePostPaidFlow, type PaidFlowDecision } from "@/lib/relayPlanFlow"
import { account, eventStore, pool } from "@/lib/state"
import { useNostr } from "@/lib/nostr"
export function useProfilePicture(pubkey: () => string | undefined) {
const [picture, setPicture] = createSignal<string | undefined>()
// Subscribes to the raw ProfileContent for a single pubkey from the event store,
// optionally priming it over the network. Call sites project the fields they need
// (name/display_name/nip05/picture) from the returned ProfileContent. Pass
// { prime: false } when a parent list already batch-primes these profiles.
export function useProfileMetadata(pubkey: () => string | undefined, opts?: { prime?: boolean }) {
// Safe: hooks run inside a component/root reactive scope, so useNostr resolves.
const nostr = useNostr()
const prime = opts?.prime ?? true
const [metadata, setMetadata] = createSignal<ProfileContent | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) {
setPicture(undefined)
setMetadata(undefined)
return
}
const profileSub = eventStore.profile(pk).subscribe((profile) => {
setPicture(getProfilePicture(profile))
})
const reqSub = primeProfiles([pk])
const profileSub = nostr.eventStore.profile(pk).subscribe(setMetadata)
const reqSub = prime ? primeProfiles([pk], nostr) : undefined
onCleanup(() => {
profileSub.unsubscribe()
reqSub.unsubscribe()
reqSub?.unsubscribe()
})
})
return picture
return metadata
}
export function primeProfiles(pubkeys: string[]) {
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
// Batch variant of useProfileMetadata: subscribes to a list of pubkeys, priming
// them all in one request, and returns a Record keyed by pubkey. Call sites
// project the fields they need from each ProfileContent.
export function useProfileMetadataMap(pubkeys: () => string[]) {
const nostr = useNostr()
const [metadata, setMetadata] = createSignal<Record<string, ProfileContent | undefined>>({})
createEffect(() => {
const list = pubkeys()
if (!list.length) return
const reqSub = primeProfiles(list, nostr)
const profileSubs = list.map((pubkey) =>
nostr.eventStore.profile(pubkey).subscribe((profile) => {
setMetadata((prev) => ({ ...prev, [pubkey]: profile }))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
})
return metadata
}
export function useProfilePicture(pubkey: () => string | undefined) {
const md = useProfileMetadata(pubkey)
return () => getProfilePicture(md())
}
// Accepts an optional context so callers inside a reactive scope can thread the
// injected eventStore/pool through; defaults to the module singletons because
// most callers run outside reactive scope (event handlers, plain effects) where
// useNostr() would be invalid.
export function primeProfiles(pubkeys: string[], ctx: { eventStore: EventStore; pool: RelayPool } = { eventStore, pool }) {
const { eventStore: store, pool: relayPool } = ctx
const uniquePubkeys = uniq(pubkeys.filter(Boolean))
if (uniquePubkeys.length === 0) {
return { unsubscribe() {} }
}
const seedRelays = Array.from(pool.relays.keys())
const seedRelays = Array.from(relayPool.relays.keys())
const mailboxSeedSub = seedRelays.length
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
eventStore.add(event)
? relayPool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
store.add(event)
})
: undefined
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
includeMailboxes(eventStore),
includeMailboxes(store),
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
map(createOutboxMap),
)
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") eventStore.add(message)
const profileSub = relayPool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") store.add(message)
})
return {
@@ -99,10 +149,16 @@ export const useAdminTenants = () => createResource(listTenants)
export const useAdminRelays = () => createResource(listRelays)
export const useAdminInvoices = () => createResource(listInvoices)
export const useInvoice = (id: () => string) => createResource(id, getInvoice)
export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant)
export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays)
export const useAdminTenantInvoices = (pubkey: () => string) => createResource(pubkey, listTenantInvoices)
export const createRelayForActiveTenant = (input: CreateRelayInput) => {
const defaults = {
info_name: "",
@@ -116,7 +172,7 @@ export const createRelayForActiveTenant = (input: CreateRelayInput) => {
}
const overrides = {
tenant: account()!.pubkey,
tenant_pubkey: account()!.pubkey,
blossom_enabled: input.plan_id === "free" ? 0 : 1,
livekit_enabled: input.plan_id === "free" ? 0 : 1,
}
@@ -136,7 +192,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_is_set && !tenant.stripe_payment_method_id
return !autopayConfigured(tenant)
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
@@ -147,4 +203,21 @@ export async function getLatestOpenInvoice(): Promise<Invoice | null> {
return open[0] ?? null
}
// Resolve what to do after a paid create/upgrade succeeds: a tenant that already
// has autopay configured just navigates, otherwise we surface the freshly
// materialized open invoice to pay directly (or fall back to payment setup when
// none is available). Shared by RelayNew, Home's signup-and-create path, and the
// plan-upgrade toggle so the post-paid ladder stays identical across all three.
export async function resolvePostPaidFlow(): Promise<PaidFlowDecision> {
const pubkey = account()!.pubkey
// The reads below are pure GETs now, so explicitly materialize the just-created
// invoice and pick up any portal-added card before deciding the post-paid ladder.
await reconcileTenant(pubkey)
const needsSetup = await tenantNeedsPaymentSetup()
const invoice = needsSetup ? await getLatestOpenInvoice() : null
return decidePostPaidFlow({ needsSetup, invoice })
}
export type { Activity, Invoice, Relay, Tenant }
export type { ProfileContent }
+56
View File
@@ -0,0 +1,56 @@
// Pure decision module for Login's input handling. No signers are constructed
// here (that's effectful/async and stays in the component) — only the input
// normalization, the key-material validation ladder, and the initial-tab choice.
// Keeping these pure makes them testable independently of the DOM/signer stack.
export type Tab = "nip07" | "nip46" | "key"
// Normalize a pasted signer link into a bunker:// URI. nostrconnect:// links are
// rewritten to the equivalent bunker:// form; anything else is passed through
// trimmed. Pure string transform.
export function normalizeBunkerUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ""
if (trimmed.startsWith("nostrconnect://")) {
const url = new URL(trimmed)
const remote = url.host || url.pathname.replace(/^\/+/, "")
const relays = url.searchParams.getAll("relay")
const secret = url.searchParams.get("secret")
const params = new URLSearchParams()
for (const relay of relays) params.append("relay", relay)
if (secret) params.set("secret", secret)
return `bunker://${remote}?${params.toString()}`
}
return trimmed
}
// Discriminated plan for key-material login, encoding the validation ladder:
// an ncryptsec requires a password; otherwise an nsec is required; otherwise it's
// an error. The component constructs the actual signer/account on the data
// branches and throws on the error branch.
export type KeyLoginPlan =
| { kind: "ncryptsec"; ncryptsec: string; password: string }
| { kind: "nsec"; key: string }
| { kind: "error"; message: string }
export function decideKeyLogin(input: { ncryptsec: string; nsec: string; password: string }): KeyLoginPlan {
const ncryptsec = input.ncryptsec.trim()
if (ncryptsec) {
if (!input.password.trim()) {
return { kind: "error", message: "Password is required for ncryptsec" }
}
return { kind: "ncryptsec", ncryptsec, password: input.password }
}
const key = input.nsec.trim()
if (!key) return { kind: "error", message: "Enter an nsec or ncryptsec key" }
return { kind: "nsec", key }
}
// Default login tab: prefer the extension when one is present, otherwise the
// remote signer tab.
export function initialLoginTab(hasExtension: boolean): Tab {
return hasExtension ? "nip07" : "nip46"
}
+35
View File
@@ -0,0 +1,35 @@
import { createComponent, createContext, useContext } from "solid-js"
import type { JSX } from "solid-js"
import type { IAccount } from "applesauce-accounts"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { account, eventStore, pool } from "@/lib/state"
// A single lightweight DI seam for the app's nostr singletons. eventStore and
// pool are genuine app-lifetime singletons; account is a reactive signal. This
// context lets call sites reach them without importing the module globals
// directly, while falling back to the live singletons when no Provider is
// mounted — so production wiring stays byte-for-byte identical and the seam
// only matters for tests/alternate mounts.
export type NostrContext = {
account: () => IAccount | undefined
eventStore: EventStore
pool: RelayPool
}
const NostrContextImpl = createContext<NostrContext>()
export function useNostr(): NostrContext {
return useContext(NostrContextImpl) ?? { account, eventStore, pool }
}
// Authored without JSX so this stays a plain .ts module. createComponent renders
// the context Provider with the given value, wrapping the children.
export function NostrProvider(props: { value: NostrContext; children?: JSX.Element }): JSX.Element {
return createComponent(NostrContextImpl.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
+43
View File
@@ -0,0 +1,43 @@
import type { InvoiceMethod, Tenant } from "@/lib/api"
// A discriminated view of a single payment method's state, derived AT the
// boundary from the raw tenant fields (nwc_is_set/stripe_payment_method_id and
// the matching *_error). This replaces the ad-hoc per-page MethodState objects
// and the scattered nwc/stripe boolean expressions so the surfaces can't drift.
// No new wire data is emitted: these read the same raw fields the API returns.
export type PaymentMethodState =
| { kind: "not_set_up" }
| { kind: "ok" }
| { kind: "error"; message: string }
export function nwcState(t: Pick<Tenant, "nwc_is_set" | "nwc_error">): PaymentMethodState {
if (!t.nwc_is_set) return { kind: "not_set_up" }
if (t.nwc_error) return { kind: "error", message: t.nwc_error }
return { kind: "ok" }
}
export function cardState(t: Pick<Tenant, "stripe_payment_method_id" | "stripe_error">): PaymentMethodState {
if (!t.stripe_payment_method_id) return { kind: "not_set_up" }
// Don't surface Stripe's raw decline/error text to the tenant (it can be noisy
// or sensitive); show a generic message. The detail stays on the tenant record
// for admins (see AdminTenantDetail).
if (t.stripe_error) return { kind: "error", message: "Payment failed" }
return { kind: "ok" }
}
// True when the tenant has any usable automatic payment method on file.
export function autopayConfigured(
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean {
return t.nwc_is_set || Boolean(t.stripe_payment_method_id)
}
const METHOD_LABELS: Record<InvoiceMethod, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
export function methodLabel(m: InvoiceMethod): string {
return METHOD_LABELS[m]
}
+3
View File
@@ -0,0 +1,3 @@
export function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
+15
View File
@@ -0,0 +1,15 @@
// Relay feature/policy flags are stored on the wire as numeric 0/1 (see
// Relay/CreateRelayInput/UpdateRelayInput in api.ts). These helpers centralize
// the boolean<->0/1 conversion so it isn't duplicated across the toggle UI and
// the toggle mutations. The wire shape stays numeric: boolToFlag returns the
// literal union `0 | 1` so it remains assignable to the `number` input fields.
export function flagToBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
export function boolToFlag(value: boolean): 0 | 1 {
return value ? 1 : 0
}
+56
View File
@@ -0,0 +1,56 @@
// Pure decision module shared by useRelayToggles and RelayNew. No Solid signals,
// no awaits, no effects live here — only the decisions (payload shape, optimistic
// copy shape, the toggle next-relay computation, and the needs-setup -> invoice ->
// setup ladder). The effect layers (mutate, awaits, signal writes, navigate) stay
// in the hook/components that import these. Keeping the decisions pure makes the
// plan-upgrade and relay-creation flows testable and dedupes the post-paid ladder
// that RelayNew and handleUpdatePlan previously inlined separately.
import { flagToBool, boolToFlag } from "@/lib/relayFlags"
import type { Invoice, PlanId, Relay, UpdateRelayInput } from "@/lib/api"
// CRITICAL: the returned object is the JSON request payload sent to PUT /relays.
// Keep exactly these field names/values so the wire stays byte-identical to the
// previous inline payload: `{ plan_id }` for paid plans, plus blossom/livekit
// disable flags for free.
export function planUpdatePayload(plan_id: PlanId): UpdateRelayInput {
if (plan_id === "free") {
return { plan_id, blossom_enabled: 0, livekit_enabled: 0 }
}
return { plan_id }
}
// The optimistic local copy passed to mutate(). Internal-only type, mirrors the
// payload's effect on the relay row. Free downgrades also clear the paid feature
// flags so the optimistic view matches what the server will persist.
export function applyPlanToRelay(relay: Relay, plan_id: PlanId): Relay {
if (plan_id === "free") {
return { ...relay, plan_id, blossom_enabled: 0, livekit_enabled: 0 }
}
return { ...relay, plan_id }
}
// Pure next-relay computation for a single boolean flag toggle, extracted from
// useRelayToggles. The flag is stored as 0/1 on the wire, so flip the derived
// boolean and convert back.
export function toggleField(relay: Relay, field: keyof Relay, fallback: boolean): Relay {
return { ...relay, [field]: boolToFlag(!flagToBool(relay[field] as number, fallback)) }
}
// Discriminated decision for what to do after a paid create/upgrade succeeds and
// the tenant's payment-setup state has been resolved. Internal/derived — never
// serialized.
export type PaidFlowDecision =
| { kind: "navigate" }
| { kind: "pay_invoice"; invoice: Invoice }
| { kind: "setup" }
// The exact ladder both RelayNew's post-create branch and handleUpdatePlan's
// post-upgrade branch inline: if the tenant already has autopay configured, just
// navigate; otherwise surface the materialized open invoice to pay directly, or
// fall back to payment setup when none is available.
export function decidePostPaidFlow(args: { needsSetup: boolean; invoice: Invoice | null }): PaidFlowDecision {
if (!args.needsSetup) return { kind: "navigate" }
if (args.invoice) return { kind: "pay_invoice", invoice: args.invoice }
return { kind: "setup" }
}
+10
View File
@@ -0,0 +1,10 @@
import Fuse from "fuse.js"
export const FUSE_THRESHOLD = 0.35
export function fuzzySearch<T>(list: T[], keys: string[], query: string): T[] {
if (!query) return list
return new Fuse(list, {keys, threshold: FUSE_THRESHOLD, ignoreLocation: true})
.search(query)
.map(result => result.item)
}
+136 -3
View File
@@ -6,7 +6,8 @@ import { EventStore } from "applesauce-core"
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
import { RelayPool } from "applesauce-relay"
import { NostrConnectSigner } from "applesauce-signers"
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, reconcileInvoice, reconcileTenant, registerAccountGetter, selectPayableInvoice, type Plan } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
export type UnsignedEvent = {
kind: number
@@ -25,7 +26,7 @@ export type EventSigner = {
signEvent(event: UnsignedEvent): Promise<SignedEvent>
}
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const eventStore = new EventStore()
export const pool = new RelayPool()
@@ -46,6 +47,21 @@ export const [account, setAccount] = createSignal<IAccount | undefined>()
registerAccountGetter(account)
export type ToastVariant = "error" | "success"
export const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
// Set while the create/upgrade flow drives its own payment/setup modals, so the
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
// every close path of that flow.
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
export const [plans] = createResource<Plan[]>(listPlans, { initialValue: [] })
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
@@ -55,6 +71,106 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
}
)
// Shared billing reads, fetched once per session and consumed by the dashboard
// shell, the billing page, and the billing-prompt surface. They're gated on
// billingPubkey rather than the active account directly: the tenant row is
// provisioned lazily on first login, so getTenant/listTenantInvoices 404 until
// ensureSessionTenant() has created it. billingPubkey is reset on activation and
// re-set once the tenant is ensured, so the reads still refetch on account switch
// without racing ahead of provisioning. refetchBilling() refreshes them all after
// a mutation (payment, method update, plan change).
const [billingPubkey, setBillingPubkey] = createSignal<string>()
const billingKey = () => billingPubkey()
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
export const [billingDraftInvoice, { refetch: refetchBillingDraftInvoice }] = createResource(billingKey, getDraftInvoice)
export function refetchBilling() {
void Promise.allSettled([
refetchBillingTenant(),
refetchBillingInvoices(),
refetchBillingRelays(),
refetchBillingDraftInvoice(),
]).then(results => {
if (results.some(r => r.status === "rejected")) {
const err = results.find(r => r.status === "rejected") as PromiseRejectedResult | undefined
console.error("Failed to refresh billing data", err?.reason)
setToastMessage("Failed to refresh billing data")
}
})
}
// In-flight autopay run, keyed by pubkey, so concurrent triggers (a mount
// double-fire, two dialog onClose handlers) collapse into one run.
let autopayInFlight: { pubkey: string; promise: Promise<void> } | undefined
// The side-effecting billing refresh, layered above the pure refetchBilling: on
// load of the billing surface it reconciles the subscription (materializing the
// current invoice), syncs the Stripe payment method (picking up a portal-added
// card), and — when a method is on file and an invoice is due — collects it,
// then refreshes all billing reads. This is what makes "add a card, return to
// the app" actually pay the open invoice. Payment is skipped while the
// create/upgrade flow owns the invoice (billingFlowActive).
export function autopayBilling(pubkey: string): Promise<void> {
if (autopayInFlight?.pubkey === pubkey) return autopayInFlight.promise
const promise = (async () => {
try {
// The tenant row is provisioned lazily on first login; make sure it exists
// before the tenant-scoped POSTs, since autopay can fire (on the Account
// landing effect) before provisioning has completed.
if (billingPubkey() !== pubkey) await ensureSessionTenant()
// Sync the payment method + reconcile the subscription, then collect the
// oldest open invoice when a method is on file (and the create/upgrade
// flow isn't already driving its own invoice).
const tenant = await reconcileTenant(pubkey)
const invoice = selectPayableInvoice(await listTenantInvoices(pubkey))
if (invoice && autopayConfigured(tenant) && !billingFlowActive()) {
await reconcileInvoice(invoice.id)
}
} catch (e) {
console.error("Autopay billing failed", e)
setToastMessage(e instanceof Error ? e.message : "Failed to update billing")
} finally {
// Reflect the final state (paid invoice, cleared errors, relay changes).
refetchBilling()
if (autopayInFlight?.pubkey === pubkey) autopayInFlight = undefined
}
})()
autopayInFlight = { pubkey, promise }
return promise
}
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
// tenant is created lazily on first login, so this must run before any
// tenant-scoped read. The in-flight promise is shared so the login flow (which
// awaits it to roll back on failure) and the activation subscriber below don't
// double-provision; createTenant is itself idempotent.
let tenantEnsure: { pubkey: string; promise: Promise<void> } | undefined
export function ensureSessionTenant(): Promise<void> {
const pubkey = account()?.pubkey
if (!pubkey) return Promise.resolve()
if (tenantEnsure?.pubkey === pubkey) return tenantEnsure.promise
const promise = (async () => {
try {
await createTenant()
if (account()?.pubkey === pubkey) setBillingPubkey(pubkey)
} finally {
if (tenantEnsure?.pubkey === pubkey) tenantEnsure = undefined
}
})()
tenantEnsure = { pubkey, promise }
return promise
}
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
queueMicrotask(() => {
try {
@@ -69,7 +185,13 @@ queueMicrotask(() => {
accountManager.setActive(active)
}
accountManager.active$.subscribe(account => {
// Held for the whole app session: this callback persists accounts to
// localStorage and ensures the session tenant on every switch, so it must
// never be torn down by a component lifecycle. The only teardown is the HMR
// dispose hook below, which prevents a Vite hot-update from stacking a
// duplicate persisting subscriber during dev (production uses /* @refresh
// reload */, so it never runs there).
const accountSubscription = accountManager.active$.subscribe(account => {
setAccount(account)
localStorage.setItem("caravel.accounts", JSON.stringify(accountManager.toJSON(true)))
@@ -81,5 +203,16 @@ queueMicrotask(() => {
}
refetchIdentity()
// Lock billing reads until the new account's tenant is ensured, so they never
// fire against a not-yet-provisioned tenant during signup.
setBillingPubkey(undefined)
if (account)
void ensureSessionTenant().catch(e => {
console.error("Failed to ensure tenant", e)
setToastMessage(e instanceof Error ? e.message : "Failed to set up your billing account")
})
})
if (import.meta.hot) import.meta.hot.dispose(() => accountSubscription.unsubscribe())
})
+2
View File
@@ -1,3 +1,5 @@
export const RELAY_DOMAIN = import.meta.env.VITE_RELAY_DOMAIN
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
+132
View File
@@ -0,0 +1,132 @@
import { createSignal } from "solid-js"
import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
import { methodLabel } from "@/lib/paymentMethod"
import { formatUsd } from "@/lib/format"
import { PLATFORM_NAME } from "@/lib/state"
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
function escapeHtml(value: string) {
const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
return value.replace(/[&<>"']/g, (c) => map[c])
}
// Generates a printable invoice and opens the browser's print/save-as-PDF dialog.
// No PDF dependency: the invoice is rendered as standalone HTML into an off-screen
// iframe so the current page is never disturbed. The bitcoin line is included only
// for Lightning-relevant invoices (never card-paid or void) to avoid spuriously
// minting a bolt11.
export function useInvoicePdf() {
const [printing, setPrinting] = createSignal(false)
// `loadItems` overrides how line items are fetched — used by the draft invoice,
// whose sentinel id has no row in the per-invoice items endpoint.
async function printInvoice(invoice: Invoice, loadItems?: () => Promise<InvoiceItem[]>) {
if (printing()) return
setPrinting(true)
try {
const fetchItems = loadItems ?? (() => listInvoiceItems(invoice.id))
const items = await fetchItems().catch(e => {
console.error("Failed to load invoice line items", e)
return [] as InvoiceItem[]
})
let sats: number | undefined
if (invoice.method !== "stripe" && invoice.voided_at == null) {
try {
const bolt11 = await ensureInvoiceBolt11(invoice.id)
sats = Math.round(bolt11.msats / 1000)
} catch {
// no bolt11 available — omit the bitcoin line
}
}
printHtml(buildHtml({ invoice, items, sats }))
} finally {
setPrinting(false)
}
}
return { printInvoice, printing }
}
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number }): string {
const { invoice, items, sats } = opts
// The draft invoice carries the sentinel id and no lifecycle timestamps, so
// invoiceStatus would read it as "open" — label it "draft" explicitly.
const status = invoice.id === "draft" ? "draft" : invoiceStatus(invoice)
const rows = items.length
? items
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${formatUsd(i.amount)}</td></tr>`)
.join("")
: `<tr><td>Relay subscription</td><td class="amt">${formatUsd(invoice.amount)}</td></tr>`
const satsRow = sats != null ? `<tr><td>Bitcoin equivalent</td><td class="amt">${sats.toLocaleString()} sats</td></tr>` : ""
const methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabel(invoice.method))}</div>` : ""
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
<style>
* { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111827; box-sizing: border-box; }
body { margin: 40px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.muted { color: #6b7280; font-size: 12px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.badge { text-transform: capitalize; border: 1px solid #d1d5db; border-radius: 9999px; padding: 2px 10px; font-size: 12px; }
.meta { font-size: 13px; color: #374151; line-height: 1.6; word-break: break-all; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
.amt { text-align: right; white-space: nowrap; }
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
</style></head>
<body>
<div class="head">
<div><h1>${escapeHtml(PLATFORM_NAME)}</h1><div class="muted">Invoice</div></div>
<span class="badge">${status}</span>
</div>
<div class="meta">
<div>Invoice ID: ${escapeHtml(invoice.id)}</div>
<div>Billed to: ${escapeHtml(invoice.tenant_pubkey)}</div>
<div>Issued: ${fmtDate(invoice.created_at)}</div>
<div>Period: ${fmtDate(invoice.period_start)} &ndash; ${fmtDate(invoice.period_end)}</div>
${methodLine}
</div>
<table>
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
<tbody>${rows}${satsRow}</tbody>
<tfoot><tr><td>Total</td><td class="amt">${formatUsd(invoice.amount)}</td></tr></tfoot>
</table>
</body></html>`
}
function printHtml(html: string) {
const iframe = document.createElement("iframe")
iframe.style.position = "fixed"
iframe.style.right = "0"
iframe.style.bottom = "0"
iframe.style.width = "0"
iframe.style.height = "0"
iframe.style.border = "0"
document.body.appendChild(iframe)
const win = iframe.contentWindow
const doc = win?.document
if (!win || !doc) {
iframe.remove()
return
}
doc.open()
doc.write(html)
doc.close()
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
win.onafterprint = cleanup
// Let the iframe lay out before printing.
window.setTimeout(() => {
win.focus()
win.print()
window.setTimeout(cleanup, 60000)
}, 150)
}
+97
View File
@@ -0,0 +1,97 @@
import { createSignal } from "solid-js"
import { updateActiveTenant } from "@/lib/hooks"
import { createInvoiceCheckout, createPortalSession } from "@/lib/api"
import { account } from "@/lib/state"
// Lightning/NWC save state machine, shared by the combined and focused setup
// dialogs. `onSaved` fires once the wallet URL is persisted.
export function useNwcSetup(onSaved?: () => void) {
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
const [error, setError] = createSignal("")
async function save() {
const url = nwcUrl().trim()
if (!url) return
setSaving(true)
setError("")
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
setSaving(false)
}
}
function reset() {
setNwcUrl("")
setSaved(false)
setError("")
}
return { nwcUrl, setNwcUrl, saving, saved, error, save, reset }
}
export type NwcSetup = ReturnType<typeof useNwcSetup>
// Card setup is a full-page redirect to the Stripe billing portal (which returns
// to wherever it was opened from), so there's no local "saved" state — only the
// in-flight redirect and any failure to open the portal.
export function useCardPortal() {
const [redirecting, setRedirecting] = createSignal(false)
const [error, setError] = createSignal("")
async function openPortal() {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
setRedirecting(false)
}
}
function reset() {
setError("")
}
return { redirecting, error, openPortal, reset }
}
export type CardPortal = ReturnType<typeof useCardPortal>
// Paying one specific invoice by card is a full-page redirect to a Stripe
// Checkout session scoped to that invoice (so a 3D Secure challenge can be
// completed) — distinct from the billing-portal redirect that manages the
// recurring card on file. Like the portal, there's no local "saved" state, only
// the in-flight redirect and any failure to open the session.
export function useInvoiceCheckout(invoiceId: () => string) {
const [redirecting, setRedirecting] = createSignal(false)
const [error, setError] = createSignal("")
async function openCheckout() {
setRedirecting(true)
setError("")
try {
const { url } = await createInvoiceCheckout(invoiceId())
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open checkout")
setRedirecting(false)
}
}
function reset() {
setError("")
}
return { redirecting, error, openCheckout, reset }
}
export type InvoiceCheckout = ReturnType<typeof useInvoiceCheckout>
+21 -30
View File
@@ -1,18 +1,9 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import { updateRelayById, deactivateRelayById, reactivateRelayById, resolvePostPaidFlow, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/lib/state"
import { applyPlanToRelay, planUpdatePayload, toggleField } from "@/lib/relayPlanFlow"
import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
function toInt(value: boolean): number {
return value ? 1 : 0
}
type RelayResource = {
(): Relay | undefined
loading: boolean
@@ -47,8 +38,7 @@ export default function useRelayToggles(
function toggle(field: keyof Relay, fallback: boolean) {
const current = relay()
if (!current) return
const next = { ...current, [field]: toInt(!toBool(current[field] as number, fallback)) }
void updateRelay(next, current)
void updateRelay(toggleField(current, field, fallback), current)
}
async function handleDeactivate() {
@@ -82,18 +72,10 @@ export default function useRelayToggles(
if (!current) return
const previous = current
const next = { ...current, plan_id }
const update: Record<string, unknown> = { plan_id }
if (plan_id === "free") {
next.blossom_enabled = 0
next.livekit_enabled = 0
update.blossom_enabled = 0
update.livekit_enabled = 0
}
mutate(next)
mutate(applyPlanToRelay(current, plan_id))
try {
await updateRelayById(relayId(), update)
await updateRelayById(relayId(), planUpdatePayload(plan_id))
await refetch()
} catch (e) {
mutate(previous)
@@ -101,13 +83,22 @@ export default function useRelayToggles(
throw e
}
if (plan_id !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
if (plan_id === "free") return
// Paid upgrades materialize an open invoice; resolvePostPaidFlow reconciles
// and decides whether to prompt the tenant to pay it, set up a payment method,
// or do nothing when autopay is already configured.
const decision = await resolvePostPaidFlow()
switch (decision.kind) {
case "pay_invoice":
setPendingInvoice(decision.invoice)
setPendingPaymentSetup(true)
}
break
case "setup":
setPendingPaymentSetup(true)
break
case "navigate":
break
}
}
+19
View File
@@ -0,0 +1,19 @@
export function isKnownPlanId(planId: string, plans: { id: string }[]): boolean {
return plans.some(p => p.id === planId)
}
export function validateBunkerUri(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return "Enter a bunker or nostrconnect link"
if (trimmed.startsWith("nostrconnect://") || trimmed.startsWith("bunker://")) {
try {
new URL(trimmed)
} catch {
return "That doesn't look like a valid link"
}
return null
}
return "Link must start with bunker:// or nostrconnect://"
}
+144 -157
View File
@@ -1,85 +1,90 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, getInvoice, invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
import useMinLoading from "@/lib/useMinLoading"
import { useInvoicePdf } from "@/lib/useInvoicePdf"
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
import PaymentSetupCard from "@/components/PaymentSetupCard"
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod"
import { account } from "@/lib/state"
import { formatPeriod } from "@/lib/format"
import PaymentMethodRow from "@/components/account/PaymentMethodRow"
import InvoiceListItem from "@/components/account/InvoiceListItem"
const invoiceStatusStyles: Record<string, string> = {
draft: "bg-blue-50 text-blue-700 border-blue-200",
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
const accountStatusStyles: Record<AccountStatus, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
delinquent: "bg-red-50 text-red-700 border-red-200",
}
const INVOICE_PAGE_SIZE = 10
export default function Account() {
const [tenant, { refetch: refetchTenant }] = useTenant()
const [invoices, { refetch: refetchInvoices }] = createResource(() => listTenantInvoices(account()!.pubkey))
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
const billing = useBillingStatus()
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
// invoice and opens the payment dialog. The fetched invoice takes precedence
// over a row the user clicked in the list.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinkedInvoice] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
createEffect(() => {
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
const [nwcModalOpen, setNwcModalOpen] = createSignal(false)
const [cardModalOpen, setCardModalOpen] = createSignal(false)
const [showAllInvoices, setShowAllInvoices] = createSignal(false)
const visibleInvoices = createMemo(() => {
const all = billing.invoices()
return showAllInvoices() ? all : all.slice(0, INVOICE_PAGE_SIZE)
})
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
const invoicesLoading = useMinLoading(() => billing.loading())
const { printInvoice, printing } = useInvoicePdf()
// The backend never returns the stored nwc_url (it's private), so the input is
// write-only: we can only act on a newly entered URL, not prefill the saved one.
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
// On landing here (the billing portal returns to this page), run the autopay
// composite: reconcile the subscription, sync a card just added in the portal,
// and collect the open invoice if a method is now on file — then refresh. This
// is what pays the outstanding invoice after the user adds a card and returns.
// Reconciles on landing (including after returning from a Stripe Checkout or
// the billing portal): reconcile_tenant settles any out-of-band payment — a
// completed Checkout or a bolt11 paid elsewhere — and collects when a method
// is on file, then refreshes. No per-invoice return marker needed.
createEffect(() => {
const pubkey = account()?.pubkey
if (pubkey) void billing.autopay(pubkey)
})
async function saveBilling() {
setError("")
setSaving(true)
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
setNwcUrl("")
await refetchTenant()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
} finally {
setSaving(false)
}
}
// Coarse account-health summary for the badge. Same snapshot the inline prompt
// consumes, so the two can never disagree.
const status = createMemo(() =>
accountStatus({
tenant: billing.tenant(),
openInvoice: billing.openInvoice(),
hasPaidSubscription: billing.hasPaidSubscription(),
}),
)
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
// Clearing the query param drops the deep-linked invoice and closes the dialog.
if (searchParams.invoice) setSearchParams({ invoice: undefined })
void refetchInvoices()
}
// Per-method state, reported independently so a concurrent error on one method
// isn't masked by the other. Derived from the shared boundary helpers so the
// badge surface can't drift from the billing prompts.
const nwc = createMemo<PaymentMethodState>(() => {
const t = billing.tenant()
if (!t) return { kind: "not_set_up" }
return nwcState(t)
})
async function openPortal() {
setPortalLoading(true)
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
} finally {
setPortalLoading(false)
}
}
const card = createMemo<PaymentMethodState>(() => {
const t = billing.tenant()
if (!t) return { kind: "not_set_up" }
return cardState(t)
})
function logout() {
localStorage.clear()
window.location.href = "/"
}
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
return (
<PageContainer>
<div class="mb-6 py-2 flex items-center justify-between gap-3">
@@ -95,129 +100,111 @@ export default function Account() {
<div class="space-y-6">
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<Show when={tenant()}>
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
tenant
<div class="flex items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-900">Payment Methods</h2>
<Show when={billing.tenant()}>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${accountStatusStyles[status()]}`}>
Your account is {status()}
</span>
</Show>
</div>
<ul class="space-y-3">
{/* Lightning / NWC row — CTA opens the NWC modal */}
<PaymentMethodRow title="Lightning (NWC)" state={nwc()} onAction={() => setNwcModalOpen(true)} />
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
<PaymentMethodRow title="Card" state={card()} onAction={() => setCardModalOpen(true)} />
</ul>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recurring Billing</h2>
<button
type="button"
onClick={openPortal}
disabled={portalLoading()}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{portalLoading() ? "Loading..." : "Manage Billing"}
</button>
</div>
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
</p>
<Show when={tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="flex-1 border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={saveBilling}
disabled={saving() || !hasBillingChanges()}
class="py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
<Show when={tenant()?.churned_at}>
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
Your account is past due and some relays have been paused. Update your payment method below to restore service.
</p>
</Show>
<Show when={tenant()?.nwc_error}>
<p class="mt-3 text-sm text-red-600">Lightning auto-payment failed: {tenant()!.nwc_error}</p>
</Show>
<Show when={tenant()?.stripe_error}>
<p class="mt-3 text-sm text-red-600">Card auto-payment failed: {tenant()!.stripe_error}</p>
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Invoice History</h2>
<h2 class="text-lg font-semibold text-gray-900 mb-4">Payment History</h2>
<Show when={invoicesLoading()}>
<LoadingState message="Loading invoices..." paddingClass="py-8" />
</Show>
<Show when={!invoicesLoading()}>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<Show when={billing.invoices().length > 0 || billing.draftInvoice()} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<ul class="space-y-3">
<For each={invoices()}>
{/* Draft: this period's in-progress charges, not yet a real invoice. */}
<Show when={billing.draftInvoice()}>
{(draft) => (
<InvoiceListItem
isDraft
amount={draft().amount}
statusLabel="draft"
statusStyle={invoiceStatusStyles.draft}
periodLabel={formatPeriod(draft().period_start, draft().period_end)}
onPrintPdf={() => void printInvoice(draft(), () => listDraftInvoiceItems(draft().tenant_pubkey))}
printing={printing()}
/>
)}
</Show>
<For each={visibleInvoices()}>
{(invoice) => {
const status = () => invoiceStatus(invoice)
const isOpen = () => status() === "open"
const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"
const periodLabel = () => {
const start = new Date(invoice.period_start * 1000)
const end = new Date(invoice.period_end * 1000)
return `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
}
return (
<li
class={`rounded-lg border border-gray-200 p-4 text-sm ${isOpen() ? "cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors" : ""}`}
onClick={() => isOpen() && setSelectedInvoice(invoice)}
title={isOpen() ? "Click to pay this invoice" : undefined}
>
<div class="flex items-center justify-between gap-3">
<div>
<span class="font-medium text-gray-900">
${(invoice.amount / 100).toFixed(2)}
</span>
<Show when={invoice.period_start && invoice.period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={isOpen()}>
<span class="text-xs text-blue-600 font-medium">Pay now</span>
</Show>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{status()}
</span>
</div>
</div>
</li>
<InvoiceListItem
amount={invoice.amount}
statusLabel={status()}
statusStyle={invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}
periodLabel={formatPeriod(invoice.period_start, invoice.period_end)}
method={invoice.method ? methodLabel(invoice.method) : undefined}
isOpen={status() === "open"}
onPay={() => setSelectedInvoice(invoice)}
onPrintPdf={() => void printInvoice(invoice)}
printing={printing()}
/>
)
}}
</For>
<Show when={billing.invoices().length > INVOICE_PAGE_SIZE}>
<li>
<button
type="button"
onClick={() => setShowAllInvoices(v => !v)}
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
{showAllInvoices() ? "Show less" : `Show all (${billing.invoices().length})`}
</button>
</li>
</Show>
</ul>
</Show>
</Show>
</section>
</div>
<Show when={activeInvoice()}>
<Show when={selectedInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={handleInvoiceDialogClose}
onClose={() => {
setSelectedInvoice(undefined)
void billing.autopay(account()!.pubkey)
}}
/>
)}
</Show>
<PaymentSetupNWC
open={nwcModalOpen()}
isUpdate={nwc().kind !== "not_set_up"}
onClose={() => {
setNwcModalOpen(false)
void billing.autopay(account()!.pubkey)
}}
/>
<PaymentSetupCard
open={cardModalOpen()}
isUpdate={card().kind !== "not_set_up"}
onClose={() => {
setCardModalOpen(false)
void billing.autopay(account()!.pubkey)
}}
/>
</PageContainer>
)
}
+74 -51
View File
@@ -5,11 +5,13 @@ import ExternalLinkIcon from "@/components/ExternalLinkIcon"
import PricingTable from "@/components/PricingTable"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import Modal from "@/components/Modal"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state"
import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { account, refetchBilling, setToastMessage } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
export default function Home() {
@@ -18,6 +20,9 @@ export default function Home() {
const [showLoginModal, setShowLoginModal] = createSignal(false)
const [draftRelay, setDraftRelay] = createSignal<RelayFormValues>()
const [initialPlanId, setInitialPlanId] = createSignal<RelayFormValues["plan_id"]>("free")
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = ""
function openRelayModal(planId: RelayFormValues["plan_id"] = "free") {
setInitialPlanId(planId)
@@ -25,25 +30,67 @@ export default function Home() {
}
async function onRelayFormSubmit(values: RelayFormValues) {
if (account()) {
const relay = await createRelayForActiveTenant(values)
navigate(`/relays/${relay.id}`)
} else {
// Not signed in yet: stash the draft and send them through login. The relay
// (and any payment prompt) is created in onAuthenticated once the session and
// its tenant exist, so signing up and creating a paid relay in one go still
// surfaces the invoice.
if (!account()) {
setDraftRelay(values)
setShowRelayModal(false)
setShowLoginModal(true)
return
}
const relay = await createRelayForActiveTenant(values)
createdRelayId = relay.id
setShowRelayModal(false)
// Paid plans materialize an open invoice on create. A just-signed-up tenant
// has no payment method yet, so open the payment dialog here instead of
// dropping them on the relay page with no prompt (the shared dashboard banner
// only catches up once they navigate and its billing reads refetch).
if (values.plan_id !== "free") {
void refetchBilling()
const decision = await resolvePostPaidFlow()
if (decision.kind === "pay_invoice") {
setPendingInvoice(decision.invoice)
return
}
if (decision.kind === "setup") {
setPaymentSetupOpen(true)
return
}
}
navigate(`/relays/${relay.id}`)
}
async function onAuthenticated() {
setShowLoginModal(false)
const relay = draftRelay()
setDraftRelay(undefined)
if (relay) {
onRelayFormSubmit(relay)
} else {
if (!relay) {
navigate("/relays")
return
}
try {
await onRelayFormSubmit(relay)
} catch (e) {
setToastMessage(e instanceof Error ? e.message : "Failed to create relay")
}
}
function handleInvoiceClose() {
setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
void refetchBilling()
navigate(`/relays/${createdRelayId}`)
}
return (
@@ -96,8 +143,8 @@ export default function Home() {
</h1>
<p class="text-xl text-gray-500 mb-10 max-w-2xl mx-auto leading-relaxed">
Spin up a private, managed Nostr relay for your community in minutes.
Full control over membership, access, and policies no DevOps required.
Spin up a private, managed Nostr relay for your community in minutes,
with full control over membership, access, and policies.
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
@@ -255,44 +302,6 @@ export default function Home() {
</div>
</a>
{/* Chachi */}
<a
href="https://chachi.chat"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-purple-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-purple-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A group chat app built on top of Nostr. Chachi makes it easy for your community
to have real-time conversations, all flowing through your own relay.
</p>
<div class="space-y-2">
{["Real-time group messaging", "Bring your own relay", "No accounts — just your Nostr key"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-purple-600">
Visit chachi.chat <ExternalLinkIcon />
</span>
</div>
</a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
@@ -423,6 +432,20 @@ export default function Home() {
onAuthenticated={onAuthenticated}
/>
</Modal>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</div>
)
}
@@ -0,0 +1,43 @@
import { useParams } from "@solidjs/router"
import { createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import InvoiceDetailCard from "@/components/InvoiceDetailCard"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/lib/useMinLoading"
import { listInvoiceItems } from "@/lib/api"
import { useInvoice } from "@/lib/hooks"
export default function AdminInvoiceDetail() {
const params = useParams()
const invoiceId = () => params.id ?? ""
const [invoice] = useInvoice(invoiceId)
const [items] = createResource(invoiceId, async (id) => {
if (!id) return []
try {
return await listInvoiceItems(id)
} catch {
return []
}
})
const loading = useMinLoading(() => invoice.loading && !invoice())
return (
<PageContainer>
<BackLink label="Back" />
<ResourceState loading={loading()} error={invoice.error} loadingText="Loading invoice..." errorText="Failed to load invoice." class="mb-4" />
<Show when={!loading() && invoice()}>
{(i) => (
<div class="space-y-6 mb-6">
<InvoiceDetailCard invoice={i()} />
<Show when={(items()?.length ?? 0) > 0}>
<InvoiceItemsList items={items() ?? []} />
</Show>
</div>
)}
</Show>
</PageContainer>
)
}
@@ -0,0 +1,49 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { fuzzySearch } from "@/lib/search"
import AdminInvoiceListItem from "@/components/AdminInvoiceListItem"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/lib/useMinLoading"
import { primeProfiles, useAdminInvoices } from "@/lib/hooks"
export default function AdminInvoiceList() {
const [query, setQuery] = createSignal("")
const [invoices] = useAdminInvoices()
const loading = useMinLoading(() => invoices.loading)
// Each list item shows its tenant's profile; prime them all in one batch so
// we don't open a separate outbox subscription per invoice.
createEffect(() => {
const list = invoices() ?? []
if (!list.length) return
const sub = primeProfiles(list.map(i => i.tenant_pubkey))
onCleanup(() => sub.unsubscribe())
})
const filtered = createMemo(() => {
const list = invoices() ?? []
const q = query().trim()
if (!q) return list
return fuzzySearch(list, ["tenant_pubkey", "id"], q)
})
return (
<PageContainer>
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Invoices</h1>
<div class="mb-6">
<SearchInput value={query()} onInput={setQuery} placeholder="Search invoices..." />
</div>
<ResourceState loading={loading()} error={invoices.error} loadingText="Loading invoices..." errorText="Failed to load invoices." />
<Show when={!loading()}>
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No invoices found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} showTenant />}
</For>
</ul>
</Show>
</Show>
</PageContainer>
)
}
@@ -4,7 +4,7 @@ import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks"
@@ -29,7 +29,7 @@ export default function AdminRelayDetail() {
return (
<PageContainer>
<BackLink href="/admin/relays" label="Relays" />
<BackLink label="Back" />
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
+14 -5
View File
@@ -1,22 +1,31 @@
import Fuse from "fuse.js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { fuzzySearch } from "@/lib/search"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import { useAdminRelays } from "@/lib/hooks"
import useMinLoading from "@/lib/useMinLoading"
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
export default function AdminRelayList() {
const [query, setQuery] = createSignal("")
const [relays] = useAdminRelays()
const loading = useMinLoading(() => relays.loading)
// Each list item shows its tenant's profile; prime them all in one batch so
// we don't open a separate outbox subscription per relay.
createEffect(() => {
const list = relays() ?? []
if (!list.length) return
const sub = primeProfiles(list.map(r => r.tenant_pubkey))
onCleanup(() => sub.unsubscribe())
})
const filtered = createMemo(() => {
const list = relays() ?? []
const q = query().trim()
if (!q) return list
return new Fuse(list, { keys: ["info_name", "subdomain", "tenant"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
return fuzzySearch(list, ["info_name", "subdomain", "tenant_pubkey"], q)
})
return (
+43 -9
View File
@@ -1,18 +1,23 @@
import { useParams } from "@solidjs/router"
import { For, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import AdminInvoiceListItem from "@/components/AdminInvoiceListItem"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenant, useAdminTenantInvoices, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
export default function AdminTenantDetail() {
const params = useParams()
const tenantId = () => params.id ?? ""
const [tenant] = useAdminTenant(tenantId)
const [relays] = useAdminTenantRelays(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading)
const [invoices] = useAdminTenantInvoices(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading || invoices.loading)
const metadata = useProfileMetadata(tenantId)
const churnedLabel = () => {
const ts = tenant()?.churned_at
@@ -22,9 +27,24 @@ export default function AdminTenantDetail() {
return (
<PageContainer>
<BackLink href="/admin/tenants" label="Tenants" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Tenant {params.id}</h1>
<ResourceState loading={loading()} error={tenant.error || relays.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
<BackLink label="Back" />
<div class="flex items-center gap-4 mb-6 py-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-14 w-14 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-lg">
{((metadata()?.name || metadata()?.display_name) || tenantId()).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="Profile" class="h-14 w-14 flex-shrink-0 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(tenantId())}</h1>
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
</div>
</div>
<ResourceState loading={loading()} error={tenant.error || relays.error || invoices.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
<Show when={!loading()}>
<div class="space-y-6">
<section class="bg-white border border-gray-200 rounded-xl p-6">
@@ -32,14 +52,18 @@ export default function AdminTenantDetail() {
<Show when={tenant()}>
{(t) => (
<dl class="grid gap-y-3 text-sm">
<div class="flex gap-2">
<div class="flex gap-2 items-center">
<dt class="text-gray-500">Status:</dt>
<dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
<dd>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${t().churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
{t().churned_at ? "delinquent" : "active"}
</span>
</dd>
</div>
<Show when={t().stripe_customer_id}>
<div class="flex gap-2">
<dt class="text-gray-500">Stripe Customer:</dt>
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
<dd class="font-mono">{t().stripe_customer_id}</dd>
</div>
</Show>
<Show when={churnedLabel()}>
@@ -74,6 +98,16 @@ export default function AdminTenantDetail() {
</ul>
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4">Invoices</h2>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500">No invoices.</p>}>
<ul class="space-y-3">
<For each={invoices() ?? []}>
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} />}
</For>
</ul>
</Show>
</section>
</div>
</Show>
</PageContainer>
+21 -43
View File
@@ -1,58 +1,33 @@
import { A } from "@solidjs/router"
import Fuse from "fuse.js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import { primeProfiles, useAdminTenants } from "@/lib/hooks"
import { eventStore } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenants, useProfileMetadataMap } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { fuzzySearch } from "@/lib/search"
export default function AdminTenantList() {
const [query, setQuery] = createSignal("")
const [tenants] = useAdminTenants()
const [profiles, setProfiles] = createSignal<Record<string, { name?: string; about?: string; nip05?: string; picture?: string }>>({})
const profiles = useProfileMetadataMap(() => (tenants() ?? []).map((t) => t.pubkey))
const loading = useMinLoading(() => tenants.loading)
const filtered = createMemo(() => {
const list = (tenants() ?? []).map((tenant) => {
const profile = profiles()[tenant.pubkey]
return { ...tenant, profileName: profile?.name, profileAbout: profile?.about, profileNip05: profile?.nip05 }
return {
...tenant,
profileName: profile?.name || profile?.display_name,
profileAbout: profile?.about,
profileNip05: profile?.nip05,
}
})
const q = query().trim()
if (!q) return list
return new Fuse(list, { keys: ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
})
createEffect(() => {
const list = tenants() ?? []
if (!list.length) return
const pubkeys = list.map(t => t.pubkey)
const reqSub = primeProfiles(pubkeys)
const profileSubs = pubkeys.map((pubkey) =>
eventStore.profile(pubkey).subscribe((profile) => {
setProfiles(prev => ({
...prev,
[pubkey]: {
name: profile?.name || profile?.display_name,
about: profile?.about,
nip05: profile?.nip05,
picture: getProfilePicture(profile),
},
}))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
return fuzzySearch(list, ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], q)
})
return (
@@ -68,24 +43,27 @@ export default function AdminTenantList() {
<For each={filtered()}>
{(tenant) => {
const profile = () => profiles()[tenant.pubkey]
const profileName = () => profile()?.name || profile()?.display_name
return (
<li>
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex items-start gap-3">
<Show
when={profile()?.picture}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
when={getProfilePicture(profile())}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profileName() || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
>
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
<img src={getProfilePicture(profile())} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
<p class="font-medium text-gray-900 truncate">{profileName() || shortenPubkey(tenant.pubkey)}</p>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
</div>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">tenant</p>
<span class={`inline-flex flex-shrink-0 items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${tenant.churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
{tenant.churned_at ? "delinquent" : "active"}
</span>
</div>
</A>
</li>
+11 -80
View File
@@ -1,17 +1,17 @@
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
import { refetchBilling, setBillingFlowActive } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
@@ -30,10 +30,7 @@ export default function RelayDetail() {
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
@@ -42,71 +39,18 @@ export default function RelayDetail() {
}
})
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan_id)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_is_set && !t.stripe_payment_method_id
})
// Suppress the shared banner's redundant pay/setup prompts while this page's
// own inline plan-change modals are open.
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false))
return (
<PageContainer>
<BackLink href="/relays" label="Relays" />
<BackLink label="Back" />
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members()?.length}
@@ -129,20 +73,7 @@ export default function RelayDetail() {
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
void refetchBilling()
}}
/>
)}
@@ -151,7 +82,7 @@ export default function RelayDetail() {
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
void refetchBilling()
}}
/>
</PageContainer>

Some files were not shown because too many files have changed in this diff Show More