Compare commits

...

2 Commits

Author SHA1 Message Date
Jon Staab 1f99d0bd34 Add endpoint for paying an invoice so that users don't get expired qr codes
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 1s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-05-25 10:51:19 -07:00
Jon Staab 384ddbd439 Sync frontend and backend 2026-05-22 11:03:55 -07:00
12 changed files with 108 additions and 44 deletions
+1
View File
@@ -3,6 +3,7 @@ SERVER_HOST=127.0.0.1
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
APP_URL=http://127.0.0.1:5173 # Public base URL of the frontend, used for links in billing DMs
# Database
DATABASE_URL=sqlite://data/caravel.db
+2 -2
View File
@@ -178,7 +178,7 @@ Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/bod
- Admin or matching tenant
- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id`
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency }`
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency, period_start, period_end }`
## `get_invoice` — `GET /invoices/:id`
@@ -219,7 +219,7 @@ Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fi
1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through.
2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt.
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment.
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any). The DM includes a link to the dashboard payment view for this invoice — `{env.app_url}/account?invoice={stripe_invoice_id}` — where the tenant can review the invoice and pay by Lightning or card.
## `invoice.paid`
+1
View File
@@ -8,6 +8,7 @@ Members (all populated from environment variables):
- `server_port: u16` - from `SERVER_PORT`
- `server_admin_pubkeys: Vec<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
- `app_url: String` - public base URL of the frontend app from `APP_URL`, with any trailing slash stripped; used to build tenant-facing links such as the invoice payment link in billing DMs
- `database_url: String` - from `DATABASE_URL`
- `robot_name: String` - from `ROBOT_NAME`
- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices
+1 -1
View File
@@ -95,4 +95,4 @@ Verifies the `Stripe-Signature` header against `env.stripe_webhook_secret` and p
- `StripeSubscription { id, status, items: Vec<StripeSubscriptionItem> }` (`items` flattened from Stripe's `{ data: [...] }` list)
- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent)
- `StripePrice { id }`
- `StripeInvoice { id, customer, status, amount_due, currency }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
- `StripeInvoice { id, customer, status, amount_due, currency, period_start, period_end }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
+4
View File
@@ -7,6 +7,9 @@ pub struct Env {
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
/// Public base URL of the frontend app, used to build links sent to tenants
/// (e.g. the invoice payment link in billing DMs). No trailing slash.
pub app_url: String,
pub database_url: String,
pub robot_name: String,
pub robot_wallet: String,
@@ -43,6 +46,7 @@ impl Env {
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
app_url: require_str("APP_URL").trim_end_matches('/').to_string(),
database_url: require_str("DATABASE_URL"),
robot_name: require_str("ROBOT_NAME"),
robot_wallet: require_str("ROBOT_WALLET"),
+11 -12
View File
@@ -12,7 +12,7 @@ use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
use crate::web::{ApiResult, bad_request, internal, ok};
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
@@ -165,8 +165,16 @@ async fn handle_invoice_created(
return Ok(());
}
// 3. Manual payment: send a DM
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
// 3. Manual payment: DM a link to the in-app payment page for this invoice
let url_base = &api.env.app_url;
let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}");
let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match nwc_error_for_dm {
Some(error) if !error.is_empty() => {
format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => base,
};
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
@@ -339,12 +347,3 @@ fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
truncated.push_str("...");
Some(truncated)
}
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
match nwc_error {
Some(error) if !error.is_empty() => {
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
}
}
+2
View File
@@ -59,6 +59,8 @@ pub struct StripeInvoice {
pub status: String,
pub amount_due: i64,
pub currency: String,
pub period_start: i64,
pub period_end: i64,
}
#[derive(serde::Deserialize)]
+59 -16
View File
@@ -1,16 +1,16 @@
import { createEffect, createSignal, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
import { useTenantRelays } from "@/lib/hooks"
import { plans } from "@/lib/state"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = {
id: string
amount_due: number
}
type PaymentInvoice = Pick<Invoice, "id" | "amount_due"> &
Partial<Pick<Invoice, "period_start" | "period_end">>
type PaymentDialogProps = {
invoice: PaymentInvoice
@@ -27,6 +27,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [payError, setPayError] = createSignal("")
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [relays] = useTenantRelays()
const billedRelays = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
.filter((entry) => entry.plan?.stripe_price_id)
})
async function loadBolt11() {
if (!props.invoice.id) return
@@ -84,6 +92,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
const periodLabel = () => {
const { period_start, period_end } = props.invoice
if (!period_start || !period_end) return ""
const start = new Date(period_start * 1000).toLocaleDateString()
const end = new Date(period_end * 1000).toLocaleDateString()
return `${start} ${end}`
}
return (
<>
<Modal
@@ -98,6 +114,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<div>
<h2 class="text-lg font-semibold text-gray-900">Pay Invoice</h2>
<p class="text-2xl font-bold text-gray-900 mt-1">{amountLabel()}</p>
<Show when={periodLabel()}>
<p class="text-xs text-gray-500 mt-0.5">Billing period {periodLabel()}</p>
</Show>
</div>
<button
type="button"
@@ -117,7 +136,28 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<Show
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-3">
<div class="w-full space-y-4">
{/* What's being paid for */}
<Show when={billedRelays().length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
<ul class="space-y-1.5">
<For each={billedRelays()}>
{({ relay, plan }) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
<span class="flex-shrink-0 text-xs text-gray-500">
{plan?.name ?? relay.plan}
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
</span>
</li>
)}
</For>
</ul>
</div>
</Show>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide text-center">Pay with Lightning</p>
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
@@ -157,16 +197,19 @@ 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>
{/* Card / automatic payment alternative */}
<div class="border-t border-gray-100 pt-3 text-center">
<p class="text-xs text-gray-500 mb-1">Prefer to pay with a card?</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
Pay with card or set up automatic payments
</button>
</div>
</div>
}
>
+2 -2
View File
@@ -97,7 +97,7 @@ export type UpdateRelayInput = {
export type Tenant = {
pubkey: string
nwc_url: string
nwc_is_set: boolean
created_at: number
stripe_customer_id: string
stripe_subscription_id: string | null
@@ -107,10 +107,10 @@ export type Tenant = {
export type Invoice = {
id: string
customer: string
status: string
amount_due: number
currency: string
hosted_invoice_url: string
period_start: number
period_end: number
}
+1 -1
View File
@@ -135,7 +135,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_url && !tenant.stripe_subscription_id
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
+23 -9
View File
@@ -1,10 +1,11 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, listTenantInvoices, type Invoice } from "@/lib/api"
import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
export default function Account() {
@@ -17,15 +18,22 @@ export default function Account() {
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
const hasBillingChanges = createMemo(() => {
const current = tenant()?.nwc_url?.trim() ?? ""
const next = nwcUrl().trim()
return current !== next
})
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
// invoice and opens the payment dialog. The fetched invoice takes precedence
// over a row the user clicked in the list.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinkedInvoice] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
createEffect(() => {
setNwcUrl(tenant()?.nwc_url ?? "")
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
})
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
// The backend never returns the stored nwc_url (it's private), so the input is
// write-only: we can only act on a newly entered URL, not prefill the saved one.
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
async function saveBilling() {
setError("")
@@ -33,6 +41,7 @@ export default function Account() {
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
setNwcUrl("")
await refetchTenant()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
@@ -43,6 +52,8 @@ export default function Account() {
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
// Clearing the query param drops the deep-linked invoice and closes the dialog.
if (searchParams.invoice) setSearchParams({ invoice: undefined })
void refetchInvoices()
}
@@ -111,6 +122,9 @@ export default function Account() {
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
</p>
<Show when={tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
<input
type="text"
@@ -188,7 +202,7 @@ export default function Account() {
</section>
</div>
<Show when={selectedInvoice()}>
<Show when={activeInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function RelayDetail() {
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_url
return !t.nwc_is_set
})
return (