Compare commits

...

26 Commits

Author SHA1 Message Date
Jon Staab 4a5bd4d563 Add contact option on main pricing page
Docker / build-and-push-image (push) Successful in 1m35s
2026-06-17 15:03:23 -07:00
Jon Staab 7750a5e552 Put host in the relay stuct
Docker / build-and-push-image (push) Successful in 1m39s
2026-06-16 09:04:17 -07:00
Jon Staab 326fe9d2e1 Add migration to fix syncing imported relays
Docker / build-and-push-image (push) Successful in 1m35s
2026-06-12 14:20:14 -07:00
Jon Staab 90f5a55269 Add custom domain support
Docker / build-and-push-image (push) Successful in 1m41s
2026-06-12 13:31:40 -07:00
Jon Staab bd3217f43d Show pubkey copy things
Docker / build-and-push-image (push) Successful in 35s
2026-06-12 13:01:10 -07:00
Jon Staab 5587d40688 Show plan on relay detail header
Docker / build-and-push-image (push) Successful in 34s
2026-06-11 17:50:55 -07:00
Jon Staab ada9e10570 Show plan on the relay list
Docker / build-and-push-image (push) Successful in 36s
2026-06-11 17:44:35 -07:00
Jon Staab 0fc7c706f0 Add build cache, remove arm build target
Docker / build-and-push-image (push) Successful in 5m26s
2026-06-11 16:47:06 -07:00
Jon Staab a34f5ec41d Split out migration
Docker / build-and-push-image (push) Successful in 1h8m33s
2026-06-11 16:23:08 -07:00
Jon Staab 4ebc9fe61b Add license
Docker / build-and-push-image (push) Successful in 1h6m53s
2026-06-08 10:47:43 -07:00
Jon Staab b5f3efc775 Fix a few bugs
Docker / build-and-push-image (push) Successful in 1h6m37s
2026-06-05 11:31:10 -07:00
Jon Staab 791a4fcb70 Fix relay link, add admin navigation item on mobile
Docker / build-and-push-image (push) Successful in 1h6m43s
2026-06-04 16:00:19 -07:00
Jon Staab 28d1d164f1 Bring back chachi
Docker / build-and-push-image (push) Successful in 1h6m45s
2026-06-03 20:50:20 -07:00
Jon Staab 1097a5eba3 Add link to home page at the top of the sidebar
Docker / build-and-push-image (push) Successful in 1h8m8s
2026-06-03 17:10:38 -07:00
Jon Staab e3083304d0 Disable logging when serving frontend
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 17:07:53 -07:00
Jon Staab 451264106a Make logo customizable
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 16:46:20 -07:00
Jon Staab 96e2fcda49 Use cargo chef to speed up builds 2026-06-03 16:27:56 -07:00
Jon Staab 73cad3a153 Tweak docs 2026-06-03 15:39:56 -07:00
Jon Staab 5f8b08e02c Clean up the dockerfile a bit
Docker / build-and-push-image (push) Successful in 52m10s
2026-06-03 15:32:48 -07:00
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
52 changed files with 1875 additions and 409 deletions
+3 -1
View File
@@ -45,6 +45,8 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64 #,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
+2
View File
@@ -21,6 +21,8 @@ When referring to a tenant's pubkey, always name it `tenant_pubkey`, not `tenant
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.
+28 -39
View File
@@ -1,20 +1,36 @@
# syntax=docker/dockerfile:1
# ---------- Build the Rust backend ----------
FROM rust:1.94-bookworm AS backend-build
# cargo-chef caches the compiled dependency graph in its own layer, so a
# source-only change recompiles just our crate instead of every dependency from
# scratch. The recipe is derived solely from Cargo.toml/Cargo.lock, so the
# expensive `cook` layer is reused until the dependency set changes.
# https://github.com/LukeMathWalker/cargo-chef
FROM rust:1.94-bookworm AS chef
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& cargo install --locked cargo-chef
# Distill the dependency recipe. Cheap, and re-runs on any source change, but
# yields a stable recipe.json as long as dependencies are unchanged — which is
# what keeps the cook layer below cached.
FROM chef AS planner
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
RUN cargo chef prepare --recipe-path recipe.json
# Build (and cache) dependencies from the recipe, then compile the application.
# Must share the chef stage's Rust version for the cached layer to apply.
FROM chef AS backend-build
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
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 ----------
@@ -35,7 +51,8 @@ COPY frontend ./
ENV VITE_API_URL=__VITE_API_URL__ \
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__ \
VITE_PLATFORM_LOGO=__VITE_PLATFORM_LOGO__
RUN bun run build
# ---------- Runtime ----------
@@ -54,42 +71,14 @@ WORKDIR /app
COPY --from=backend-build /app/target/release/backend /app/backend
COPY --from=frontend-build /app/frontend/dist /app/dist
COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh
# 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
RUN mkdir -p /app/data && chown -R node:node /app/dist /app/data
# 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:-}"
USER node:node
# 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
ENV SERVER_PORT=2892 \
DATABASE_URL=sqlite:///app/data/caravel.db
EXPOSE 2892 3000
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jon Staab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+62 -2
View File
@@ -17,12 +17,11 @@ docker run -d \
-p 3000:3000 \
-v my-caravel-data:/app/data \
-e PLATFORM_NAME=Caravel \
-e PLATFORM_LOGO=/caravel.png \
-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> \
@@ -59,6 +58,67 @@ To build the image yourself instead of pulling it:
docker build -t caravel .
```
### Zooid and TLS
Zooid (the relay engine) should run on its own server with [Caddy](https://caddyserver.com/) as the TLS-terminating reverse proxy. Keeping Zooid separate lets it scale independently and makes custom relay domains work without touching the Caravel host.
**Why Caddy?** Caravel supports tenant-facing custom domains, which require per-domain TLS certificates that are provisioned automatically. Caddy's [on-demand TLS](https://caddyserver.com/docs/automatic-https#on-demand-tls) handles this: it calls a Caravel endpoint before issuing each certificate, so only known domains get one.
#### Zooid server setup
On the Zooid server, create a `Caddyfile`:
```
{
on_demand_tls {
ask http://<caravel-host>:2892/domains/check
interval 2m
burst 5
}
}
:443 {
tls {
on_demand
}
reverse_proxy localhost:3334
}
```
Replace `<caravel-host>` with the hostname or IP of the Caravel server. The `/domains/check` endpoint returns `200` for any subdomain of `RELAY_DOMAIN` and for any tenant custom domain that has been verified, and `404` otherwise — Caddy will only obtain a certificate if it gets a `200`.
Run Caddy and Zooid together, for example with Docker Compose:
```yaml
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
zooid:
image: gitea.coracle.social/coracle/zooid
environment:
API_HOST: api.zooid.example.com
API_WHITELIST: <hex-pubkey-matching-ROBOT_SECRET>
volumes:
- zooid_data:/app/data
restart: unless-stopped
volumes:
caddy_data:
zooid_data:
```
Point your wildcard DNS record (`*.relay_domain`) at this server's IP. Custom domains are pointed there by tenants via a CNAME to their relay's canonical subdomain; Caravel verifies the CNAME in the background and notifies Zooid once confirmed.
Set `ZOOID_API_URL` in Caravel's environment to the same value as zooid's `API_HOST` value, prefixed with the protocol, e.g. `https://api.zooid.example.com`.
## Local Development
### Prerequisites
+2 -2
View File
@@ -43,11 +43,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `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_PORT` | API bind port; the server binds `0.0.0.0` (all interfaces) |
| `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/` |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths resolve under the compile-time crate dir (`backend/` in dev, `/app` in the Docker image) |
**Robot identity**
+22 -1
View File
@@ -69,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)
);
@@ -87,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)
);
@@ -106,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;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE relay ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_relay_created ON relay (created_at);
@@ -0,0 +1,2 @@
ALTER TABLE relay ADD COLUMN custom_domain TEXT NOT NULL DEFAULT '';
ALTER TABLE relay ADD COLUMN custom_domain_verified INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,29 @@
-- A manual migration created relays without recording the `complete_relay_sync`
-- activity that `complete_relay_sync` normally emits. Backfill one per synced relay
-- that lacks it, mirroring `insert_activity_tx`: a fresh v4 UUID id, the relay's tenant,
-- a NULL billed_at, and a snapshot of the relay's current plan and status. This
-- activity type is not billable (see `list_billable_activity`), so the NULL
-- billed_at bills no one.
INSERT INTO activity (id, tenant_pubkey, created_at, activity_type, resource_type, resource_id, snapshot)
SELECT
lower(
hex(randomblob(4)) || '-' ||
hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)), 2) || '-' ||
substr('89ab', abs(random() % 4) + 1, 1) ||
substr(hex(randomblob(2)), 2) || '-' ||
hex(randomblob(6))
),
relay.tenant_pubkey,
CAST(strftime('%s', 'now') AS INTEGER),
'complete_relay_sync',
'relay',
relay.id,
json_object('resource_type', 'relay', 'plan', relay.plan_id, 'status', relay.status)
FROM relay
WHERE relay.synced = 1
AND NOT EXISTS (
SELECT 1 FROM activity
WHERE activity.resource_id = relay.id
AND activity.activity_type = 'complete_relay_sync'
);
+5 -1
View File
@@ -31,9 +31,11 @@ use crate::env;
use crate::models::{Relay, Tenant};
use crate::query;
use crate::robot::Robot;
use crate::routes::domains::check_domain;
use crate::routes::identity::get_identity;
use crate::routes::invoices::{
ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, reconcile_invoice,
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::{
@@ -90,9 +92,11 @@ impl Api {
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay))
.route("/domains/check", get(check_domain))
.route("/invoices", get(list_invoices))
.route("/invoices/:id", get(get_invoice))
.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)
+221 -104
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;
@@ -89,13 +89,15 @@ impl Billing {
) -> 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;
}
@@ -120,10 +122,25 @@ impl Billing {
command::create_invoice(&tenant, &period).await?;
}
// Attempt payment on every open invoice after syncing with stripe.
// 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?;
self.collect_open_invoices(&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(())
@@ -139,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?,
@@ -188,6 +205,7 @@ impl Billing {
amount,
description: description.to_string(),
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -234,6 +252,7 @@ impl Billing {
amount,
description,
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -276,6 +295,7 @@ impl Billing {
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period.start,
voided_at: None,
});
}
@@ -284,88 +304,111 @@ impl Billing {
command::insert_invoice_items_for_renewal(&line_items, period).await
}
// --- Payments ---
// --- Auto-churn ---
/// 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(());
/// 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);
};
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?;
if now - oldest.created_at < GRACE_PERIOD_SECS {
return Ok(false);
}
// Notify the tenant once, on the transition into churn (the guard
// above fires this a single time). Log-and-continue on failure.
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
}
// 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");
}
return Ok(());
}
for invoice in &open {
self.attempt_payment(tenant, invoice, true).await?;
}
Ok(())
Ok(true)
}
/// Collect an invoice via NWC, then a saved card, then (when `notify`) 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. Caller-initiated payments pass
/// `notify = false` to skip the dunning DM, since the failure is already
/// surfaced on screen.
pub async fn attempt_payment(
// --- 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. 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}")),
}
}
// 2. Out-of-band lightning: catches partially failed NWC or manual payment
// 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: 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)
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
if !tenant.nwc_url.is_empty()
&& self
.attempt_payment_using_nwc(tenant, invoice)
.await
{
Ok(()) => return Ok(()),
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
}
.is_ok()
{
return self.cleanup_pending_payments(invoice).await;
}
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
if notify
&& let Err(e) = self
.attempt_payment_using_dm(tenant, invoice, error_message)
// 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
&& self
.attempt_payment_using_stripe(tenant, invoice, payment_method)
.await
.is_ok()
{
return self.cleanup_pending_payments(invoice).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).await {
tracing::error!(
tenant = %tenant.pubkey,
error = %e,
@@ -403,36 +446,39 @@ 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: Option<String>,
) -> Result<()> {
async fn attempt_payment_using_dm(&self, tenant: &Tenant, invoice: &Invoice) -> 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.
@@ -452,26 +498,39 @@ impl Billing {
let invoice_id = &invoice.id;
let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match error {
Some(error) if !error.is_empty() => {
let limit: usize = 240;
let summary = error
.chars()
.take(limit.saturating_sub(3))
.collect::<String>();
format!("{base}\n\nAuto-payment failed: {summary}")
}
_ => base,
};
let message = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
// Send via NIP 17
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
self.robot.send_dm(&tenant.pubkey, &message).await?;
// Record the send to avoid spammy notifications.
command::mark_invoice_notified(invoice_id).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"
);
}
}
Ok(())
}
// --- Bolt11 utils ---
pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Bolt11> {
@@ -486,7 +545,8 @@ impl Billing {
}
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
&& (existing.settled_at.is_none() || now < existing.expires_at)
&& existing.settled_at.is_none()
&& now < existing.expires_at
{
return Ok(existing);
}
@@ -501,6 +561,61 @@ impl Billing {
.ok_or_else(|| anyhow!("failed to insert bolt11"))
}
// --- Checkout utils ---
/// 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();
// 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
@@ -583,15 +698,17 @@ impl BillingPeriod {
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// 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.
/// the nearest day so proration tracks whole days rather than exact seconds.
/// Rounding by the day keeps a relay created within the first half-day of a
/// period billed at the full plan amount instead of a few cents short.
fn fraction_remaining(&self, at: i64) -> f64 {
const HOUR: i64 = 60 * 60;
const DAY: i64 = 24 * 60 * 60;
let len = (self.end - self.start) as f64;
if len <= 0.0 {
return 1.0;
}
let remaining = ((self.end - at) + HOUR / 2) / HOUR * HOUR;
let remaining = ((self.end - at) + DAY / 2) / DAY * DAY;
(remaining as f64 / len).clamp(0.0, 1.0)
}
+328 -157
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 ---
@@ -114,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?;
@@ -135,15 +137,16 @@ 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 ---
pub async fn create_relay(relay: &Relay) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
let activity = with_tx(async |tx| {
sqlx::query(
"INSERT INTO relay (
@@ -151,8 +154,8 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
livekit_enabled, push_enabled, custom_domain, created_at
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant_pubkey)
@@ -169,6 +172,8 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.custom_domain)
.bind(created_at)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
@@ -184,13 +189,20 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
pub async fn update_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
// Reset custom_domain_verified only when the domain actually changes. The
// CASE compares the row's existing custom_domain (SQLite evaluates SET
// expressions against the original row) against the incoming one, so an
// update that round-trips the same domain — e.g. a feature toggle — leaves
// a verification set by the background poller untouched.
sqlx::query(
"UPDATE relay
SET tenant_pubkey = ?, subdomain = ?, plan_id = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
livekit_enabled = ?, push_enabled = ?,
custom_domain = ?,
custom_domain_verified = CASE WHEN custom_domain = ? THEN custom_domain_verified ELSE 0 END
WHERE id = ?",
)
.bind(&relay.tenant_pubkey)
@@ -208,6 +220,8 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.custom_domain)
.bind(&relay.custom_domain)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
@@ -255,6 +269,22 @@ pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
Ok(())
}
pub async fn verify_relay_custom_domain(relay_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET custom_domain_verified = 1, synced = 0 WHERE id = ?")
.bind(relay_id)
.execute(pool())
.await?;
Ok(())
}
pub async fn unverify_relay_custom_domain(relay_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET custom_domain_verified = 0, synced = 0 WHERE id = ?")
.bind(relay_id)
.execute(pool())
.await?;
Ok(())
}
pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
@@ -348,20 +378,22 @@ 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);
}
@@ -369,7 +401,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
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)
@@ -383,6 +415,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(
@@ -399,26 +441,37 @@ pub async fn settle_invoice_via_nwc(
.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<()> {
/// 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| {
mark_bolt11_settled_tx(tx, bolt11_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "oob").await?;
clear_tenant_stripe_error_tx(tx, tenant_pubkey).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 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 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,
intent_id: &str,
checkout_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_checkout_settled_tx(tx, checkout_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
Ok(())
})
@@ -463,8 +516,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,
@@ -511,52 +594,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.
@@ -574,79 +611,7 @@ async fn mark_activity_billed_tx(
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
}
/// 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(())
}
/// 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, 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.
@@ -682,23 +647,229 @@ async fn clear_tenant_stripe_error_tx(
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(())
}
+77
View File
@@ -0,0 +1,77 @@
use std::sync::LazyLock;
use std::time::Duration;
use anyhow::Result;
use regex::Regex;
pub static CUSTOM_DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$").unwrap());
/// Returns true if `domain` either has a CNAME pointing to `expected` or
/// resolves to the same IPv4 addresses. The A-record fallback accepts
/// Cloudflare CNAME flattening and ALIAS/ANAME records, which external
/// resolvers see as A records rather than CNAMEs.
pub async fn domain_points_to(domain: &str, expected: &str) -> Result<bool> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let cnames = doh_query(&client, domain, "CNAME").await?;
let expected_norm = expected.trim_end_matches('.');
for r in &cnames {
if r.record_type == 5
&& r.data
.trim_end_matches('.')
.eq_ignore_ascii_case(expected_norm)
{
return Ok(true);
}
}
let domain_ips = doh_query(&client, domain, "A").await?;
let expected_ips = doh_query(&client, expected, "A").await?;
let domain_set: Vec<&str> = domain_ips
.iter()
.filter(|r| r.record_type == 1)
.map(|r| r.data.as_str())
.collect();
let expected_set: Vec<&str> = expected_ips
.iter()
.filter(|r| r.record_type == 1)
.map(|r| r.data.as_str())
.collect();
if domain_set.is_empty() || expected_set.is_empty() {
return Ok(false);
}
Ok(domain_set.iter().any(|ip| expected_set.contains(ip)))
}
#[derive(serde::Deserialize)]
struct DohResponse {
#[serde(rename = "Answer")]
answer: Option<Vec<DohRecord>>,
}
#[derive(serde::Deserialize)]
struct DohRecord {
#[serde(rename = "type")]
record_type: u16,
data: String,
}
const DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
async fn doh_query(client: &reqwest::Client, name: &str, qtype: &str) -> Result<Vec<DohRecord>> {
let resp: DohResponse = client
.get(DOH_URL)
.query(&[("name", name), ("type", qtype)])
.header("Accept", "application/dns-json")
.send()
.await?
.json()
.await?;
Ok(resp.answer.unwrap_or_default())
}
+109 -1
View File
@@ -8,6 +8,7 @@ use std::time::Duration;
use crate::command;
use crate::db;
use crate::domains;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query;
@@ -16,6 +17,111 @@ const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
const DOMAIN_VERIFY_INTERVAL_SECS: u64 = 30;
const DOMAIN_REVERIFY_INTERVAL_SECS: u64 = 3 * 60 * 60;
/// Poll for relays with an unverified custom domain and attempt DNS verification.
/// Runs for the life of the process; each cycle waits for all checks to finish
/// before sleeping, so cycles never overlap.
pub async fn start_domain_verification() {
loop {
if let Err(e) = verify_pending_custom_domains().await {
tracing::error!(error = %e, "domain verification poll failed");
}
tokio::time::sleep(Duration::from_secs(DOMAIN_VERIFY_INTERVAL_SECS)).await;
}
}
async fn verify_pending_custom_domains() -> Result<()> {
let relays = query::list_relays_with_unverified_custom_domain().await?;
for relay in relays {
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
Ok(true) => {
tracing::info!(
relay = %relay.id,
domain = %relay.custom_domain,
"custom domain verified",
);
if let Err(e) = command::verify_relay_custom_domain(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark domain verified");
continue;
}
// Fetch the updated relay and sync so Zooid learns the new host.
match query::get_relay(&relay.id).await {
Ok(Some(updated)) => sync_relay(&updated).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain verify")
}
}
}
Ok(false) => {
tracing::debug!(
relay = %relay.id,
domain = %relay.custom_domain,
target = %canonical,
"custom domain not yet pointing to relay",
);
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "DNS check failed for custom domain");
}
}
}
Ok(())
}
/// Poll verified custom domains every 3 hours and un-verify any whose DNS no
/// longer points at the relay. Triggers a Zooid sync so the relay reverts to
/// its canonical subdomain as its host.
pub async fn start_domain_reverification() {
loop {
tokio::time::sleep(Duration::from_secs(DOMAIN_REVERIFY_INTERVAL_SECS)).await;
if let Err(e) = check_verified_custom_domains().await {
tracing::error!(error = %e, "verified domain check poll failed");
}
}
}
async fn check_verified_custom_domains() -> Result<()> {
let relays = query::list_relays_with_verified_custom_domain().await?;
for relay in relays {
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
Ok(true) => {
tracing::debug!(
relay = %relay.id,
domain = %relay.custom_domain,
"verified custom domain still points to relay",
);
}
Ok(false) => {
tracing::warn!(
relay = %relay.id,
domain = %relay.custom_domain,
"verified custom domain no longer points to relay; removing verification",
);
if let Err(e) = command::unverify_relay_custom_domain(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to un-verify custom domain");
continue;
}
match query::get_relay(&relay.id).await {
Ok(Some(updated)) => sync_relay(&updated).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain un-verify")
}
}
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "DNS check failed for verified custom domain");
}
}
}
Ok(())
}
/// Run the reactor for the life of the process: reconcile any relays left
/// unsynced from a previous run, then sync each relay as its activity arrives.
pub async fn start() {
@@ -175,8 +281,10 @@ async fn try_sync_relay(relay: &Relay) -> Result<()> {
.await?
.is_none();
let host = relay.host();
let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
"host": host,
"schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
+1
View File
@@ -3,6 +3,7 @@ pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod db;
pub mod domains;
pub mod env;
pub mod infra;
pub mod models;
+8 -1
View File
@@ -3,6 +3,7 @@ mod billing;
mod bitcoin;
mod command;
mod db;
mod domains;
mod env;
mod infra;
mod models;
@@ -56,12 +57,18 @@ async fn main() -> Result<()> {
tokio::spawn(async {
infra::start().await;
});
tokio::spawn(async {
infra::start_domain_verification().await;
});
tokio::spawn(async {
infra::start_domain_reverification().await;
});
tokio::spawn(async move {
billing.start().await;
});
let url = format!("127.0.0.1:{}", env::get().server_port);
let url = format!("0.0.0.0:{}", env::get().server_port);
let listener = tokio::net::TcpListener::bind(url).await?;
axum::serve(listener, app).await?;
+54
View File
@@ -28,6 +28,7 @@ pub struct Plan {
pub id: String,
pub name: String,
pub amount: i64,
pub hidden: bool,
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
@@ -90,7 +91,22 @@ pub struct Relay {
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
pub custom_domain: String,
pub custom_domain_verified: i64,
pub synced: i64,
pub created_at: i64,
}
impl Relay {
/// The relay's effective public host: its verified custom domain when set,
/// otherwise its canonical `<subdomain>.<relay_domain>`.
pub fn host(&self) -> String {
if self.custom_domain_verified == 1 && !self.custom_domain.is_empty() {
self.custom_domain.clone()
} else {
format!("{}.{}", self.subdomain, crate::env::get().relay_domain)
}
}
}
impl Default for Relay {
@@ -112,7 +128,10 @@ impl Default for Relay {
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
custom_domain: String::new(),
custom_domain_verified: 0,
synced: 0,
created_at: 0,
}
}
}
@@ -154,6 +173,10 @@ pub struct InvoiceItem {
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)]
@@ -166,3 +189,34 @@ pub struct Bolt11 {
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>,
}
+73 -15
View File
@@ -1,7 +1,7 @@
use anyhow::{Result, anyhow};
use crate::db::pool;
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
use crate::models::{Activity, Bolt11, Checkout, Invoice, InvoiceItem, Plan, Relay, Tenant};
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
@@ -23,6 +23,7 @@ pub fn list_plans() -> Vec<Plan> {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
hidden: false,
members: Some(10),
blossom: false,
livekit: false,
@@ -31,6 +32,7 @@ pub fn list_plans() -> Vec<Plan> {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
hidden: false,
members: Some(100),
blossom: true,
livekit: true,
@@ -39,6 +41,7 @@ pub fn list_plans() -> Vec<Plan> {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
hidden: false,
members: None,
blossom: true,
livekit: true,
@@ -56,9 +59,11 @@ pub fn get_plan(plan_id: &str) -> Result<Plan> {
// --- Tenants ---
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(pool())
.await?)
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("ORDER BY created_at DESC"))
.fetch_all(pool())
.await?,
)
}
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
@@ -73,9 +78,27 @@ pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
// --- Relays ---
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(pool())
.await?)
Ok(
sqlx::query_as::<_, Relay>(&select_relay("ORDER BY created_at DESC"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_with_unverified_custom_domain() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain != '' AND custom_domain_verified = 0",
))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_with_verified_custom_domain() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain != '' AND custom_domain_verified = 1",
))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
@@ -87,12 +110,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 = ? ORDER BY created_at DESC",
))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
@@ -102,6 +125,15 @@ pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
.await?)
}
pub async fn get_relay_by_verified_custom_domain(domain: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain = ? AND custom_domain_verified = 1",
))
.bind(domain)
.fetch_optional(pool())
.await?)
}
/// The relay's plan immediately before `before`, read from the most recent
/// relay-activity snapshot with `created_at < before`. Billing uses this as
/// the `old` side of a plan-change delta.
@@ -165,7 +197,7 @@ pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<Invo
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
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
@@ -197,6 +229,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
@@ -208,7 +265,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",
))
+28
View File
@@ -0,0 +1,28 @@
use std::collections::HashMap;
use axum::{extract::Query, http::StatusCode, response::IntoResponse};
use crate::env;
use crate::query;
/// Caddy on-demand TLS "ask" endpoint. Returns 200 if Caddy should provision a
/// cert for the domain, 404 if not. No authentication: Caddy calls this
/// internally before issuing a certificate, passing the domain as a `?domain=`
/// query parameter.
pub async fn check_domain(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let Some(domain) = params.get("domain") else {
return StatusCode::BAD_REQUEST;
};
let domain = domain.trim_end_matches('.');
let relay_domain = &env::get().relay_domain;
if domain == relay_domain.as_str() || domain.ends_with(&format!(".{relay_domain}")) {
return StatusCode::OK;
}
match query::get_relay_by_verified_custom_domain(domain).await {
Ok(Some(_)) => StatusCode::OK,
_ => StatusCode::NOT_FOUND,
}
}
+28 -1
View File
@@ -57,7 +57,7 @@ pub async fn reconcile_invoice(
.map_err(internal)?;
api.billing
.attempt_payment(&tenant, &invoice, false)
.reconcile_payments(&tenant, &invoice, true, false)
.await
.map_err(internal)?;
@@ -92,6 +92,33 @@ pub async fn ensure_invoice_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>>,
+1
View File
@@ -1,3 +1,4 @@
pub mod domains;
pub mod identity;
pub mod invoices;
pub mod plans;
+43
View File
@@ -9,6 +9,8 @@ use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::domains;
use crate::env;
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,
@@ -77,6 +79,8 @@ pub struct CreateRelayRequest {
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
#[serde(default)]
pub custom_domain: String,
}
pub async fn create_relay(
@@ -107,6 +111,7 @@ pub async fn create_relay(
blossom_enabled: payload.blossom_enabled,
livekit_enabled: payload.livekit_enabled,
push_enabled: payload.push_enabled,
custom_domain: normalize_custom_domain(&payload.custom_domain)?,
..Default::default()
};
@@ -133,6 +138,7 @@ pub struct UpdateRelayRequest {
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
pub custom_domain: Option<String>,
}
pub async fn update_relay(
@@ -184,6 +190,17 @@ pub async fn update_relay(
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
// Changing the custom domain invalidates any prior verification, so clear the
// flag and let the background poller re-verify the new domain. An update that
// round-trips the existing domain (e.g. a feature toggle) leaves it untouched;
// the matching reset in `command::update_relay` keeps the persisted row right.
if let Some(v) = payload.custom_domain {
let normalized = normalize_custom_domain(&v)?;
if normalized != relay.custom_domain {
relay.custom_domain = normalized;
relay.custom_domain_verified = 0;
}
}
let relay = prepare_relay(relay)?;
@@ -306,6 +323,32 @@ fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
Ok(relay)
}
/// Normalize and validate a tenant custom domain. An empty string is allowed and
/// clears the domain. Rejects malformed hostnames and any domain under the
/// platform's relay domain, which belong in the subdomain field instead.
fn normalize_custom_domain(domain: &str) -> Result<String, ApiError> {
let domain = domain.trim().to_lowercase();
if !domain.is_empty() {
if !domains::CUSTOM_DOMAIN_RE.is_match(&domain) {
return Err(unprocessable(
"invalid-domain",
"domain must be a valid hostname (e.g. relay.example.com)",
));
}
let relay_domain = &env::get().relay_domain;
if domain == *relay_domain || domain.ends_with(&format!(".{relay_domain}")) {
return Err(unprocessable(
"reserved-domain",
"use the subdomain field for domains under the platform's relay domain",
));
}
}
Ok(domain)
}
/// Translate a duplicate-subdomain write into a 422; anything else is a 500.
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
+72
View File
@@ -128,6 +128,7 @@ impl Stripe {
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("metadata[invoice_id]", invoice_id),
("off_session", "true"),
("confirm", "true"),
])
@@ -148,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
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Substitute the real config into the prebuilt frontend bundle, then run the
# backend and static server, exiting (so the orchestrator restarts us) if either
# process dies.
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:-}"
VITE_PLATFORM_LOGO="${PLATFORM_LOGO:-/caravel.png}"
# 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" \
-e "s|__VITE_PLATFORM_LOGO__|$(esc "$VITE_PLATFORM_LOGO")|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 -L -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
+3
View File
@@ -6,3 +6,6 @@ VITE_RELAY_DOMAIN=spaces.coracle.social
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
# Platform logo shown in UI (path under public/, or an absolute URL)
VITE_PLATFORM_LOGO=/caravel.png
@@ -2,9 +2,9 @@ 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 CopyNpub from "@/components/CopyNpub"
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",
@@ -45,7 +45,12 @@ export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {
>
<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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.invoice.tenant_pubkey} class="text-xs text-gray-500" />}
>
<span class="text-xs text-gray-500 truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</div>
</Show>
</div>
+82 -10
View File
@@ -1,17 +1,14 @@
import { A, useLocation } from "@solidjs/router"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, identity } from "@/lib/state"
import { account, identity, PLATFORM_LOGO, PLATFORM_NAME } 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 BillingPrompts from "@/components/BillingPrompts"
function shortenPubkey(pubkey?: string) {
if (!pubkey) return ""
return `${pubkey.slice(0, 8)}${pubkey.slice(-6)}`
}
import CopyNpub from "@/components/CopyNpub"
import { shortenNpub } from "@/lib/pubkey"
function SearchIcon() {
return (
@@ -26,6 +23,14 @@ function RelayIcon() {
return <img src={serverIcon} alt="" aria-hidden="true" class="h-5 w-5" />
}
function AdminIcon() {
return (
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
)
}
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
@@ -33,8 +38,14 @@ export default function AppShell(props: { children?: any }) {
const [tenantRelays] = useTenantRelays()
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [adminOpen, setAdminOpen] = createSignal(false)
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
const displayName = createMemo(() => metadata()?.name || metadata()?.display_name)
const username = createMemo(() => {
const pubkey = account()?.pubkey
return displayName() || (pubkey ? shortenNpub(pubkey) : "")
})
const initial = createMemo(() => (displayName() || account()?.pubkey || "?").slice(0, 1).toUpperCase())
const nip05 = createMemo(() => metadata()?.nip05)
const searchedRelays = createMemo<Relay[]>(() => {
const list = tenantRelays() ?? []
@@ -57,15 +68,28 @@ export default function AppShell(props: { children?: any }) {
: "block rounded-lg px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white"
}
const mobileNavItemClass = (href: string) => {
const active = location.pathname === href || location.pathname.startsWith(`${href}/`)
return active
? "block rounded-lg bg-gray-100 px-3 py-3 text-sm font-medium text-gray-900"
: "block rounded-lg px-3 py-3 text-sm text-gray-700 hover:bg-gray-100"
}
const openSearchModal = () => setSearchOpen(true)
const closeSearchModal = () => {
setSearchOpen(false)
setSearchQuery("")
}
const openAdminModal = () => setAdminOpen(true)
const closeAdminModal = () => setAdminOpen(false)
return (
<div class="min-h-screen bg-gray-50">
<aside class="hidden md:flex fixed inset-y-0 left-0 w-[260px] bg-slate-900 text-white flex-col z-10">
<A href="/" class="flex items-center gap-3 border-b border-white/15 px-7 py-5 text-lg font-bold text-white hover:bg-white/10">
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="h-7 w-7 rounded" />
<span class="truncate">{PLATFORM_NAME}</span>
</A>
<div class="flex-1 px-4 py-6">
<h2 class="px-3 text-xs font-semibold uppercase tracking-wider text-white/60">Resources</h2>
<ul class="mt-2 space-y-1">
@@ -88,14 +112,19 @@ export default function AppShell(props: { children?: any }) {
when={picture()}
fallback={
<div class="h-10 w-10 rounded-full bg-white/15 text-sm font-medium text-white flex items-center justify-center">
{username().slice(0, 1).toUpperCase()}
{initial()}
</div>
}
>
<img src={picture()} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-white">{username()}</p>
<Show
when={displayName()}
fallback={<CopyNpub pubkey={account()?.pubkey ?? ""} class="text-sm font-medium text-white" />}
>
<p class="truncate text-sm font-medium text-white">{username()}</p>
</Show>
<p class="truncate text-xs text-white/60">{nip05()}</p>
</div>
</A>
@@ -111,6 +140,7 @@ export default function AppShell(props: { children?: any }) {
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
if (searchOpen()) closeSearchModal()
if (adminOpen()) closeAdminModal()
}}
>
<div class="flex h-16 items-center justify-between px-6">
@@ -129,11 +159,24 @@ export default function AppShell(props: { children?: any }) {
<A href="/relays" aria-label="Relays" class="rounded-lg p-2 text-gray-700 hover:bg-gray-100">
<RelayIcon />
</A>
<Show when={identity()?.is_admin}>
<button
type="button"
aria-label="Administration"
class="rounded-lg p-2 text-gray-700 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation()
openAdminModal()
}}
>
<AdminIcon />
</button>
</Show>
</div>
<A href="/account" aria-label="Account settings" class="rounded-full">
<Show
when={picture()}
fallback={<div class="h-9 w-9 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">{username().slice(0, 1).toUpperCase()}</div>}
fallback={<div class="h-9 w-9 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">{initial()}</div>}
>
<img src={picture()} alt="Profile" class="h-9 w-9 rounded-full object-cover" />
</Show>
@@ -198,6 +241,35 @@ export default function AppShell(props: { children?: any }) {
</Show>
</div>
</Modal>
<Modal
open={adminOpen()}
onClose={closeAdminModal}
wrapperClass="fixed inset-x-0 top-0 bottom-16 z-30 flex items-end bg-black/40 p-0 md:hidden"
panelClass="w-full overflow-hidden rounded-t-2xl bg-white"
>
<div class="flex items-center justify-between border-b border-gray-200 p-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500">Administration</h2>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={closeAdminModal}
>
Close
</button>
</div>
<ul class="space-y-1 p-4">
<For each={adminResources}>
{(item) => (
<li>
<A href={item.href} class={mobileNavItemClass(item.href)} onClick={closeAdminModal}>
{item.label}
</A>
</li>
)}
</For>
</ul>
</Modal>
</div>
)
}
+8 -1
View File
@@ -29,6 +29,13 @@ export default function BillingPrompts(props: BillingPromptsProps) {
(id) => getInvoice(id),
)
// A resource keeps its last value once its source goes falsy, so deepLinked()
// still returns the fetched invoice after the param is cleared. Gate on the
// param so clearing it actually closes the dialog (otherwise it can't be).
const deepLinkedInvoice = createMemo(() =>
searchParams.invoice ? deepLinked() : undefined,
)
const prompt = createMemo(() =>
activeBillingPrompt(
{
@@ -108,7 +115,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
</Show>
{/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}>
<Show when={payInvoice() ?? deepLinkedInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
+33
View File
@@ -0,0 +1,33 @@
import { copyToClipboard } from "@/lib/clipboard"
import { shortenNpub, toNpub } from "@/lib/pubkey"
// Renders a pubkey as a shortened npub followed by a small copy button. The button
// copies the full npub and toasts on success. The icon inherits the surrounding
// text color (currentColor + opacity) so it reads on both light and dark
// backgrounds, and the click stops propagation so it can sit inside links/cards
// without triggering them. Pass `class` to style the npub text.
export default function CopyNpub(props: { pubkey: string; class?: string }) {
const copy = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
void copyToClipboard(toNpub(props.pubkey), { successMessage: "npub copied to clipboard" })
}
return (
<span class="inline-flex items-center gap-1 min-w-0">
<span class={`truncate ${props.class ?? ""}`}>{shortenNpub(props.pubkey)}</span>
<button
type="button"
onClick={copy}
title="Copy npub"
aria-label="Copy npub"
class="shrink-0 opacity-60 transition-opacity hover:opacity-100"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
</span>
)
}
@@ -3,9 +3,9 @@ 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 CopyNpub from "@/components/CopyNpub"
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",
@@ -59,7 +59,12 @@ export default function InvoiceDetailCard(props: InvoiceDetailCardProps) {
>
<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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.invoice.tenant_pubkey} class="text-blue-600 group-hover:underline" />}
>
<span class="truncate text-blue-600 group-hover:underline">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</A>
</Field>
</dl>
+30 -11
View File
@@ -2,12 +2,11 @@ import { createEffect, createResource, createSignal, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { CardSetupBody } from "@/components/PaymentSetupShell"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import LightningPayBody from "@/components/payment/LightningPayBody"
import { setToastMessage } from "@/lib/state"
import { copyToClipboard } from "@/lib/clipboard"
import { useCardPortal } from "@/lib/usePaymentSetup"
import { useInvoiceCheckout } from "@/lib/usePaymentSetup"
import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { billingTenant } from "@/lib/state"
@@ -40,9 +39,11 @@ export default function PaymentDialog(props: PaymentDialogProps) {
listInvoiceItems,
)
// Card payment is a redirect to the Stripe billing portal; once a card is on
// file we retry collection on this invoice automatically.
const card = useCardPortal()
// 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()
@@ -72,10 +73,10 @@ export default function PaymentDialog(props: PaymentDialogProps) {
void loadBolt11()
})
// The card portal lives in a shared hook, so surface its failures here by
// mirroring its error signal into the toast.
// The checkout redirect lives in a shared hook, so surface its failures here
// by mirroring its error signal into the toast.
createEffect(() => {
const err = card.error()
const err = checkout.error()
if (err) setToastMessage(err)
})
@@ -106,7 +107,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11("")
setQrDataUrl("")
setPayMethod("lightning")
card.reset()
checkout.reset()
props.onClose()
}
@@ -186,9 +187,27 @@ export default function PaymentDialog(props: PaymentDialogProps) {
/>
</Show>
{/* Card: redirect to the Stripe billing portal */}
{/* Card: redirect to a Stripe Checkout session for this invoice */}
<Show when={payMethod() === "card"}>
<CardSetupBody card={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={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"
>
{checkout.redirecting() ? "Redirecting..." : `Pay ${amountLabel()} by card`}
</button>
</div>
</Show>
</div>
}
+38 -2
View File
@@ -38,9 +38,24 @@ type PricingTableProps = {
}
export default function PricingTable(props: PricingTableProps) {
// Hidden plans (e.g. the legacy "basic" tier) stay resolvable for billing via
// the backend but never show up in the public pricing table or plan selector.
// This is the only place the hidden flag is honored.
const visiblePlans = () => plans().filter((plan) => !plan.hidden)
// The full pricing page also shows the synthetic "Custom" card, so the column
// count tracks the real number of cards (literal classes so Tailwind keeps them).
const cardCount = () => visiblePlans().length + (props.selectable ? 0 : 1)
const gridCols: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
}
return (
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<For each={plans()}>
<div class={`grid grid-cols-1 gap-6 items-start ${gridCols[cardCount()] ?? "lg:grid-cols-4"}`}>
<For each={visiblePlans()}>
{(plan) => {
const isPopular = plan.id === "basic"
const isSelected = () => props.selectable && props.selectedPlanId === plan.id
@@ -99,6 +114,27 @@ export default function PricingTable(props: PricingTableProps) {
)
}}
</For>
{!props.selectable && (
<div class="relative bg-white rounded-2xl p-8 border border-gray-200">
<h3 class="text-lg font-bold text-gray-900 mb-1">Custom</h3>
<div class="mb-8">
<span class="text-4xl font-extrabold text-gray-900">Let's talk</span>
</div>
<ul class="mb-8 text-sm text-gray-600 space-y-3">
<li class="flex items-start gap-2"><CheckIcon />White-labeled app</li>
<li class="flex items-start gap-2"><CheckIcon />Dedicated support</li>
<li class="flex items-start gap-2"><CheckIcon />Custom feature development</li>
</ul>
<a
href="https://cal.com/coracle.social/30min"
target="_blank"
rel="noopener noreferrer"
class="block w-full text-center py-2.5 px-4 text-sm rounded-xl font-semibold transition-colors border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Contact us
</a>
</div>
)}
</div>
)
}
+74 -1
View File
@@ -1,16 +1,20 @@
import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import ConfirmDialog from "@/components/ConfirmDialog"
import CustomDomainModal from "@/components/relay/CustomDomainModal"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import RelayCardHeader, { StatusBadge } from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import useMinLoading from "@/lib/useMinLoading"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
import useCustomDomain from "@/lib/useCustomDomain"
function DetailSection(props: { title: string; children: any }) {
return (
@@ -53,12 +57,17 @@ type RelayDetailCardProps = {
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
mutateRelay?: (relay: Relay) => void
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
const [customDomainModalOpen, setCustomDomainModalOpen] = createSignal(false)
const { saving: cdSaving, verifying: cdVerifying, error: cdError, saveDomain, verifyDomain } =
useCustomDomain(() => props.relay.id, props.mutateRelay ?? (() => {}))
const cdVerifyingVisible = useMinLoading(cdVerifying)
// 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.
@@ -134,6 +143,70 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
onRequestManageCustomDomain={() => setCustomDomainModalOpen(true)}
/>
<hr class="border-gray-200" />
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">Custom Domain</h3>
<div class="space-y-3">
<div class="flex items-center justify-between gap-2">
<Show
when={r().custom_domain}
fallback={<span class="text-gray-400 text-sm">Not configured</span>}
>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{r().custom_domain}</span>
<Show when={r().custom_domain_verified === 1}>
<StatusBadge status="verified" />
</Show>
</div>
</Show>
<button
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => setCustomDomainModalOpen(true)}
>
Update
</button>
</div>
<Show when={r().custom_domain && r().custom_domain_verified !== 1}>
<div class="rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 space-y-3">
<div class="flex items-start gap-2">
<span class="mt-0.5 text-yellow-600 text-sm shrink-0"></span>
<p class="text-sm font-medium text-yellow-800">
Not yet verified add this DNS record, then verify:
</p>
</div>
<div class="rounded border border-yellow-200 bg-white px-3 py-2 font-mono text-xs text-gray-700 break-all">
{r().custom_domain} CNAME {r().subdomain}.{RELAY_DOMAIN}
</div>
<p class="text-xs text-yellow-700">
For apex domains (e.g. example.com), use an ALIAS or ANAME record instead.
</p>
<Show when={cdError()}>
<p class="text-sm text-red-600">{cdError()}</p>
</Show>
<button
type="button"
onClick={verifyDomain}
disabled={cdVerifyingVisible()}
class="inline-flex items-center rounded-lg bg-yellow-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-yellow-700 disabled:opacity-50"
>
{cdVerifyingVisible() ? "Verifying…" : "Verify DNS record"}
</button>
</div>
</Show>
</div>
</div>
<CustomDomainModal
open={customDomainModalOpen()}
onClose={() => setCustomDomainModalOpen(false)}
relay={r}
saving={cdSaving}
error={cdError}
onSave={saveDomain}
/>
<hr class="border-gray-200" />
+1 -1
View File
@@ -113,7 +113,7 @@ export default function RelayForm(props: RelayFormProps) {
<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()}>
<For each={plans().filter((p) => !p.hidden)}>
{(p) => (
<button
type="button"
+21 -12
View File
@@ -2,8 +2,9 @@ import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { PlanBadge, StatusBadge } from "@/components/relay/RelayCardHeader"
import CopyNpub from "@/components/CopyNpub"
import { useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
type RelayListItemProps = {
@@ -37,21 +38,29 @@ export default function RelayListItem(props: RelayListItemProps) {
>
<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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.relay.tenant_pubkey} class="text-xs text-gray-500" />}
>
<span class="text-xs text-gray-500 truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</div>
</Show>
</div>
<Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
<div class="flex items-center gap-2 shrink-0">
<Show
when={props.relay.sync_error}
fallback={<StatusBadge status={props.relay.status} />}
>
Failed to sync
</span>
</Show>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
Failed to sync
</span>
</Show>
<PlanBadge planId={props.relay.plan_id} />
</div>
</div>
</A>
</li>
@@ -0,0 +1,93 @@
import { Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
import type { Relay } from "@/lib/api"
type Props = {
open: boolean
onClose: () => void
relay: () => Relay | undefined
saving: () => boolean
error: () => string | undefined
onSave: (domain: string) => Promise<void>
}
export default function CustomDomainModal(props: Props) {
const [input, setInput] = createSignal("")
createEffect(() => {
if (props.open) {
setInput(props.relay()?.custom_domain ?? "")
}
})
const current = () => props.relay()?.custom_domain ?? ""
const inputTrimmed = () => input().trim()
async function handleSubmit(e: Event) {
e.preventDefault()
await props.onSave(inputTrimmed())
if (!props.error()) props.onClose()
}
return (
<Modal
open={props.open}
onClose={props.onClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
panelClass="w-full max-w-lg bg-white rounded-2xl shadow-xl p-6 space-y-5"
>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Custom domain</h2>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<form onSubmit={handleSubmit} class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Custom domain</label>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
placeholder="relay.example.com"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
autocomplete="off"
autocapitalize="none"
spellcheck={false}
/>
<p class="mt-1 text-xs text-gray-500">
Must be a domain you control, e.g. <span class="font-mono">relay.example.com</span>
</p>
</div>
<Show when={props.error()}>
<p class="text-sm text-red-600">{props.error()}</p>
</Show>
<div class="flex items-center gap-3 flex-wrap">
<button
type="submit"
disabled={props.saving() || !inputTrimmed() || inputTrimmed() === current()}
class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{props.saving() ? "Saving…" : "Save domain"}
</button>
<Show when={current()}>
<button
type="button"
onClick={() => props.onSave("")}
disabled={props.saving()}
class="inline-flex items-center rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{props.saving() ? "Removing…" : "Remove custom domain"}
</button>
</Show>
</div>
</form>
</Modal>
)
}
@@ -3,12 +3,13 @@ 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 CopyNpub from "@/components/CopyNpub"
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",
verified: "bg-green-50 text-green-700 border-green-200",
}
export function StatusBadge(props: { status: string }) {
@@ -21,6 +22,17 @@ export function StatusBadge(props: { status: string }) {
)
}
export function PlanBadge(props: { planId: string }) {
const styles = () => props.planId === "free"
? "bg-gray-100 text-gray-500 border-gray-200"
: "bg-blue-50 text-blue-700 border-blue-200"
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{props.planId}
</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
@@ -36,6 +48,7 @@ type RelayCardHeaderProps = {
reactivating?: boolean
onRequestDeactivate?: () => void
onRequestReactivate?: () => void
onRequestManageCustomDomain?: () => void
}
export default function RelayCardHeader(props: RelayCardHeaderProps) {
@@ -82,9 +95,10 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
<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} />
<PlanBadge planId={r().plan_id} />
</div>
<a
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
href={`https://${r().subdomain}.${RELAY_DOMAIN}`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.{RELAY_DOMAIN}
@@ -101,7 +115,12 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
>
<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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={r().tenant_pubkey} class="text-sm text-blue-600 group-hover:underline" />}
>
<span class="text-sm text-blue-600 group-hover:underline truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</A>
</Show>
<Show when={r().info_description.trim()}>
@@ -110,7 +129,7 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
</div>
</div>
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
<Show when={props.editHref || props.onRequestDeactivate || props.onRequestReactivate || props.onRequestManageCustomDomain}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
@@ -128,13 +147,27 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
"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={props.editHref}>
<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>
<Show when={props.onRequestManageCustomDomain}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => {
setMenuOpen(false)
props.onRequestManageCustomDomain?.()
}}
>
Manage custom domain
</button>
</Show>
<Show when={r().status === "active" && props.onRequestDeactivate}>
<button
type="button"
+1
View File
@@ -24,6 +24,7 @@ interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_RELAY_DOMAIN: string
readonly VITE_PLATFORM_NAME?: string
readonly VITE_PLATFORM_LOGO?: string
}
interface ImportMeta {
+15
View File
@@ -35,6 +35,7 @@ export type Plan = {
id: string
name: string
amount: number
hidden: boolean
members: number | null
blossom: boolean
livekit: boolean
@@ -50,6 +51,8 @@ export type Relay = {
status: string
sync_error: string
synced: number
custom_domain: string
custom_domain_verified: number
info_name: string
info_icon: string
info_description: string
@@ -76,6 +79,7 @@ export type CreateRelayInput = {
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type UpdateRelayInput = {
@@ -91,6 +95,7 @@ export type UpdateRelayInput = {
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type Tenant = {
@@ -133,6 +138,7 @@ export type InvoiceItem = {
amount: number
description: string
created_at: number
voided_at: number | null
}
export type Bolt11 = {
@@ -335,6 +341,15 @@ 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
+4 -1
View File
@@ -18,7 +18,10 @@ export function nwcState(t: Pick<Tenant, "nwc_is_set" | "nwc_error">): PaymentMe
export function cardState(t: Pick<Tenant, "stripe_payment_method_id" | "stripe_error">): PaymentMethodState {
if (!t.stripe_payment_method_id) return { kind: "not_set_up" }
if (t.stripe_error) return { kind: "error", message: t.stripe_error }
// 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" }
}
+16
View File
@@ -1,3 +1,19 @@
import { npubEncode } from "applesauce-core/helpers/pointers"
export function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
// Encode a hex pubkey as an npub for display. Falls back to the raw input when it
// isn't a valid pubkey, since npubEncode throws on malformed input.
export function toNpub(pubkey: string) {
try {
return npubEncode(pubkey)
} catch {
return pubkey
}
}
export function shortenNpub(pubkey: string) {
return shortenPubkey(toNpub(pubkey))
}
+1
View File
@@ -27,6 +27,7 @@ export type EventSigner = {
}
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_LOGO = import.meta.env.VITE_PLATFORM_LOGO
export const eventStore = new EventStore()
export const pool = new RelayPool()
+39
View File
@@ -0,0 +1,39 @@
import { createSignal } from "solid-js"
import type { Relay } from "@/lib/api"
import { getRelay, updateRelay } from "@/lib/api"
export default function useCustomDomain(relayId: () => string, mutate: (relay: Relay) => void) {
const [saving, setSaving] = createSignal(false)
const [verifying, setVerifying] = createSignal(false)
const [error, setError] = createSignal<string>()
async function saveDomain(domain: string) {
setSaving(true)
setError(undefined)
try {
const updated = await updateRelay(relayId(), { custom_domain: domain })
mutate(updated)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to save domain")
} finally {
setSaving(false)
}
}
// Verification runs in a background poller on the backend; reload the relay so
// the UI picks up the latest custom_domain_verified state.
async function verifyDomain() {
setVerifying(true)
setError(undefined)
try {
const updated = await getRelay(relayId())
mutate(updated)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Verification failed")
} finally {
setVerifying(false)
}
}
return { saving, verifying, error, saveDomain, verifyDomain }
}
+4 -14
View File
@@ -1,5 +1,4 @@
import { createSignal } from "solid-js"
import QRCode from "qrcode"
import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
import { methodLabel } from "@/lib/paymentMethod"
import { formatUsd } from "@/lib/format"
@@ -33,20 +32,16 @@ export function useInvoicePdf() {
})
let sats: number | undefined
let qrDataUrl: string | undefined
if (invoice.method !== "stripe" && invoice.voided_at == null) {
try {
const bolt11 = await ensureInvoiceBolt11(invoice.id)
sats = Math.round(bolt11.msats / 1000)
if (invoice.paid_at == null) {
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
}
} catch {
// no bolt11 available — omit the bitcoin line
}
}
printHtml(buildHtml({ invoice, items, sats, qrDataUrl }))
printHtml(buildHtml({ invoice, items, sats }))
} finally {
setPrinting(false)
}
@@ -55,8 +50,8 @@ export function useInvoicePdf() {
return { printInvoice, printing }
}
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
const { invoice, items, sats, qrDataUrl } = opts
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)
@@ -69,9 +64,6 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
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>` : ""
const qr = qrDataUrl
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
: ""
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
<style>
@@ -86,7 +78,6 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
.amt { text-align: right; white-space: nowrap; }
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
.qr { margin-top: 28px; text-align: center; }
</style></head>
<body>
<div class="head">
@@ -105,7 +96,6 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
<tbody>${rows}${satsRow}</tbody>
<tfoot><tr><td>Total</td><td class="amt">${formatUsd(invoice.amount)}</td></tr></tfoot>
</table>
${qr}
</body></html>`
}
@@ -133,7 +123,7 @@ function printHtml(html: string) {
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
win.onafterprint = cleanup
// Let the iframe lay out (and decode the QR image) before printing.
// Let the iframe lay out before printing.
window.setTimeout(() => {
win.focus()
win.print()
+31 -1
View File
@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import { updateActiveTenant } from "@/lib/hooks"
import { createPortalSession } from "@/lib/api"
import { createInvoiceCheckout, createPortalSession } from "@/lib/api"
import { account } from "@/lib/state"
// Lightning/NWC save state machine, shared by the combined and focused setup
@@ -65,3 +65,33 @@ export function useCardPortal() {
}
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>
+4
View File
@@ -46,6 +46,10 @@ export default function Account() {
// 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)
+48 -9
View File
@@ -10,9 +10,10 @@ import PaymentSetup from "@/components/PaymentSetup"
import Login from "@/views/Login"
import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { account, refetchBilling, setToastMessage } from "@/lib/state"
import { account, PLATFORM_LOGO, PLATFORM_NAME, refetchBilling, setToastMessage } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
export default function Home() {
const navigate = useNavigate()
@@ -100,8 +101,8 @@ export default function Home() {
<nav class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-100">
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-2 font-bold text-gray-900">
<img src="/caravel.png" alt="Caravel" class="w-7 h-7 rounded" />
Caravel
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-7 h-7 rounded" />
{PLATFORM_NAME}
</div>
<Show
when={account()}
@@ -177,7 +178,7 @@ export default function Home() {
<div class="max-w-5xl mx-auto px-6 py-20">
<h2 class="text-3xl font-bold text-center text-gray-900 mb-4">Everything you need</h2>
<p class="text-center text-gray-500 mb-14 max-w-xl mx-auto">
Caravel takes care of the infrastructure so you can focus on building your community.
{PLATFORM_NAME} takes care of the infrastructure so you can focus on building your community.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[
@@ -340,6 +341,44 @@ export default function Home() {
</span>
</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>
</div>
</section>
@@ -361,7 +400,7 @@ export default function Home() {
<div class="relative max-w-5xl mx-auto px-6 py-28 text-center">
<h2 class="text-4xl font-extrabold text-gray-900 mb-4">Ready to launch your relay?</h2>
<p class="text-gray-500 mb-10 max-w-lg mx-auto text-lg">
Join communities already running on Caravel. Set up in minutes.
Join communities already running on {PLATFORM_NAME}. Set up in minutes.
</p>
<button
type="button"
@@ -378,14 +417,14 @@ export default function Home() {
<footer class="border-t border-gray-100 bg-gray-50">
<div class="max-w-5xl mx-auto px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<div class="flex items-center gap-2 font-semibold text-gray-500">
<img src="/caravel.png" alt="Caravel" class="w-5 h-5 rounded" />
Caravel
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-5 h-5 rounded" />
{PLATFORM_NAME}
</div>
<p>© {new Date().getFullYear()} Caravel. Built on Nostr.</p>
<p>© {new Date().getFullYear()} {PLATFORM_NAME}. Built on Nostr.</p>
<div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
</div>
</div>
</footer>
@@ -45,6 +45,7 @@ export default function AdminRelayDetail() {
reactivating={busy()}
enforcePlanLimits={false}
showPlanActions={false}
mutateRelay={mutate}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
@@ -8,7 +8,8 @@ import AdminInvoiceListItem from "@/components/AdminInvoiceListItem"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenant, useAdminTenantInvoices, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import CopyNpub from "@/components/CopyNpub"
import { shortenNpub } from "@/lib/pubkey"
export default function AdminTenantDetail() {
const params = useParams()
@@ -40,8 +41,10 @@ export default function AdminTenantDetail() {
<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>
<h1 class="text-2xl font-bold text-gray-900 truncate">{(metadata()?.name || metadata()?.display_name) || shortenNpub(tenantId())}</h1>
<p class="mt-0.5">
<CopyNpub pubkey={tenantId()} class="text-xs text-gray-500" />
</p>
</div>
</div>
<ResourceState loading={loading()} error={tenant.error || relays.error || invoices.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
+6 -3
View File
@@ -6,7 +6,8 @@ import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenants, useProfileMetadataMap } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import CopyNpub from "@/components/CopyNpub"
import { shortenNpub } from "@/lib/pubkey"
import { fuzzySearch } from "@/lib/search"
export default function AdminTenantList() {
@@ -56,9 +57,11 @@ export default function AdminTenantList() {
<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">{profileName() || shortenPubkey(tenant.pubkey)}</p>
<p class="font-medium text-gray-900 truncate">{profileName() || shortenNpub(tenant.pubkey)}</p>
<p class="-mt-1">
<CopyNpub pubkey={tenant.pubkey} class="text-xs text-gray-500" />
</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>
<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"}`}>
@@ -60,6 +60,7 @@ export default function RelayDetail() {
deactivating={busy()}
reactivating={busy()}
onUpdatePlan={handleUpdatePlan}
mutateRelay={mutate}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />