forked from coracle/caravel
Add draft invoices
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user