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,
|
amount INTEGER NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
voided_at INTEGER,
|
||||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
|
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
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_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.
|
-- 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);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ impl Billing {
|
|||||||
amount,
|
amount,
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
created_at: activity.created_at,
|
created_at: activity.created_at,
|
||||||
|
voided_at: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@ impl Billing {
|
|||||||
amount,
|
amount,
|
||||||
description,
|
description,
|
||||||
created_at: activity.created_at,
|
created_at: activity.created_at,
|
||||||
|
voided_at: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +293,7 @@ impl Billing {
|
|||||||
amount: plan.amount,
|
amount: plan.amount,
|
||||||
description: "Subscription renewal".to_string(),
|
description: "Subscription renewal".to_string(),
|
||||||
created_at: period.start,
|
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| {
|
with_tx(async |tx| {
|
||||||
let total = sqlx::query_scalar::<_, i64>(
|
let total = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
|
"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)
|
.bind(&tenant.pubkey)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
@@ -369,7 +369,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE invoice_item SET invoice_id = ?
|
"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(&invoice.id)
|
||||||
.bind(&tenant.pubkey)
|
.bind(&tenant.pubkey)
|
||||||
@@ -665,8 +665,8 @@ async fn insert_invoice_item_tx(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO invoice_item
|
"INSERT INTO invoice_item
|
||||||
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan_id, amount, description, created_at)
|
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan_id, amount, description, created_at, voided_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&item.id)
|
.bind(&item.id)
|
||||||
.bind(&item.invoice_id)
|
.bind(&item.invoice_id)
|
||||||
@@ -677,6 +677,7 @@ async fn insert_invoice_item_tx(
|
|||||||
.bind(item.amount)
|
.bind(item.amount)
|
||||||
.bind(&item.description)
|
.bind(&item.description)
|
||||||
.bind(item.created_at)
|
.bind(item.created_at)
|
||||||
|
.bind(item.voided_at)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -704,8 +705,12 @@ async fn mark_invoice_paid_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Void all of a tenant's open invoices, forgiving the balance — used when a
|
/// Void all of a tenant's open invoices and unpaid line items, forgiving the
|
||||||
/// tenant churns or re-activates, so old debt never has to be collected.
|
/// 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(
|
async fn void_open_invoices_tx(
|
||||||
tx: &mut Transaction<'_, Sqlite>,
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
@@ -720,6 +725,20 @@ async fn void_open_invoices_tx(
|
|||||||
.bind(tenant_pubkey)
|
.bind(tenant_pubkey)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ pub struct InvoiceItem {
|
|||||||
pub amount: i64,
|
pub amount: i64,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub created_at: i64,
|
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)]
|
#[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>> {
|
pub async fn list_unbilled_invoice_items(tenant_pubkey: &str) -> Result<Vec<InvoiceItem>> {
|
||||||
Ok(sqlx::query_as::<_, InvoiceItem>(
|
Ok(sqlx::query_as::<_, InvoiceItem>(
|
||||||
"SELECT * FROM invoice_item
|
"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",
|
ORDER BY created_at ASC",
|
||||||
)
|
)
|
||||||
.bind(tenant_pubkey)
|
.bind(tenant_pubkey)
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export type InvoiceItem = {
|
|||||||
amount: number
|
amount: number
|
||||||
description: string
|
description: string
|
||||||
created_at: number
|
created_at: number
|
||||||
|
voided_at: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Bolt11 = {
|
export type Bolt11 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user