Add draft invoices
This commit is contained in:
+7
-2
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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