Compare commits

...

2 Commits

Author SHA1 Message Date
userAdityaa e82c45af35 fix: stripe portal dead-end with callback return flow 2026-05-08 13:39:56 +05:45
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
8 changed files with 69 additions and 24 deletions
+8 -2
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, Query as QueryParams, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
@@ -1101,10 +1101,16 @@ async fn get_invoice_bolt11(
} }
} }
#[derive(serde::Deserialize)]
struct StripeSessionParams {
return_url: Option<String>,
}
async fn create_stripe_session( async fn create_stripe_session(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey): Path<String>, Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> std::result::Result<Response, ApiError> { ) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
@@ -1113,7 +1119,7 @@ async fn create_stripe_session(
match state match state
.api .api
.billing .billing
.stripe_create_portal_session(&tenant.stripe_customer_id) .stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await .await
{ {
Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))), Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))),
+16 -6
View File
@@ -151,7 +151,11 @@ impl Billing {
return Ok(()); 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 { for relay in relays {
if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await { if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await {
@@ -961,12 +965,20 @@ impl Billing {
Ok(()) 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 let resp = self
.http .http
.post(format!("{STRIPE_API}/billing_portal/sessions")) .post(format!("{STRIPE_API}/billing_portal/sessions"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.form(&[("customer", customer_id)]) .form(&params)
.send() .send()
.await?; .await?;
@@ -998,8 +1010,7 @@ impl Billing {
customer_id: &str, customer_id: &str,
price_id: &str, price_id: &str,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let idempotency_key = let idempotency_key = self.idempotency_key(&["create_subscription", customer_id, price_id]);
self.idempotency_key(&["create_subscription", customer_id, price_id]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/subscriptions")) .post(format!("{STRIPE_API}/subscriptions"))
@@ -1726,5 +1737,4 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
setRedirecting(true) setRedirecting(true)
setError("") setError("")
try { try {
const { url } = await createPortalSession(account()!.pubkey) const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") 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`) return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
} }
export function createPortalSession(pubkey: string) { export function createPortalSession(pubkey: string, returnUrl?: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`) const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
} }
export function getInvoiceBolt11(invoiceId: string) { export function getInvoiceBolt11(invoiceId: string) {
+9 -4
View File
@@ -1,5 +1,5 @@
import { createSignal } from "solid-js" 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 { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api" import type { Invoice, PlanId } from "@/lib/api"
@@ -31,6 +31,7 @@ export default function useRelayToggles(
) { ) {
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) { async function updateRelay(next: Relay, previous: Relay) {
mutate(next) mutate(next)
@@ -101,8 +102,12 @@ export default function useRelayToggles(
} }
if (plan !== "free") { if (plan !== "free") {
const invoice = await getLatestOpenInvoice() const needsSetup = await tenantNeedsPaymentSetup()
if (invoice) setPendingInvoice(invoice) 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"), 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() { async function openPortal() {
setPortalLoading(true) setPortalLoading(true)
try { try {
const { url } = await createPortalSession(account()!.pubkey) const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") 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 { 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 BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog" import PaymentDialog from "@/components/PaymentDialog"
@@ -28,13 +28,20 @@ export default function RelayDetail() {
}) })
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) 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 [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false) const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false) const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false) const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
setPaymentSetupOpen(true)
clearPendingPaymentSetup()
}
})
const isPaidRelay = createMemo(() => { const isPaidRelay = createMemo(() => {
const r = relay() const r = relay()
if (!r) return false if (!r) return false
+22 -6
View File
@@ -3,13 +3,15 @@ import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog" import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" 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" import type { Invoice } from "@/lib/api"
export default function RelayNew() { export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
@@ -17,9 +19,14 @@ export default function RelayNew() {
createdRelayId = relay.id createdRelayId = relay.id
if (values.plan !== "free") { if (values.plan !== "free") {
const invoice = await getLatestOpenInvoice() const needsSetup = await tenantNeedsPaymentSetup()
if (invoice) { if (needsSetup) {
setPendingInvoice(invoice) const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return return
} }
} }
@@ -27,8 +34,13 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`) navigate(`/relays/${relay.id}`)
} }
function handleDialogClose() { function handleInvoiceClose() {
setPendingInvoice(undefined) setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`) navigate(`/relays/${createdRelayId}`)
} }
@@ -47,10 +59,14 @@ export default function RelayNew() {
<PaymentDialog <PaymentDialog
invoice={inv()} invoice={inv()}
open={true} open={true}
onClose={handleDialogClose} onClose={handleInvoiceClose}
/> />
)} )}
</Show> </Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</PageContainer> </PageContainer>
) )
} }