Void unattached invoice items when churning a tenant

This commit is contained in:
Jon Staab
2026-06-03 11:05:00 -07:00
parent 4dc8ea942d
commit ffb1491f00
6 changed files with 36 additions and 8 deletions
+2 -1
View File
@@ -69,6 +69,7 @@ CREATE TABLE IF NOT EXISTS invoice_item (
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
voided_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
@@ -116,7 +117,7 @@ CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_a
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL AND voided_at IS NULL;
-- At most one line item per billable activity to ensure no double-billing.
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
+3
View File
@@ -203,6 +203,7 @@ impl Billing {
amount,
description: description.to_string(),
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -249,6 +250,7 @@ impl Billing {
amount,
description,
created_at: activity.created_at,
voided_at: None,
}))
}
@@ -291,6 +293,7 @@ impl Billing {
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period.start,
voided_at: None,
});
}
+25 -6
View File
@@ -355,7 +355,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
with_tx(async |tx| {
let total = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&tenant.pubkey)
.fetch_one(&mut **tx)
@@ -369,7 +369,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
sqlx::query(
"UPDATE invoice_item SET invoice_id = ?
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&invoice.id)
.bind(&tenant.pubkey)
@@ -665,8 +665,8 @@ async fn insert_invoice_item_tx(
) -> Result<()> {
sqlx::query(
"INSERT INTO invoice_item
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan_id, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan_id, amount, description, created_at, voided_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice_id)
@@ -677,6 +677,7 @@ async fn insert_invoice_item_tx(
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.bind(item.voided_at)
.execute(&mut **tx)
.await?;
Ok(())
@@ -704,8 +705,12 @@ async fn mark_invoice_paid_tx(
Ok(())
}
/// Void all of a tenant's open invoices, forgiving the balance — used when a
/// tenant churns or re-activates, so old debt never has to be collected.
/// Void all of a tenant's open invoices and unpaid line items, forgiving the
/// balance — used when a tenant churns or re-activates, so old debt never has to
/// be collected. Voiding the items too (both outstanding ones and those on the
/// just-voided invoices) keeps a credit from bleeding into a future invoice and
/// lets a re-billed period start from a clean ledger. Items on a paid invoice are
/// left untouched.
async fn void_open_invoices_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant_pubkey: &str,
@@ -720,6 +725,20 @@ async fn void_open_invoices_tx(
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
// Run after voiding the invoices above, so the `paid_at IS NULL` subquery
// catches their now-voided items along with the still-outstanding ones.
sqlx::query(
"UPDATE invoice_item SET voided_at = ?
WHERE tenant_pubkey = ? AND voided_at IS NULL
AND (invoice_id IS NULL OR invoice_id IN (
SELECT id FROM invoice WHERE paid_at IS NULL
))",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
}
+4
View File
@@ -154,6 +154,10 @@ pub struct InvoiceItem {
pub amount: i64,
pub description: String,
pub created_at: i64,
/// Set when the item is forgiven — the tenant churned or reactivated — so it
/// is never billed or carried into a later invoice; `None` while live. Applies
/// whether or not the item has been claimed onto an invoice.
pub voided_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
+1 -1
View File
@@ -165,7 +165,7 @@ pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<Invo
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
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
+1
View File
@@ -133,6 +133,7 @@ export type InvoiceItem = {
amount: number
description: string
created_at: number
voided_at: number | null
}
export type Bolt11 = {