diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index b84e129..c1ca552 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -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); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 791d44b..4c719c8 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -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, }); } diff --git a/backend/src/command.rs b/backend/src/command.rs index 6502a4a..16366e1 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -355,7 +355,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result( "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 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(()) } diff --git a/backend/src/models.rs b/backend/src/models.rs index 450bbc1..fc521d9 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -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, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/src/query.rs b/backend/src/query.rs index 868726d..f063534 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -165,7 +165,7 @@ 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 + WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL ORDER BY created_at ASC", ) .bind(tenant_pubkey) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 07b96dd..4b4e6a8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -133,6 +133,7 @@ export type InvoiceItem = { amount: number description: string created_at: number + voided_at: number | null } export type Bolt11 = {