Compare commits

..

1 Commits

11 changed files with 34 additions and 105 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` - calculated based on `subdomain` + 8 random hex chars
- `id` - a random ID identifying the relay
- `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read only, same as `id`)
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `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.
+7 -16
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow};
use axum::{
Json, Router,
extract::{Path, Query as QueryParams, State},
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
@@ -275,6 +275,9 @@ 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();
}
@@ -751,16 +754,10 @@ 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: relay_id.clone(),
id: uuid::Uuid::new_v4().to_string(),
tenant: payload.tenant,
schema: relay_id.clone(),
schema: String::new(),
subdomain: payload.subdomain,
plan: payload.plan,
stripe_subscription_item_id: None,
@@ -1101,16 +1098,10 @@ 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)?;
@@ -1119,7 +1110,7 @@ async fn create_stripe_session(
match state
.api
.billing
.stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.stripe_create_portal_session(&tenant.stripe_customer_id)
.await
{
Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))),
+8 -19
View File
@@ -151,11 +151,7 @@ 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 {
@@ -769,9 +765,8 @@ impl Billing {
}
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
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 short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}");
let idempotency_key = self.idempotency_key(&["create_customer", tenant_pubkey]);
let resp = self
@@ -966,20 +961,12 @@ impl Billing {
Ok(())
}
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()));
}
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
let resp = self
.http
.post(format!("{STRIPE_API}/billing_portal/sessions"))
.bearer_auth(&self.stripe_secret_key)
.form(&params)
.form(&[("customer", customer_id)])
.send()
.await?;
@@ -1011,7 +998,8 @@ 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"))
@@ -1738,4 +1726,5 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
}
}
-19
View File
@@ -160,25 +160,6 @@ 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, window.location.href)
const { url } = await createPortalSession(account()!.pubkey)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
+2 -3
View File
@@ -253,9 +253,8 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
}
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 createPortalSession(pubkey: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
}
export function getInvoiceBolt11(invoiceId: string) {
+4 -9
View File
@@ -1,5 +1,5 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api"
@@ -31,7 +31,6 @@ 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)
@@ -102,12 +101,8 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
setPendingPaymentSetup(true)
}
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
}
}
@@ -121,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), 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, window.location.href)
const { url } = await createPortalSession(account()!.pubkey)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
+2 -9
View File
@@ -1,5 +1,5 @@
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import { createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
@@ -28,20 +28,13 @@ export default function RelayDetail() {
})
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, 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
+6 -22
View File
@@ -3,15 +3,13 @@ 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, tenantNeedsPaymentSetup } from "@/lib/hooks"
import { createRelayForActiveTenant, getLatestOpenInvoice } 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) {
@@ -19,14 +17,9 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
}
@@ -34,13 +27,8 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`)
}
function handleInvoiceClose() {
function handleDialogClose() {
setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`)
}
@@ -59,14 +47,10 @@ export default function RelayNew() {
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
onClose={handleDialogClose}
/>
)}
</Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</PageContainer>
)
}
+1 -4
View File
@@ -5,9 +5,6 @@ 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
@@ -30,7 +27,7 @@ build-backend:
cd backend && cargo build
build-frontend:
cd frontend && bun i && bun run build
cd frontend && bun run build
fmt: fmt-backend