You've already forked AstralRinth
forked from didirus/AstralRinth
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:
committed by
GitHub
parent
47020f34b6
commit
4228a193e9
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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)?,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user