Massive user-story-oriented refactor
This commit is contained in:
+29
-1
@@ -97,6 +97,7 @@ export type Tenant = {
|
||||
pubkey: string
|
||||
nwc_is_set: boolean
|
||||
created_at: number
|
||||
billing_anchor: number | null
|
||||
stripe_customer_id: string
|
||||
stripe_payment_method_id: string | null
|
||||
nwc_error: string | null
|
||||
@@ -113,6 +114,29 @@ export type Invoice = {
|
||||
created_at: number
|
||||
paid_at: number | null
|
||||
voided_at: number | null
|
||||
method: "nwc" | "stripe" | "oob" | null
|
||||
}
|
||||
|
||||
export type InvoiceItem = {
|
||||
id: string
|
||||
invoice_id: string | null
|
||||
activity_id: string | null
|
||||
tenant_pubkey: string
|
||||
relay_id: string
|
||||
plan_id: string
|
||||
amount: number
|
||||
description: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type Bolt11 = {
|
||||
id: string
|
||||
invoice_id: string
|
||||
lnbc: string
|
||||
msats: number
|
||||
created_at: number
|
||||
expires_at: number
|
||||
settled_at: number | null
|
||||
}
|
||||
|
||||
// The backend models an invoice's lifecycle as timestamps rather than a status
|
||||
@@ -265,7 +289,11 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
|
||||
}
|
||||
|
||||
export function getInvoiceBolt11(invoiceId: string) {
|
||||
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
|
||||
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
|
||||
}
|
||||
|
||||
export function listInvoiceItems(invoiceId: string) {
|
||||
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
|
||||
}
|
||||
|
||||
export function createRelay(input: CreateRelayInput) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
|
||||
import { billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
||||
|
||||
// Set while the create/upgrade flow drives its own payment/setup modals, so the
|
||||
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
|
||||
// every close path of that flow.
|
||||
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
|
||||
|
||||
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
||||
|
||||
export type BillingPrompt = {
|
||||
kind: BillingPromptKind
|
||||
severity: "error" | "warn" | "info"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BillingStatusSnapshot = {
|
||||
tenant: Tenant | undefined
|
||||
openInvoice: Invoice | undefined
|
||||
hasPaidSubscription: boolean
|
||||
}
|
||||
|
||||
// The single billing read shared by the dashboard shell and the billing page.
|
||||
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
|
||||
// dunning order so the UI pays the same one collection targets.
|
||||
export function useBillingStatus() {
|
||||
const tenant = () => billingTenant()
|
||||
const invoices = () => billingInvoices() ?? []
|
||||
|
||||
const openInvoices = createMemo(() =>
|
||||
invoices()
|
||||
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||
.sort((a, b) => a.created_at - b.created_at),
|
||||
)
|
||||
const openInvoice = () => openInvoices()[0]
|
||||
|
||||
// Amount due: the total of all open invoices.
|
||||
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
||||
|
||||
const hasPaidSubscription = createMemo(() => {
|
||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
||||
return (billingRelays() ?? []).some((relay) => {
|
||||
const plan = planById.get(relay.plan_id)
|
||||
return Boolean(plan && plan.amount > 0 && relay.status === "active")
|
||||
})
|
||||
})
|
||||
|
||||
const loading = () => billingTenant.loading || billingInvoices.loading
|
||||
|
||||
return { tenant, invoices, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
|
||||
}
|
||||
|
||||
// Pure priority selector: returns the single highest-priority billing prompt to
|
||||
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
|
||||
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
|
||||
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
|
||||
// and method errors.
|
||||
export function activeBillingPrompt(
|
||||
s: BillingStatusSnapshot,
|
||||
opts?: { suppressInline?: boolean },
|
||||
): BillingPrompt | null {
|
||||
const tenant = s.tenant
|
||||
if (!tenant) return null
|
||||
|
||||
if (tenant.churned_at) {
|
||||
return {
|
||||
kind: "churned",
|
||||
severity: "error",
|
||||
message:
|
||||
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
|
||||
}
|
||||
}
|
||||
|
||||
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
|
||||
const methodError = tenant.nwc_error ?? tenant.stripe_error
|
||||
const suppressInline = opts?.suppressInline ?? false
|
||||
|
||||
if (s.openInvoice && !suppressInline && (!autopayConfigured || methodError)) {
|
||||
return {
|
||||
kind: "pay_invoice",
|
||||
severity: "warn",
|
||||
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
|
||||
}
|
||||
}
|
||||
|
||||
if (methodError) {
|
||||
return {
|
||||
kind: "update_method",
|
||||
severity: "warn",
|
||||
message: tenant.nwc_error
|
||||
? "Your Lightning wallet couldn't be charged. Update your payment method."
|
||||
: "Your card couldn't be charged. Update your payment method.",
|
||||
}
|
||||
}
|
||||
|
||||
if (s.hasPaidSubscription && !autopayConfigured && !s.openInvoice && !suppressInline) {
|
||||
return {
|
||||
kind: "setup_autopay",
|
||||
severity: "info",
|
||||
message: "Set up automatic payments so your subscription renews without interruption.",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
|
||||
import { getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
|
||||
|
||||
export type UnsignedEvent = {
|
||||
kind: number
|
||||
@@ -55,6 +55,22 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
|
||||
}
|
||||
)
|
||||
|
||||
// Shared billing reads, fetched once per session and consumed by the dashboard
|
||||
// shell, the billing page, and the billing-prompt surface. Keyed on the active
|
||||
// pubkey so they refetch on account switch; refetchBilling() refreshes them all
|
||||
// after a mutation (payment, method update, plan change).
|
||||
const billingKey = () => account()?.pubkey
|
||||
|
||||
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
|
||||
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
|
||||
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
|
||||
|
||||
export function refetchBilling() {
|
||||
void refetchBillingTenant()
|
||||
void refetchBillingInvoices()
|
||||
void refetchBillingRelays()
|
||||
}
|
||||
|
||||
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import QRCode from "qrcode"
|
||||
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
||||
import { PLATFORM_NAME } from "@/lib/state"
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
nwc: "Lightning",
|
||||
stripe: "Card",
|
||||
oob: "Lightning (out of band)",
|
||||
}
|
||||
|
||||
const fmtUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
||||
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
||||
return value.replace(/[&<>"']/g, (c) => map[c])
|
||||
}
|
||||
|
||||
// Generates a printable invoice and opens the browser's print/save-as-PDF dialog.
|
||||
// No PDF dependency: the invoice is rendered as standalone HTML into an off-screen
|
||||
// iframe so the current page is never disturbed. The bitcoin line is included only
|
||||
// for Lightning-relevant invoices (never card-paid or void) to avoid spuriously
|
||||
// minting a bolt11.
|
||||
export function useInvoicePdf() {
|
||||
const [printing, setPrinting] = createSignal(false)
|
||||
|
||||
async function printInvoice(invoice: Invoice) {
|
||||
if (printing()) return
|
||||
setPrinting(true)
|
||||
try {
|
||||
const items = await listInvoiceItems(invoice.id).catch(() => [] as InvoiceItem[])
|
||||
|
||||
let sats: number | undefined
|
||||
let qrDataUrl: string | undefined
|
||||
if (invoice.method !== "stripe" && invoice.voided_at == null) {
|
||||
try {
|
||||
const bolt11 = await getInvoiceBolt11(invoice.id)
|
||||
sats = Math.round(bolt11.msats / 1000)
|
||||
if (invoice.paid_at == null) {
|
||||
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
|
||||
}
|
||||
} catch {
|
||||
// no bolt11 available — omit the bitcoin line
|
||||
}
|
||||
}
|
||||
|
||||
printHtml(buildHtml({ invoice, items, sats, qrDataUrl }))
|
||||
} finally {
|
||||
setPrinting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { printInvoice, printing }
|
||||
}
|
||||
|
||||
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
|
||||
const { invoice, items, sats, qrDataUrl } = opts
|
||||
const status = invoiceStatus(invoice)
|
||||
|
||||
const rows = items.length
|
||||
? items
|
||||
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${fmtUsd(i.amount)}</td></tr>`)
|
||||
.join("")
|
||||
: `<tr><td>Relay subscription</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr>`
|
||||
|
||||
const satsRow = sats != null ? `<tr><td>Bitcoin equivalent</td><td class="amt">${sats.toLocaleString()} sats</td></tr>` : ""
|
||||
const methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabels[invoice.method] ?? invoice.method)}</div>` : ""
|
||||
const qr = qrDataUrl
|
||||
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
|
||||
: ""
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
|
||||
<style>
|
||||
* { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111827; box-sizing: border-box; }
|
||||
body { margin: 40px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.muted { color: #6b7280; font-size: 12px; }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
||||
.badge { text-transform: capitalize; border: 1px solid #d1d5db; border-radius: 9999px; padding: 2px 10px; font-size: 12px; }
|
||||
.meta { font-size: 13px; color: #374151; line-height: 1.6; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
|
||||
.amt { text-align: right; white-space: nowrap; }
|
||||
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
|
||||
.qr { margin-top: 28px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<div><h1>${escapeHtml(PLATFORM_NAME)}</h1><div class="muted">Invoice</div></div>
|
||||
<span class="badge">${status}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>Invoice ID: ${escapeHtml(invoice.id)}</div>
|
||||
<div>Billed to: ${escapeHtml(invoice.tenant_pubkey)}</div>
|
||||
<div>Issued: ${fmtDate(invoice.created_at)}</div>
|
||||
<div>Period: ${fmtDate(invoice.period_start)} – ${fmtDate(invoice.period_end)}</div>
|
||||
${methodLine}
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
|
||||
<tbody>${rows}${satsRow}</tbody>
|
||||
<tfoot><tr><td>Total</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr></tfoot>
|
||||
</table>
|
||||
${qr}
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function printHtml(html: string) {
|
||||
const iframe = document.createElement("iframe")
|
||||
iframe.style.position = "fixed"
|
||||
iframe.style.right = "0"
|
||||
iframe.style.bottom = "0"
|
||||
iframe.style.width = "0"
|
||||
iframe.style.height = "0"
|
||||
iframe.style.border = "0"
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const win = iframe.contentWindow
|
||||
const doc = win?.document
|
||||
if (!win || !doc) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
doc.open()
|
||||
doc.write(html)
|
||||
doc.close()
|
||||
|
||||
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
|
||||
win.onafterprint = cleanup
|
||||
|
||||
// Let the iframe lay out (and decode the QR image) before printing.
|
||||
window.setTimeout(() => {
|
||||
win.focus()
|
||||
win.print()
|
||||
window.setTimeout(cleanup, 60000)
|
||||
}, 150)
|
||||
}
|
||||
@@ -104,8 +104,13 @@ export default function useRelayToggles(
|
||||
if (plan_id !== "free") {
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
if (needsSetup) {
|
||||
// Materialize the invoice for this upgrade (no collection, no DM) so we
|
||||
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
|
||||
// first, so a just-created invoice is visible here.
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
}
|
||||
setPendingPaymentSetup(true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user