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>,