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>,
|
||||
|
||||
Reference in New Issue
Block a user