Compare commits
26 Commits
8c44d8cc0f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a5bd4d563 | |||
| 7750a5e552 | |||
| 326fe9d2e1 | |||
| 90f5a55269 | |||
| bd3217f43d | |||
| 5587d40688 | |||
| ada9e10570 | |||
| 0fc7c706f0 | |||
| a34f5ec41d | |||
| 4ebc9fe61b | |||
| b5f3efc775 | |||
| 791a4fcb70 | |||
| 28d1d164f1 | |||
| 1097a5eba3 | |||
| e3083304d0 | |||
| 451264106a | |||
| 96e2fcda49 | |||
| 73cad3a153 | |||
| 5f8b08e02c | |||
| 43eaad1621 | |||
| 5e6d5ab7c4 | |||
| a9f66dc3e5 | |||
| ffb1491f00 | |||
| 4dc8ea942d | |||
| 0e18d4020a | |||
| b702733559 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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**
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod domains;
|
||||
pub mod identity;
|
||||
pub mod invoices;
|
||||
pub mod plans;
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
Reference in New Issue
Block a user