Add draft invoices

This commit is contained in:
Jon Staab
2026-06-01 17:22:44 -07:00
parent 93bfe8e5a4
commit 08e59e3b40
9 changed files with 166 additions and 13 deletions
+12
View File
@@ -259,6 +259,18 @@ export function listTenantInvoices(pubkey: string) {
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
}
// 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) {
return callApi<undefined, Invoice | null>("GET", `/tenants/${pubkey}/invoices/draft`)
}
// The draft's line items, fetched by tenant since its sentinel id has no row in
// the per-invoice items endpoint. Lets the draft render an itemized PDF.
export function listDraftInvoiceItems(pubkey: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/tenants/${pubkey}/invoices/draft/items`)
}
export function updateTenant(pubkey: string, input: UpdateTenantInput) {
return callApi<UpdateTenantInput, Tenant>("PUT", `/tenants/${pubkey}`, input)
}
+7 -3
View File
@@ -1,6 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
import { billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
import { billingDraftInvoice, 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
@@ -28,6 +28,10 @@ export function useBillingStatus() {
const tenant = () => billingTenant()
const invoices = () => billingInvoices() ?? []
// The current period's in-progress bill (outstanding items not yet cut into a
// real invoice), or undefined when nothing is due. Shown as a "draft" row.
const draftInvoice = () => billingDraftInvoice() ?? undefined
const openInvoices = createMemo(() =>
invoices()
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
@@ -46,9 +50,9 @@ export function useBillingStatus() {
})
})
const loading = () => billingTenant.loading || billingInvoices.loading
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
return { tenant, invoices, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
}
// Pure priority selector: returns the single highest-priority billing prompt to
+3 -1
View File
@@ -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 { createTenant, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
export type UnsignedEvent = {
kind: number
@@ -69,11 +69,13 @@ const billingKey = () => billingPubkey()
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 const [billingDraftInvoice, { refetch: refetchBillingDraftInvoice }] = createResource(billingKey, getDraftInvoice)
export function refetchBilling() {
void refetchBillingTenant()
void refetchBillingInvoices()
void refetchBillingRelays()
void refetchBillingDraftInvoice()
}
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
+8 -3
View File
@@ -25,11 +25,14 @@ function escapeHtml(value: string) {
export function useInvoicePdf() {
const [printing, setPrinting] = createSignal(false)
async function printInvoice(invoice: Invoice) {
// `loadItems` overrides how line items are fetched — used by the draft invoice,
// whose sentinel id has no row in the per-invoice items endpoint.
async function printInvoice(invoice: Invoice, loadItems?: () => Promise<InvoiceItem[]>) {
if (printing()) return
setPrinting(true)
try {
const items = await listInvoiceItems(invoice.id).catch(() => [] as InvoiceItem[])
const fetchItems = loadItems ?? (() => listInvoiceItems(invoice.id))
const items = await fetchItems().catch(() => [] as InvoiceItem[])
let sats: number | undefined
let qrDataUrl: string | undefined
@@ -56,7 +59,9 @@ export function useInvoicePdf() {
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
const { invoice, items, sats, qrDataUrl } = opts
const status = invoiceStatus(invoice)
// The draft invoice carries the sentinel id and no lifecycle timestamps, so
// invoiceStatus would read it as "open" — label it "draft" explicitly.
const status = invoice.id === "draft" ? "draft" : invoiceStatus(invoice)
const rows = items.length
? items
+41 -2
View File
@@ -7,7 +7,7 @@ import { useInvoicePdf } from "@/lib/useInvoicePdf"
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
import PaymentSetupCard from "@/components/PaymentSetupCard"
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
import { invoiceStatus, type Invoice } from "@/lib/api"
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
const methodLabels: Record<string, string> = {
@@ -17,6 +17,7 @@ const methodLabels: Record<string, string> = {
}
const invoiceStatusStyles: Record<string, string> = {
draft: "bg-blue-50 text-blue-700 border-blue-200",
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
@@ -163,8 +164,46 @@ export default function Account() {
<LoadingState message="Loading invoices..." paddingClass="py-8" />
</Show>
<Show when={!invoicesLoading()}>
<Show when={billing.invoices().length > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<Show when={billing.invoices().length > 0 || billing.draftInvoice()} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<ul class="space-y-3">
{/* Draft: this period's in-progress charges, not yet a real invoice. */}
<Show when={billing.draftInvoice()}>
{(draft) => {
const periodLabel = () => {
const start = new Date(draft().period_start * 1000)
const end = new Date(draft().period_end * 1000)
return `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
}
return (
<li class="rounded-lg border border-dashed border-gray-300 p-4 text-sm">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">${(draft().amount / 100).toFixed(2)}</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles.draft}`}>
draft
</span>
</div>
<Show when={draft().period_start && draft().period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
<p class="text-xs text-gray-400 mt-1">Charges accruing this period. You'll be invoiced once a balance is due.</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => void printInvoice(draft(), () => listDraftInvoiceItems(draft().tenant_pubkey))}
disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
)
}}
</Show>
<For each={billing.invoices()}>
{(invoice) => {
const status = () => invoiceStatus(invoice)