Compare commits

...

1 Commits

Author SHA1 Message Date
userAdityaa 7696b9e1d4 feat: invoice payment flow for paid relays 2026-04-21 15:52:11 +05:45
9 changed files with 467 additions and 46 deletions
+33 -3
View File
@@ -26,12 +26,15 @@ Members:
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment.
- Fetch the relay and tenant associated with the `activity`
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then run downgrade proration validation by previewing the upcoming invoice and logging proration lines/amounts. Then check cleanup (below). Return early.
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `active` and on a paid plan**:
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
- **Downgrade validation**: when changing an existing subscription item, detect if the new plan amount is lower than the current one. If yes, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
@@ -44,6 +47,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
- `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- `payment_method.attached` -> `self.handle_payment_method_attached`
- Unknown event types are ignored (return Ok)
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
@@ -66,16 +70,37 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
- Creates a Stripe Customer Portal session for the given customer
- Returns the portal session URL
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
- If `tenant.nwc_url` is empty, return early.
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Attempt NWC payment via `nwc_pay_invoice`.
- On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`.
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
- If tenant has no card payment method, return early.
- List all Stripe invoices for `tenant.stripe_customer_id`.
- For each invoice with `status == "open"` and `amount_due > 0`:
- Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file.
- Log and continue on failures.
## `fn handle_invoice_created(&self, invoice: &Invoice)`
Attempts to pay a new subscription invoice. Payment priority:
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt.
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with `amount_due` of 0.
@@ -112,3 +137,8 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)`
- Look up tenant by `stripe_customer_id`
- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately
+18 -1
View File
@@ -945,12 +945,29 @@ async fn update_tenant(
state.api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
let nwc_previously_empty = tenant.nwc_url.is_empty();
if let Some(nwc_url) = payload.nwc_url {
tenant.nwc_url = nwc_url;
}
match state.api.command.update_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Ok(()) => {
// When NWC is first connected, attempt to pay any outstanding open invoices.
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
let billing = state.api.billing.clone();
let tenant_clone = tenant.clone();
tokio::spawn(async move {
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
tracing::error!(
error = %e,
pubkey = %tenant_clone.pubkey,
"pay_outstanding_nwc_invoices failed after NWC setup"
);
}
});
}
Ok(ok(StatusCode::OK, tenant))
}
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
+254 -2
View File
@@ -167,6 +167,8 @@ impl Billing {
self.command
.delete_relay_subscription_item(&relay.id)
.await?;
self.validate_downgrade_proration(&tenant, "free-plan-downgrade")
.await;
}
self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(());
@@ -206,8 +208,28 @@ impl Billing {
// Sync the subscription item: create or update
let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap();
let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id {
self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
.await?
let is_downgrade = self
.is_subscription_item_downgrade(existing_item_id, plan.amount)
.await
.unwrap_or_else(|error| {
tracing::warn!(
error = %error,
relay_id = %relay.id,
"failed to determine relay plan downgrade direction"
);
false
});
let updated_item_id = self
.stripe_update_subscription_item(existing_item_id, stripe_price_id)
.await?;
if is_downgrade {
self.validate_downgrade_proration(&tenant, "paid-plan-downgrade")
.await;
}
updated_item_id
} else {
self.stripe_create_subscription_item(subscription_id, stripe_price_id)
.await?
@@ -275,6 +297,10 @@ impl Billing {
let customer = obj["customer"].as_str().unwrap_or_default();
self.handle_subscription_deleted(customer).await?;
}
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
self.handle_payment_method_attached(customer).await?;
}
_ => {}
}
@@ -492,6 +518,103 @@ impl Billing {
Ok(())
}
async fn handle_payment_method_attached(&self, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = self
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
self.pay_outstanding_card_invoices(&tenant).await?;
Ok(())
}
async fn is_subscription_item_downgrade(
&self,
item_id: &str,
next_plan_amount: i64,
) -> Result<bool> {
let Some(current_price_id) = self.stripe_get_subscription_item_price_id(item_id).await?
else {
return Ok(false);
};
let Some(current_plan_amount) = Self::plan_amount_from_price_id(&current_price_id) else {
return Ok(false);
};
Ok(next_plan_amount < current_plan_amount)
}
fn plan_amount_from_price_id(price_id: &str) -> Option<i64> {
Query::list_plans().into_iter().find_map(|plan| {
if plan.stripe_price_id.as_deref() == Some(price_id) {
Some(plan.amount)
} else {
None
}
})
}
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
match self
.stripe_preview_upcoming_invoice(
&tenant.stripe_customer_id,
tenant.stripe_subscription_id.as_deref(),
)
.await
{
Ok(upcoming) => {
let lines = upcoming["lines"]["data"]
.as_array()
.cloned()
.unwrap_or_default();
let proration_lines = lines
.iter()
.filter(|line| line["proration"].as_bool().unwrap_or(false))
.count();
let amount_due = upcoming["amount_due"]
.as_i64()
.unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0));
let currency = upcoming["currency"].as_str().unwrap_or("usd");
let preview_id = upcoming["id"].as_str().unwrap_or_default();
tracing::info!(
tenant_pubkey = %tenant.pubkey,
stripe_customer_id = %tenant.stripe_customer_id,
context,
preview_id,
proration_lines,
amount_due,
currency,
"validated Stripe proration preview for downgrade"
);
if proration_lines == 0 {
tracing::warn!(
tenant_pubkey = %tenant.pubkey,
context,
"downgrade proration preview has no proration lines; verify in Stripe dashboard"
);
}
}
Err(error) => {
tracing::warn!(
error = %error,
tenant_pubkey = %tenant.pubkey,
context,
"failed to fetch downgrade proration preview"
);
}
}
}
// --- Public API helpers ---
pub async fn get_invoice_with_tenant(
@@ -599,6 +722,93 @@ impl Billing {
Ok(invoice_response.invoice)
}
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
if tenant.nwc_url.is_empty() {
return Ok(());
}
let invoices = self
.stripe_list_invoices(&tenant.stripe_customer_id)
.await?;
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
for invoice in &invoices_arr {
let status = invoice["status"].as_str().unwrap_or_default();
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let invoice_id = invoice["id"].as_str().unwrap_or_default();
let currency = invoice["currency"].as_str().unwrap_or("usd");
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
continue;
}
match self
.nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
.await
{
Ok(()) => {
if let Err(e) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
tracing::error!(
error = %e,
invoice_id,
"failed to mark invoice paid out of band"
);
} else {
let _ = self.command.clear_tenant_nwc_error(&tenant.pubkey).await;
}
}
Err(e) => {
let error_msg = format!("{e}");
tracing::error!(
error = %e,
invoice_id,
"nwc payment failed for outstanding invoice"
);
let _ = self
.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await;
}
}
}
Ok(())
}
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
if !self
.stripe_has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
let invoices = self
.stripe_list_invoices(&tenant.stripe_customer_id)
.await?;
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
for invoice in &invoices_arr {
let status = invoice["status"].as_str().unwrap_or_default();
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let invoice_id = invoice["id"].as_str().unwrap_or_default();
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
continue;
}
if let Err(error) = self.stripe_pay_invoice(invoice_id).await {
tracing::error!(
error = %error,
invoice_id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
let resp = self
.http
@@ -715,6 +925,48 @@ impl Billing {
Ok(())
}
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
.bearer_auth(&self.stripe_secret_key)
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn stripe_get_subscription_item_price_id(&self, item_id: &str) -> Result<Option<String>> {
let resp = self
.http
.get(format!("{STRIPE_API}/subscription_items/{item_id}"))
.bearer_auth(&self.stripe_secret_key)
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(body["price"]["id"].as_str().map(ToString::to_string))
}
async fn stripe_preview_upcoming_invoice(
&self,
customer_id: &str,
subscription_id: Option<&str>,
) -> Result<serde_json::Value> {
let mut req = self
.http
.get(format!("{STRIPE_API}/invoices/upcoming"))
.bearer_auth(&self.stripe_secret_key)
.query(&[("customer", customer_id)]);
if let Some(subscription_id) = subscription_id {
req = req.query(&[("subscription", subscription_id)]);
}
let body: serde_json::Value = req.send().await?.error_for_status()?.json().await?;
Ok(body)
}
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
self.http
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
+25 -14
View File
@@ -3,7 +3,6 @@ import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -26,8 +25,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
async function loadBolt11() {
if (!props.invoice.id) return
@@ -63,7 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") {
setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else {
setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -81,7 +79,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
props.onClose()
}
@@ -160,6 +157,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button>
</div>
</Show>
<div class="text-center pt-1">
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-700"
>
Set up payment method instead
</button>
</div>
</Show>
</div>
}
@@ -172,15 +178,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<Show when={showSetup()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</Show>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</div>
</Show>
</div>
@@ -228,7 +232,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal>
<PaymentSetup
open={showPaymentSetup()}
onClose={() => setShowPaymentSetup(false)}
onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/>
</>
)
+4 -2
View File
@@ -9,6 +9,7 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
}
export default function PaymentSetup(props: PaymentSetupProps) {
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
props.onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
</div>
<button
type="button"
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p>
<button
type="button"
onClick={openPortal}
+11 -1
View File
@@ -12,12 +12,14 @@ import {
getTenant,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
updateRelay,
updateTenant,
type Activity,
type CreateRelayInput,
type Invoice,
type Relay,
type Tenant,
type UpdateRelayInput,
@@ -137,6 +139,14 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
export async function getRelayMembers(url: string) {
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
@@ -147,4 +157,4 @@ export async function getRelayMembers(url: string) {
}
}
export type { Activity, Relay, Tenant }
export type { Activity, Invoice, Relay, Tenant }
+6 -6
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { PlanId } from "@/lib/api"
import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
@@ -30,7 +30,7 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions,
) {
const [busy, setBusy] = createSignal(false)
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
@@ -101,8 +101,8 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) setNeedsPaymentSetup(true)
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
}
}
@@ -116,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
}
+98 -5
View File
@@ -1,14 +1,16 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, 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"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import { getLatestOpenInvoice, getRelayMembers, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
@@ -21,7 +23,32 @@ export default function RelayDetail() {
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, 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)
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_url
})
return (
<PageContainer>
@@ -30,6 +57,44 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members.length}
@@ -45,9 +110,37 @@ export default function RelayDetail() {
</div>
)}
</Show>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
}}
/>
)}
</Show>
<PaymentSetup
open={needsPaymentSetup()}
onClose={clearNeedsPaymentSetup}
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
}}
/>
</PageContainer>
)
+18 -12
View File
@@ -1,14 +1,15 @@
import { createSignal } from "solid-js"
import { createSignal, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentSetup from "@/components/PaymentSetup"
import PaymentDialog from "@/components/PaymentDialog"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() {
const navigate = useNavigate()
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) {
@@ -16,9 +17,9 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) {
setShowPaymentSetup(true)
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
}
@@ -27,7 +28,7 @@ export default function RelayNew() {
}
function handleDialogClose() {
setShowPaymentSetup(false)
setPendingInvoice(undefined)
navigate(`/relays/${createdRelayId}`)
}
@@ -41,10 +42,15 @@ export default function RelayNew() {
submitLabel="Create Relay"
submittingLabel="Creating..."
/>
<PaymentSetup
open={showPaymentSetup()}
onClose={handleDialogClose}
/>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleDialogClose}
/>
)}
</Show>
</PageContainer>
)
}