forked from coracle/caravel
feat: invoice payment flow for paid relays
This commit is contained in:
+14
-1
@@ -26,6 +26,8 @@ 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 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.
|
||||
@@ -66,9 +68,20 @@ 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.
|
||||
|
||||
## `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)
|
||||
|
||||
+18
-1
@@ -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",
|
||||
|
||||
@@ -599,6 +599,62 @@ 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(())
|
||||
}
|
||||
|
||||
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
|
||||
let resp = self
|
||||
.http
|
||||
@@ -805,6 +861,16 @@ impl Billing {
|
||||
}
|
||||
|
||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
||||
// BTC_PRICE_USD_OVERRIDE bypasses the external price API entirely.
|
||||
// Set this to a fixed USD/BTC rate (e.g. "97000") for local testing
|
||||
// when Coinbase is unreachable. Remove or leave unset in production.
|
||||
if let Ok(val) = std::env::var("BTC_PRICE_USD_OVERRIDE") {
|
||||
let price = val
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|e| anyhow!("invalid BTC_PRICE_USD_OVERRIDE: {e}"))?;
|
||||
return Ok(price);
|
||||
}
|
||||
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
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
|
||||
@@ -160,6 +161,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>
|
||||
}
|
||||
@@ -228,7 +238,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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 once invoices are issued.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPortal}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user