forked from coracle/caravel
refactor billing endpoints to separate reads from reconciliation requests
This commit is contained in:
@@ -5,7 +5,7 @@ import PaymentDialog from "@/components/PaymentDialog"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import { getInvoice, type Invoice } from "@/lib/api"
|
||||
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
|
||||
import { billingFlowActive } from "@/lib/state"
|
||||
import { account, billingFlowActive } from "@/lib/state"
|
||||
|
||||
type BillingPromptsProps = {
|
||||
// "banner" sits in the dashboard shell (mounted on every page except the
|
||||
@@ -84,6 +84,13 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
||||
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
||||
}
|
||||
|
||||
// After paying or saving a method, reconcile + sync + (auto)collect, then
|
||||
// refresh — so a card added inside the dialog actually settles the invoice.
|
||||
function refreshBilling() {
|
||||
const pubkey = account()?.pubkey
|
||||
if (pubkey) void status.autopay(pubkey)
|
||||
}
|
||||
|
||||
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
|
||||
|
||||
return (
|
||||
@@ -110,7 +117,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
||||
const wasDeepLink = !payInvoice()
|
||||
setPayInvoice(undefined)
|
||||
if (wasDeepLink) clearDeepLink()
|
||||
status.refetch()
|
||||
refreshBilling()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -121,7 +128,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
||||
initialTab={setupTab()}
|
||||
onClose={() => {
|
||||
setSetupOpen(false)
|
||||
status.refetch()
|
||||
refreshBilling()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,7 @@ import LightningPayBody from "@/components/payment/LightningPayBody"
|
||||
import { setToastMessage } from "@/lib/state"
|
||||
import { copyToClipboard } from "@/lib/clipboard"
|
||||
import { useCardPortal } from "@/lib/usePaymentSetup"
|
||||
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
|
||||
import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
|
||||
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||
import { billingTenant } from "@/lib/state"
|
||||
import { formatUsd, formatPeriod } from "@/lib/format"
|
||||
@@ -57,7 +57,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
setQrDataUrl("")
|
||||
|
||||
try {
|
||||
const { lnbc } = await getInvoiceBolt11(props.invoice.id)
|
||||
const { lnbc } = await ensureInvoiceBolt11(props.invoice.id)
|
||||
setBolt11(lnbc)
|
||||
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
|
||||
setBolt11Status("ready")
|
||||
@@ -86,7 +86,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
async function checkPayment() {
|
||||
setPayStatus("loading")
|
||||
try {
|
||||
const invoice = await getInvoice(props.invoice.id)
|
||||
const invoice = await reconcileInvoice(props.invoice.id)
|
||||
if (invoice.paid_at != null) {
|
||||
setPayStatus("success")
|
||||
} else {
|
||||
|
||||
+31
-2
@@ -154,6 +154,16 @@ export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">):
|
||||
return "open"
|
||||
}
|
||||
|
||||
// The single invoice autopay collects and the dashboard surfaces as "Pay now":
|
||||
// the OLDEST open invoice with a positive balance, matching the backend's
|
||||
// dunning order so the UI pays the same one collection targets. undefined when
|
||||
// nothing is due. Canonical pick shared by useBillingStatus and autopayBilling.
|
||||
export function selectPayableInvoice(invoices: Invoice[]): Invoice | undefined {
|
||||
return invoices
|
||||
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||
.sort((a, b) => a.created_at - b.created_at)[0]
|
||||
}
|
||||
|
||||
export type Activity = {
|
||||
id: string
|
||||
tenant_pubkey: string
|
||||
@@ -265,6 +275,14 @@ export function listTenantInvoices(pubkey: string) {
|
||||
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
|
||||
}
|
||||
|
||||
// Reconcile a tenant's billing: sync the Stripe payment method (picking up a
|
||||
// card added via the portal), fold billable activity into invoice items, renew
|
||||
// the current period, and cut an invoice for any outstanding balance. Does not
|
||||
// attempt payment. Returns the reconciled tenant.
|
||||
export function reconcileTenant(pubkey: string) {
|
||||
return callApi<undefined, Tenant>("POST", `/tenants/${pubkey}/reconcile`)
|
||||
}
|
||||
|
||||
// The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing
|
||||
// the current period's not-yet-billed items, or null when there's nothing due.
|
||||
export function getDraftInvoice(pubkey: string) {
|
||||
@@ -310,8 +328,19 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
|
||||
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
|
||||
}
|
||||
|
||||
export function getInvoiceBolt11(invoiceId: string) {
|
||||
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
|
||||
// Idempotently create a payable bolt11 for an invoice (reusing a valid existing
|
||||
// one) and return it. No reconciliation — settlement is detected by
|
||||
// reconcileInvoice. The lnbc string is the data the QR needs.
|
||||
export function ensureInvoiceBolt11(invoiceId: string) {
|
||||
return callApi<undefined, Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
|
||||
}
|
||||
|
||||
// Reconcile and collect an open invoice: ensure a payable bolt11 exists, then
|
||||
// run the payment cascade (NWC, then an out-of-band Lightning settle, then a
|
||||
// saved card). Caller-initiated, so no dunning DM and no churn. Returns the
|
||||
// refreshed invoice (paid_at set once collected).
|
||||
export function reconcileInvoice(invoiceId: string) {
|
||||
return callApi<undefined, Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
|
||||
}
|
||||
|
||||
export function listInvoiceItems(invoiceId: string) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { indexBy } from "@welshman/lib"
|
||||
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
|
||||
import { invoiceStatus, selectPayableInvoice, type Invoice, type Tenant } from "@/lib/api"
|
||||
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
|
||||
import { billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
||||
import { autopayBilling, billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
||||
|
||||
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
||||
|
||||
@@ -30,11 +30,10 @@ export function useBillingStatus() {
|
||||
const draftInvoice = () => billingDraftInvoice() ?? undefined
|
||||
|
||||
const openInvoices = createMemo(() =>
|
||||
invoices()
|
||||
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||
.sort((a, b) => a.created_at - b.created_at),
|
||||
invoices().filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0),
|
||||
)
|
||||
const openInvoice = () => openInvoices()[0]
|
||||
// The autopay/dunning target — the same pick autopayBilling collects.
|
||||
const openInvoice = () => selectPayableInvoice(invoices())
|
||||
|
||||
// Amount due: the total of all open invoices.
|
||||
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
||||
@@ -49,7 +48,7 @@ export function useBillingStatus() {
|
||||
|
||||
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
|
||||
|
||||
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
|
||||
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling, autopay: autopayBilling }
|
||||
}
|
||||
|
||||
// Pure priority selector: returns the single highest-priority billing prompt to
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
listTenantInvoices,
|
||||
listTenantRelays,
|
||||
listTenants,
|
||||
reconcileTenant,
|
||||
updateRelay,
|
||||
updateTenant,
|
||||
type Activity,
|
||||
@@ -208,6 +209,11 @@ export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||
// none is available). Shared by RelayNew, Home's signup-and-create path, and the
|
||||
// plan-upgrade toggle so the post-paid ladder stays identical across all three.
|
||||
export async function resolvePostPaidFlow(): Promise<PaidFlowDecision> {
|
||||
const pubkey = account()!.pubkey
|
||||
// The reads below are pure GETs now, so explicitly materialize the just-created
|
||||
// invoice and pick up any portal-added card before deciding the post-paid ladder.
|
||||
await reconcileTenant(pubkey)
|
||||
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
const invoice = needsSetup ? await getLatestOpenInvoice() : null
|
||||
return decidePostPaidFlow({ needsSetup, invoice })
|
||||
|
||||
@@ -6,7 +6,8 @@ import { EventStore } from "applesauce-core"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
|
||||
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, reconcileInvoice, reconcileTenant, registerAccountGetter, selectPayableInvoice, type Plan } from "@/lib/api"
|
||||
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||
|
||||
export type UnsignedEvent = {
|
||||
kind: number
|
||||
@@ -101,6 +102,50 @@ export function refetchBilling() {
|
||||
})
|
||||
}
|
||||
|
||||
// In-flight autopay run, keyed by pubkey, so concurrent triggers (a mount
|
||||
// double-fire, two dialog onClose handlers) collapse into one run.
|
||||
let autopayInFlight: { pubkey: string; promise: Promise<void> } | undefined
|
||||
|
||||
// The side-effecting billing refresh, layered above the pure refetchBilling: on
|
||||
// load of the billing surface it reconciles the subscription (materializing the
|
||||
// current invoice), syncs the Stripe payment method (picking up a portal-added
|
||||
// card), and — when a method is on file and an invoice is due — collects it,
|
||||
// then refreshes all billing reads. This is what makes "add a card, return to
|
||||
// the app" actually pay the open invoice. Payment is skipped while the
|
||||
// create/upgrade flow owns the invoice (billingFlowActive).
|
||||
export function autopayBilling(pubkey: string): Promise<void> {
|
||||
if (autopayInFlight?.pubkey === pubkey) return autopayInFlight.promise
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// The tenant row is provisioned lazily on first login; make sure it exists
|
||||
// before the tenant-scoped POSTs, since autopay can fire (on the Account
|
||||
// landing effect) before provisioning has completed.
|
||||
if (billingPubkey() !== pubkey) await ensureSessionTenant()
|
||||
|
||||
// Sync the payment method + reconcile the subscription, then collect the
|
||||
// oldest open invoice when a method is on file (and the create/upgrade
|
||||
// flow isn't already driving its own invoice).
|
||||
const tenant = await reconcileTenant(pubkey)
|
||||
|
||||
const invoice = selectPayableInvoice(await listTenantInvoices(pubkey))
|
||||
if (invoice && autopayConfigured(tenant) && !billingFlowActive()) {
|
||||
await reconcileInvoice(invoice.id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Autopay billing failed", e)
|
||||
setToastMessage(e instanceof Error ? e.message : "Failed to update billing")
|
||||
} finally {
|
||||
// Reflect the final state (paid invoice, cleared errors, relay changes).
|
||||
refetchBilling()
|
||||
if (autopayInFlight?.pubkey === pubkey) autopayInFlight = undefined
|
||||
}
|
||||
})()
|
||||
|
||||
autopayInFlight = { pubkey, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
|
||||
// tenant is created lazily on first login, so this must run before any
|
||||
// tenant-scoped read. The in-flight promise is shared so the login flow (which
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import QRCode from "qrcode"
|
||||
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
||||
import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
||||
import { methodLabel } from "@/lib/paymentMethod"
|
||||
import { formatUsd } from "@/lib/format"
|
||||
import { PLATFORM_NAME } from "@/lib/state"
|
||||
@@ -36,7 +36,7 @@ export function useInvoicePdf() {
|
||||
let qrDataUrl: string | undefined
|
||||
if (invoice.method !== "stripe" && invoice.voided_at == null) {
|
||||
try {
|
||||
const bolt11 = await getInvoiceBolt11(invoice.id)
|
||||
const bolt11 = await ensureInvoiceBolt11(invoice.id)
|
||||
sats = Math.round(bolt11.msats / 1000)
|
||||
if (invoice.paid_at == null) {
|
||||
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
|
||||
|
||||
@@ -42,11 +42,13 @@ export default function Account() {
|
||||
const invoicesLoading = useMinLoading(() => billing.loading())
|
||||
const { printInvoice, printing } = useInvoicePdf()
|
||||
|
||||
// On landing here (the billing portal returns to this page), refresh billing so
|
||||
// a card just added in the portal — which get_tenant syncs onto the tenant — and
|
||||
// any cleared error show immediately rather than only on the next reconcile.
|
||||
// On landing here (the billing portal returns to this page), run the autopay
|
||||
// composite: reconcile the subscription, sync a card just added in the portal,
|
||||
// and collect the open invoice if a method is now on file — then refresh. This
|
||||
// is what pays the outstanding invoice after the user adds a card and returns.
|
||||
createEffect(() => {
|
||||
if (account()?.pubkey) billing.refetch()
|
||||
const pubkey = account()?.pubkey
|
||||
if (pubkey) void billing.autopay(pubkey)
|
||||
})
|
||||
|
||||
// Coarse account-health summary for the badge. Same snapshot the inline prompt
|
||||
@@ -176,7 +178,7 @@ export default function Account() {
|
||||
open={true}
|
||||
onClose={() => {
|
||||
setSelectedInvoice(undefined)
|
||||
billing.refetch()
|
||||
void billing.autopay(account()!.pubkey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -187,7 +189,7 @@ export default function Account() {
|
||||
isUpdate={nwc().kind !== "not_set_up"}
|
||||
onClose={() => {
|
||||
setNwcModalOpen(false)
|
||||
billing.refetch()
|
||||
void billing.autopay(account()!.pubkey)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -196,7 +198,7 @@ export default function Account() {
|
||||
isUpdate={card().kind !== "not_set_up"}
|
||||
onClose={() => {
|
||||
setCardModalOpen(false)
|
||||
billing.refetch()
|
||||
void billing.autopay(account()!.pubkey)
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
|
||||
Reference in New Issue
Block a user