diff --git a/backend/src/api.rs b/backend/src/api.rs index 9bc5ad1..c9e21aa 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -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), diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 9ed1ff7..65a6a54 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -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 { + pub fn current(tenant: &Tenant) -> Option { Self::at(tenant, chrono::Utc::now().timestamp()) } diff --git a/backend/src/query.rs b/backend/src/query.rs index 2f00571..84e6fa0 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -151,6 +151,20 @@ pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result Result> { + 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> { diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index 7ef0ca9..c37bb54 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -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>, + AuthedPubkey(auth): AuthedPubkey, + Path(pubkey): Path, +) -> 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>, + AuthedPubkey(auth): AuthedPubkey, + Path(pubkey): Path, +) -> 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, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2ac6cac..1072afd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -259,6 +259,18 @@ export function listTenantInvoices(pubkey: string) { return callApi("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("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("GET", `/tenants/${pubkey}/invoices/draft/items`) +} + export function updateTenant(pubkey: string, input: UpdateTenantInput) { return callApi("PUT", `/tenants/${pubkey}`, input) } diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts index b654ff4..dd772d6 100644 --- a/frontend/src/lib/billing.ts +++ b/frontend/src/lib/billing.ts @@ -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 diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts index c6514a7..f7e8a8b 100644 --- a/frontend/src/lib/state.ts +++ b/frontend/src/lib/state.ts @@ -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 diff --git a/frontend/src/lib/useInvoicePdf.ts b/frontend/src/lib/useInvoicePdf.ts index 3dc422f..b0e73ca 100644 --- a/frontend/src/lib/useInvoicePdf.ts +++ b/frontend/src/lib/useInvoicePdf.ts @@ -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) { 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 diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index ca11f02..2a1ed49 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -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 = { @@ -17,6 +17,7 @@ const methodLabels: Record = { } const invoiceStatusStyles: Record = { + 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() { - 0} fallback={

No invoices yet.

}> + 0 || billing.draftInvoice()} fallback={

No invoices yet.

}>
    + {/* Draft: this period's in-progress charges, not yet a real invoice. */} + + {(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 ( +
  • +
    +
    +
    + ${(draft().amount / 100).toFixed(2)} + + draft + +
    + +

    {periodLabel()}

    +
    +

    Charges accruing this period. You'll be invoiced once a balance is due.

    +
    +
    + +
    +
    +
  • + ) + }} +
    {(invoice) => { const status = () => invoiceStatus(invoice)