Add draft invoices
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s

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
+7 -2
View File
@@ -39,8 +39,8 @@ use crate::routes::relays::{
list_relays, reactivate_relay, update_relay,
};
use crate::routes::tenants::{
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
list_tenants, update_tenant,
create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items,
list_tenant_invoices, list_tenant_relays, list_tenants, update_tenant,
};
use crate::stripe::Stripe;
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
@@ -72,6 +72,11 @@ impl Api {
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/tenants/:pubkey/invoices/draft", get(get_draft_invoice))
.route(
"/tenants/:pubkey/invoices/draft/items",
get(list_draft_invoice_items),
)
.route(
"/tenants/:pubkey/stripe/session",
get(create_stripe_session),
+1 -1
View File
@@ -547,7 +547,7 @@ pub struct BillingPeriod {
impl BillingPeriod {
/// The period containing `chrono::Utc::now()` for `tenant`. `None` when the
/// tenant has no `billing_anchor` yet — i.e. no billable activity has been seen.
fn current(tenant: &Tenant) -> Option<Self> {
pub fn current(tenant: &Tenant) -> Option<Self> {
Self::at(tenant, chrono::Utc::now().timestamp())
}
+14
View File
@@ -151,6 +151,20 @@ pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<Invo
.await?)
}
/// A tenant's outstanding line items — created but not yet claimed onto an
/// invoice — oldest first. These are exactly what `create_invoice` would bill,
/// and what a draft invoice presents before the balance is cut.
pub async fn list_unbilled_invoice_items(tenant_pubkey: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// A tenant's open invoices — neither paid nor voided — oldest first. Dunning
/// retries each and treats the oldest one's `created_at` as the grace-period start.
pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
+73 -1
View File
@@ -8,7 +8,8 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::billing::BillingPeriod;
use crate::models::{Invoice, Tenant};
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
@@ -178,6 +179,77 @@ pub async fn list_tenant_invoices(
ok(invoices)
}
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
/// outstanding line items for the current period (reconciled first so it reflects
/// the latest activity). It mirrors what `create_invoice` would bill once the
/// balance turns positive. `null` when the tenant has no billing anchor yet or
/// nothing is outstanding. The id is a fixed `draft` sentinel and the lifecycle
/// fields are empty — it isn't persisted or payable, so the UI renders it
/// read-only with a `draft` status.
pub async fn get_draft_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing
.reconcile_subscription(&tenant, false)
.await
.map_err(internal)?;
// Re-read so the draft sees a billing anchor that reconcile may have just set
// (it persists the anchor but mutates only its own clone).
let tenant = api.get_tenant_or_404(&pubkey).await?;
let draft = match BillingPeriod::current(&tenant) {
Some(period) => {
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
if items.is_empty() {
None
} else {
Some(Invoice {
id: "draft".to_string(),
amount: items.iter().map(|item| item.amount).sum(),
tenant_pubkey: tenant.pubkey,
period_start: period.start,
period_end: period.end,
created_at: Utc::now().timestamp(),
paid_at: None,
voided_at: None,
notified_at: None,
method: None,
})
}
}
None => None,
};
ok(draft)
}
/// The outstanding line items behind a tenant's draft invoice — the current
/// period's not-yet-billed charges. Mirrors `list_invoice_items` for a real
/// invoice (the draft's sentinel id can't be looked up there) so the UI can
/// itemize the draft in the same PDF.
pub async fn list_draft_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
ok(items)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
+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)