Compare commits

..

5 Commits

Author SHA1 Message Date
userAdityaa 164fff8be2 fix: stripe portal dead-end with callback return flow 2026-05-11 20:48:48 +00:00
userAdityaa 0151762362 chore: improve billing customer name using Nostr kind 0 with pubkey fallback (#66)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-08 22:52:13 +00:00
userAdityaa a79c43e17e feat: open payment modal immediately on relay plan upgrade (#64)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-07 18:35:24 +00:00
Jon Staab dbe25c372f Conflate id and schema 2026-05-05 17:47:13 -07:00
userAdityaa 80a86452d0 chore: encrypt tenant NWC URL at rest and stop secret exposure in tenant APIs (#58)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-05 20:42:12 +00:00
11 changed files with 105 additions and 34 deletions
+2 -2
View File
@@ -63,9 +63,9 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - a random ID identifying the relay
- `id` - calculated based on `subdomain` + 8 random hex chars
- `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
- `schema` - the relay's db schema (read only, same as `id`)
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
+16 -7
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow};
use axum::{
Json, Router,
extract::{Path, State},
extract::{Path, Query as QueryParams, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
@@ -275,9 +275,6 @@ impl Api {
return Err(RelayValidationError::PremiumFeature);
}
if relay.schema.is_empty() {
relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id);
}
if relay.status.is_empty() {
relay.status = RELAY_STATUS_ACTIVE.to_string();
}
@@ -754,10 +751,16 @@ async fn create_relay(
let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let mut relay = Relay {
id: uuid::Uuid::new_v4().to_string(),
id: relay_id.clone(),
tenant: payload.tenant,
schema: String::new(),
schema: relay_id.clone(),
subdomain: payload.subdomain,
plan: payload.plan,
stripe_subscription_item_id: None,
@@ -1098,10 +1101,16 @@ async fn get_invoice_bolt11(
}
}
#[derive(serde::Deserialize)]
struct StripeSessionParams {
return_url: Option<String>,
}
async fn create_stripe_session(
State(state): State<AppState>,
headers: HeaderMap,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?;
@@ -1110,7 +1119,7 @@ async fn create_stripe_session(
match state
.api
.billing
.stripe_create_portal_session(&tenant.stripe_customer_id)
.stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
{
Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))),
+19 -8
View File
@@ -151,7 +151,11 @@ impl Billing {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling relay billing state");
tracing::info!(
source,
relay_count = relays.len(),
"reconciling relay billing state"
);
for relay in relays {
if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await {
@@ -765,8 +769,9 @@ impl Billing {
}
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}");
let short_pubkey: String = tenant_pubkey.chars().take(8).collect();
let nostr_name = self.robot.fetch_nostr_name(tenant_pubkey).await;
let display_name = nostr_name.unwrap_or_else(|| short_pubkey.clone());
let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]);
let resp = self
@@ -961,12 +966,20 @@ impl Billing {
Ok(())
}
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
pub async fn stripe_create_portal_session(
&self,
customer_id: &str,
return_url: Option<&str>,
) -> Result<String> {
let mut params = vec![("customer", customer_id.to_string())];
if let Some(url) = return_url {
params.push(("return_url", url.to_string()));
}
let resp = self
.http
.post(format!("{STRIPE_API}/billing_portal/sessions"))
.bearer_auth(&self.stripe_secret_key)
.form(&[("customer", customer_id)])
.form(&params)
.send()
.await?;
@@ -998,8 +1011,7 @@ impl Billing {
customer_id: &str,
price_id: &str,
) -> Result<(String, String)> {
let idempotency_key =
self.idempotency_key(&["create_subscription", customer_id, price_id]);
let idempotency_key = self.idempotency_key(&["create_subscription", customer_id, price_id]);
let resp = self
.http
.post(format!("{STRIPE_API}/subscriptions"))
@@ -1726,5 +1738,4 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
}
}
+19
View File
@@ -160,6 +160,25 @@ impl Robot {
Ok(relays)
}
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let events = self
.indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await
.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
Some(name)
}
async fn fetch_messaging_relays_from_outbox(
&self,
recipient: &str,
+1 -1
View File
@@ -40,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey)
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
+3 -2
View File
@@ -253,8 +253,9 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
}
export function createPortalSession(pubkey: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
export function createPortalSession(pubkey: string, returnUrl?: string) {
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
}
export function getInvoiceBolt11(invoiceId: string) {
+9 -4
View File
@@ -1,5 +1,5 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api"
@@ -31,6 +31,7 @@ export default function useRelayToggles(
) {
const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
@@ -101,8 +102,12 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
setPendingPaymentSetup(true)
}
}
}
@@ -116,5 +121,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Account() {
async function openPortal() {
setPortalLoading(true)
try {
const { url } = await createPortalSession(account()!.pubkey)
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
+9 -2
View File
@@ -1,5 +1,5 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
@@ -28,13 +28,20 @@ export default function RelayDetail() {
})
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
setPaymentSetupOpen(true)
clearPendingPaymentSetup()
}
})
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
+22 -6
View File
@@ -3,13 +3,15 @@ import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() {
const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) {
@@ -17,9 +19,14 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return
}
}
@@ -27,8 +34,13 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`)
}
function handleDialogClose() {
function handleInvoiceClose() {
setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`)
}
@@ -47,10 +59,14 @@ export default function RelayNew() {
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleDialogClose}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</PageContainer>
)
}
+4 -1
View File
@@ -5,6 +5,9 @@ dev:
cd frontend && bun dev &
wait
dev-backend:
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
dev-frontend:
cd frontend && bun run dev
@@ -27,7 +30,7 @@ build-backend:
cd backend && cargo build
build-frontend:
cd frontend && bun run build
cd frontend && bun i && bun run build
fmt: fmt-backend