Charge tax on products (#4361)

* Initial Anrok integration

* Query cache, fmt, clippy

* Fmt

* Use payment intent function in edit_subscription

* Attach Anrok client, use payments in index_billing

* Integrate Anrok with refunds

* Bug fixes

* More bugfixes

* Fix resubscriptions

* Medal promotion bugfixes

* Use stripe metadata constants everywhere

* Pre-fill values in products_tax_identifiers

* Cleanup billing route module

* Cleanup

* Email notification for tax charge

* Don't charge tax on users which haven't been notified of tax change

* Fix taxnotification.amount templates

* Update .env.docker-compose

* Update .env.local

* Clippy

* Fmt

* Query cache

* Periodically update tax amount on upcoming charges

* Fix queries

* Skip indexing tax amount on charges if no charges to process

* chore: query cache, clippy, fmt

* Fix a lot of things

* Remove test code

* chore: query cache, clippy, fmt

* Fix money formatting

* Fix conflicts

* Extra documentation, handle tax association properly

* Track loss in tax drift

* chore: query cache, clippy, fmt

* Add subscription.id variable

* chore: query cache, clippy, fmt

* chore: query cache, clippy, fmt
This commit is contained in:
François-Xavier Talbot
2025-09-25 12:29:29 +01:00
committed by GitHub
parent 47020f34b6
commit 4228a193e9
44 changed files with 3438 additions and 1330 deletions

View File

@@ -7,6 +7,7 @@ use crate::models::billing::{
use chrono::{DateTime, Utc};
use std::convert::{TryFrom, TryInto};
#[derive(Clone)]
pub struct DBCharge {
pub id: DBChargeId,
pub user_id: DBUserId,
@@ -26,8 +27,13 @@ pub struct DBCharge {
pub parent_charge_id: Option<DBChargeId>,
pub tax_amount: i64,
pub tax_platform_id: Option<String>,
pub tax_last_updated: Option<DateTime<Utc>>,
// Net is always in USD
pub net: Option<i64>,
pub tax_drift_loss: Option<i64>,
}
struct ChargeQueryResult {
@@ -45,7 +51,11 @@ struct ChargeQueryResult {
payment_platform: String,
payment_platform_id: Option<String>,
parent_charge_id: Option<i64>,
tax_amount: i64,
tax_platform_id: Option<String>,
tax_last_updated: Option<DateTime<Utc>>,
net: Option<i64>,
tax_drift_loss: Option<i64>,
}
impl TryFrom<ChargeQueryResult> for DBCharge {
@@ -69,7 +79,11 @@ impl TryFrom<ChargeQueryResult> for DBCharge {
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
payment_platform_id: r.payment_platform_id,
parent_charge_id: r.parent_charge_id.map(DBChargeId),
tax_amount: r.tax_amount,
tax_platform_id: r.tax_platform_id,
net: r.net,
tax_last_updated: r.tax_last_updated,
tax_drift_loss: r.tax_drift_loss,
})
}
}
@@ -80,14 +94,16 @@ macro_rules! select_charges_with_predicate {
ChargeQueryResult,
r#"
SELECT
id, user_id, price_id, amount, currency_code, status, due, last_attempt,
charge_type, subscription_id,
charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,
charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
subscription_interval AS "subscription_interval?",
payment_platform,
payment_platform_id AS "payment_platform_id?",
parent_charge_id AS "parent_charge_id?",
net AS "net?"
charges.subscription_interval AS "subscription_interval?",
charges.payment_platform,
charges.payment_platform_id AS "payment_platform_id?",
charges.parent_charge_id AS "parent_charge_id?",
charges.net AS "net?",
charges.tax_last_updated AS "tax_last_updated?",
charges.tax_drift_loss AS "tax_drift_loss?"
FROM charges
"#
+ $predicate,
@@ -103,8 +119,8 @@ impl DBCharge {
) -> Result<DBChargeId, DatabaseError> {
sqlx::query!(
r#"
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (id)
DO UPDATE
SET status = EXCLUDED.status,
@@ -116,10 +132,14 @@ impl DBCharge {
payment_platform_id = EXCLUDED.payment_platform_id,
parent_charge_id = EXCLUDED.parent_charge_id,
net = EXCLUDED.net,
tax_amount = EXCLUDED.tax_amount,
tax_platform_id = EXCLUDED.tax_platform_id,
tax_last_updated = EXCLUDED.tax_last_updated,
price_id = EXCLUDED.price_id,
amount = EXCLUDED.amount,
currency_code = EXCLUDED.currency_code,
charge_type = EXCLUDED.charge_type
charge_type = EXCLUDED.charge_type,
tax_drift_loss = EXCLUDED.tax_drift_loss
"#,
self.id.0,
self.user_id.0,
@@ -136,6 +156,10 @@ impl DBCharge {
self.payment_platform_id.as_deref(),
self.parent_charge_id.map(|x| x.0),
self.net,
self.tax_amount,
self.tax_platform_id.as_deref(),
self.tax_last_updated,
self.tax_drift_loss,
)
.execute(&mut **transaction)
.await?;
@@ -276,6 +300,71 @@ impl DBCharge {
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
/// Returns all charges that need to have their tax amount updated.
///
/// This only selects charges which are:
/// - Open;
/// - Haven't been updated in the last day;
/// - Are due in more than 7 days;
/// - Where the user has an email, because we can't notify users without an email about a price change.
///
/// This also locks the charges.
pub async fn get_updateable_lock(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
limit: i64,
) -> Result<Vec<DBCharge>, DatabaseError> {
let res = select_charges_with_predicate!(
"
INNER JOIN users u ON u.id = charges.user_id
WHERE
status = 'open'
AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'
AND u.email IS NOT NULL
AND due - INTERVAL '7 days' > NOW()
ORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC
FOR NO KEY UPDATE SKIP LOCKED
LIMIT $1
",
limit
)
.fetch_all(exec)
.await?;
Ok(res
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
/// Returns all charges which are missing a tax identifier, that is, are 1. succeeded, 2. have a tax amount and
/// 3. haven't been assigned a tax identifier yet.
///
/// Charges are locked.
pub async fn get_missing_tax_identifier_lock(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
limit: i64,
) -> Result<Vec<DBCharge>, DatabaseError> {
let res = select_charges_with_predicate!(
"
WHERE
status = 'succeeded'
AND tax_platform_id IS NULL
AND tax_amount <> 0
ORDER BY due ASC
FOR NO KEY UPDATE SKIP LOCKED
LIMIT $1
",
limit
)
.fetch_all(exec)
.await?;
Ok(res
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn remove(
id: DBChargeId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -293,3 +382,9 @@ impl DBCharge {
Ok(())
}
}
pub struct CustomerCharge {
pub stripe_customer_id: String,
pub charge: DBCharge,
pub product_tax_id: String,
}

View File

@@ -22,6 +22,7 @@ pub mod pat_item;
pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod products_tax_identifier_item;
pub mod project_item;
pub mod report_item;
pub mod session_item;

View File

@@ -15,20 +15,22 @@ pub struct DBProduct {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
pub name: Option<String>,
}
struct ProductQueryResult {
id: i64,
metadata: serde_json::Value,
unitary: bool,
name: Option<String>,
}
macro_rules! select_products_with_predicate {
($predicate:tt, $param:ident) => {
($predicate:tt, $param:expr) => {
sqlx::query_as!(
ProductQueryResult,
r#"
SELECT id, metadata, unitary
SELECT products.id, products.metadata, products.unitary, products.name
FROM products
"#
+ $predicate,
@@ -45,6 +47,7 @@ impl TryFrom<ProductQueryResult> for DBProduct {
id: DBProductId(r.id),
metadata: serde_json::from_value(r.metadata)?,
unitary: r.unitary,
name: r.name,
})
}
}
@@ -57,6 +60,23 @@ impl DBProduct {
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
}
pub async fn get_price(
id: DBProductPriceId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBProduct>, DatabaseError> {
let maybe_row = select_products_with_predicate!(
"INNER JOIN products_prices pp ON pp.id = $1
WHERE products.id = pp.product_id",
id.0
)
.fetch_optional(exec)
.await?;
maybe_row
.map(|r| r.try_into().map_err(Into::into))
.transpose()
}
pub async fn get_by_type<'a, E>(
exec: E,
r#type: &str,
@@ -116,6 +136,8 @@ pub struct QueryProductWithPrices {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
pub prices: Vec<DBProductPrice>,
}
@@ -152,6 +174,7 @@ impl QueryProductWithPrices {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?
@@ -195,6 +218,7 @@ impl QueryProductWithPrices {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?

View File

@@ -0,0 +1,89 @@
use crate::database::models::ids::{DBProductId, DBProductPriceId};
use crate::models::billing::ProductMetadata;
use crate::routes::ApiError;
pub struct DBProductsTaxIdentifier {
pub id: i32,
pub tax_processor_id: String,
pub product_id: DBProductId,
}
impl DBProductsTaxIdentifier {
pub async fn get_product(
product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<Self>, ApiError> {
let maybe_row = sqlx::query!(
"SELECT * FROM products_tax_identifiers WHERE product_id = $1",
product_id.0,
)
.fetch_optional(exec)
.await?;
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
id: row.id,
tax_processor_id: row.tax_processor_id,
product_id: DBProductId(row.product_id),
}))
}
pub async fn get_price(
price_id: DBProductPriceId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<Self>, ApiError> {
let maybe_row = sqlx::query!(
"
SELECT pti.*
FROM products_prices pp
INNER JOIN products_tax_identifiers pti ON pti.product_id = pp.product_id
WHERE pp.id = $1
",
price_id.0,
)
.fetch_optional(exec)
.await?;
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
id: row.id,
tax_processor_id: row.tax_processor_id,
product_id: DBProductId(row.product_id),
}))
}
}
pub struct ProductInfo {
pub tax_identifier: DBProductsTaxIdentifier,
pub product_metadata: ProductMetadata,
}
pub async fn product_info_by_product_price_id(
product_price_id: DBProductPriceId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<ProductInfo>, ApiError> {
let maybe_row = sqlx::query!(
r#"
SELECT
products_tax_identifiers.*,
products.metadata product_metadata
FROM products_prices
INNER JOIN products ON products.id = products_prices.product_id
INNER JOIN products_tax_identifiers ON products_tax_identifiers.product_id = products.id
WHERE products_prices.id = $1
"#,
product_price_id.0 as i64,
)
.fetch_optional(exec)
.await?;
match maybe_row {
None => Ok(None),
Some(row) => Ok(Some(ProductInfo {
tax_identifier: DBProductsTaxIdentifier {
id: row.id,
tax_processor_id: row.tax_processor_id,
product_id: DBProductId(row.product_id),
},
product_metadata: serde_json::from_value(row.product_metadata)?,
})),
}
}

View File

@@ -2,7 +2,7 @@ use crate::database::models::{
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
};
use crate::models::billing::{
PriceDuration, SubscriptionMetadata, SubscriptionStatus,
PriceDuration, ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
};
use chrono::{DateTime, Utc};
use itertools::Itertools;
@@ -161,3 +161,12 @@ impl DBUserSubscription {
Ok(())
}
}
pub struct SubscriptionWithCharge {
pub subscription_id: DBUserSubscriptionId,
pub user_id: DBUserId,
pub product_metadata: ProductMetadata,
pub amount: i64,
pub tax_amount: i64,
pub due: DateTime<Utc>,
}