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.
|
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`
|
- 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 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 `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
|
- Creates a Stripe Customer Portal session for the given customer
|
||||||
- Returns the portal session URL
|
- 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)`
|
## `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`:
|
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)
|
- 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)?;
|
state.api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||||
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
|
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 {
|
if let Some(nwc_url) = payload.nwc_url {
|
||||||
tenant.nwc_url = nwc_url;
|
tenant.nwc_url = nwc_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
match state.api.command.update_tenant(&tenant).await {
|
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(
|
Err(e) => Ok(err(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"internal",
|
"internal",
|
||||||
|
|||||||
@@ -599,6 +599,62 @@ impl Billing {
|
|||||||
Ok(invoice_response.invoice)
|
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> {
|
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
const [payError, setPayError] = createSignal("")
|
const [payError, setPayError] = createSignal("")
|
||||||
const [showSetup, setShowSetup] = createSignal(false)
|
const [showSetup, setShowSetup] = createSignal(false)
|
||||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||||
|
const [setupSaved, setSetupSaved] = createSignal(false)
|
||||||
|
|
||||||
async function loadBolt11() {
|
async function loadBolt11() {
|
||||||
if (!props.invoice.id) return
|
if (!props.invoice.id) return
|
||||||
@@ -160,6 +161,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -228,7 +238,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
<PaymentSetup
|
<PaymentSetup
|
||||||
open={showPaymentSetup()}
|
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 = {
|
type PaymentSetupProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSaved?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymentSetup(props: PaymentSetupProps) {
|
export default function PaymentSetup(props: PaymentSetupProps) {
|
||||||
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
|||||||
try {
|
try {
|
||||||
await updateActiveTenant({ nwc_url: url })
|
await updateActiveTenant({ nwc_url: url })
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
|
props.onSaved?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
|
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
|||||||
<line x1="1" y1="10" x2="23" y2="10" />
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openPortal}
|
onClick={openPortal}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
getTenant,
|
getTenant,
|
||||||
listRelayActivity,
|
listRelayActivity,
|
||||||
listRelays,
|
listRelays,
|
||||||
|
listTenantInvoices,
|
||||||
listTenantRelays,
|
listTenantRelays,
|
||||||
listTenants,
|
listTenants,
|
||||||
updateRelay,
|
updateRelay,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
type Activity,
|
type Activity,
|
||||||
type CreateRelayInput,
|
type CreateRelayInput,
|
||||||
|
type Invoice,
|
||||||
type Relay,
|
type Relay,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
type UpdateRelayInput,
|
type UpdateRelayInput,
|
||||||
@@ -137,6 +139,14 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
|||||||
return !tenant.nwc_url && !tenant.stripe_subscription_id
|
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) {
|
export async function getRelayMembers(url: string) {
|
||||||
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
|
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 { 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 { 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 {
|
function toBool(value: number | undefined, fallback: boolean): boolean {
|
||||||
if (value === 0) return false
|
if (value === 0) return false
|
||||||
@@ -30,7 +30,7 @@ export default function useRelayToggles(
|
|||||||
{ refetch, mutate }: RelayActions,
|
{ refetch, mutate }: RelayActions,
|
||||||
) {
|
) {
|
||||||
const [busy, setBusy] = createSignal(false)
|
const [busy, setBusy] = createSignal(false)
|
||||||
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
|
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||||
|
|
||||||
async function updateRelay(next: Relay, previous: Relay) {
|
async function updateRelay(next: Relay, previous: Relay) {
|
||||||
mutate(next)
|
mutate(next)
|
||||||
@@ -101,8 +101,8 @@ export default function useRelayToggles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plan !== "free") {
|
if (plan !== "free") {
|
||||||
const needs = await tenantNeedsPaymentSetup()
|
const invoice = await getLatestOpenInvoice()
|
||||||
if (needs) setNeedsPaymentSetup(true)
|
if (invoice) setPendingInvoice(invoice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,5 +116,5 @@ export default function useRelayToggles(
|
|||||||
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
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 { 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 BackLink from "@/components/BackLink"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
import PaymentSetup from "@/components/PaymentSetup"
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import RelayDetailCard from "@/components/RelayDetailCard"
|
import RelayDetailCard from "@/components/RelayDetailCard"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/components/useMinLoading"
|
||||||
import ActivityFeed from "@/components/ActivityFeed"
|
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 useRelayToggles from "@/lib/useRelayToggles"
|
||||||
|
import { plans } from "@/lib/state"
|
||||||
|
|
||||||
export default function RelayDetail() {
|
export default function RelayDetail() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -21,7 +23,32 @@ export default function RelayDetail() {
|
|||||||
const [members] = createResource(relayUrl, getRelayMembers)
|
const [members] = createResource(relayUrl, getRelayMembers)
|
||||||
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, 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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -30,6 +57,44 @@ export default function RelayDetail() {
|
|||||||
<Show when={!loading() && relay()}>
|
<Show when={!loading() && relay()}>
|
||||||
{(r) => (
|
{(r) => (
|
||||||
<div class="space-y-6 mb-6">
|
<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
|
<RelayDetailCard
|
||||||
relay={r()}
|
relay={r()}
|
||||||
currentMembers={members.length}
|
currentMembers={members.length}
|
||||||
@@ -45,9 +110,37 @@ export default function RelayDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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
|
<PaymentSetup
|
||||||
open={needsPaymentSetup()}
|
open={paymentSetupOpen()}
|
||||||
onClose={clearNeedsPaymentSetup}
|
onClose={() => {
|
||||||
|
setPaymentSetupOpen(false)
|
||||||
|
void refetchTenant()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { useNavigate } from "@solidjs/router"
|
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 PaymentSetup from "@/components/PaymentSetup"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
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() {
|
export default function RelayNew() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||||
let createdRelayId = ""
|
let createdRelayId = ""
|
||||||
|
|
||||||
async function handleSubmit(values: RelayFormValues) {
|
async function handleSubmit(values: RelayFormValues) {
|
||||||
@@ -16,9 +17,9 @@ export default function RelayNew() {
|
|||||||
createdRelayId = relay.id
|
createdRelayId = relay.id
|
||||||
|
|
||||||
if (values.plan !== "free") {
|
if (values.plan !== "free") {
|
||||||
const needs = await tenantNeedsPaymentSetup()
|
const invoice = await getLatestOpenInvoice()
|
||||||
if (needs) {
|
if (invoice) {
|
||||||
setShowPaymentSetup(true)
|
setPendingInvoice(invoice)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +28,7 @@ export default function RelayNew() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDialogClose() {
|
function handleDialogClose() {
|
||||||
setShowPaymentSetup(false)
|
setPendingInvoice(undefined)
|
||||||
navigate(`/relays/${createdRelayId}`)
|
navigate(`/relays/${createdRelayId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +42,15 @@ export default function RelayNew() {
|
|||||||
submitLabel="Create Relay"
|
submitLabel="Create Relay"
|
||||||
submittingLabel="Creating..."
|
submittingLabel="Creating..."
|
||||||
/>
|
/>
|
||||||
<PaymentSetup
|
<Show when={pendingInvoice()}>
|
||||||
open={showPaymentSetup()}
|
{(inv) => (
|
||||||
onClose={handleDialogClose}
|
<PaymentDialog
|
||||||
/>
|
invoice={inv()}
|
||||||
|
open={true}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user