forked from coracle/caravel
Void unattached invoice items when churning a tenant
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -133,6 +133,7 @@ export type InvoiceItem = {
|
||||
amount: number
|
||||
description: string
|
||||
created_at: number
|
||||
voided_at: number | null
|
||||
}
|
||||
|
||||
export type Bolt11 = {
|
||||
|
||||
Reference in New Issue
Block a user