Files
Rocketmc/apps/labrinth/src/database/models/product_item.rs
François-Xavier Talbot 4228a193e9 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
2025-09-25 11:29:29 +00:00

403 lines
12 KiB
Rust

use crate::database::models::{
DBProductId, DBProductPriceId, DatabaseError, product_item,
};
use crate::database::redis::RedisPool;
use crate::models::billing::{Price, ProductMetadata};
use dashmap::DashMap;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use std::convert::TryInto;
const PRODUCTS_NAMESPACE: &str = "products";
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:expr) => {
sqlx::query_as!(
ProductQueryResult,
r#"
SELECT products.id, products.metadata, products.unitary, products.name
FROM products
"#
+ $predicate,
$param
)
};
}
impl TryFrom<ProductQueryResult> for DBProduct {
type Error = serde_json::Error;
fn try_from(r: ProductQueryResult) -> Result<Self, Self::Error> {
Ok(DBProduct {
id: DBProductId(r.id),
metadata: serde_json::from_value(r.metadata)?,
unitary: r.unitary,
name: r.name,
})
}
}
impl DBProduct {
pub async fn get(
id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBProduct>, DatabaseError> {
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,
) -> Result<Vec<Self>, DatabaseError>
where
E: sqlx::PgExecutor<'a>,
{
let maybe_row = select_products_with_predicate!(
"WHERE metadata ->> 'type' = $1",
r#type
)
.fetch_all(exec)
.await?;
maybe_row
.into_iter()
.map(|r| r.try_into().map_err(Into::into))
.collect()
}
pub async fn get_many(
ids: &[DBProductId],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProduct>, DatabaseError> {
let ids = ids.iter().map(|id| id.0).collect_vec();
let ids_ref: &[i64] = &ids;
let results = select_products_with_predicate!(
"WHERE id = ANY($1::bigint[])",
ids_ref
)
.fetch_all(exec)
.await?;
Ok(results
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProduct>, DatabaseError> {
let one = 1;
let results = select_products_with_predicate!("WHERE 1 = $1", one)
.fetch_all(exec)
.await?;
Ok(results
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
}
#[derive(Deserialize, Serialize)]
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>,
}
impl QueryProductWithPrices {
/// Lists products with at least one public price.
pub async fn list_purchaseable<'a, E>(
exec: E,
redis: &RedisPool,
) -> Result<Vec<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let mut redis = redis.connect().await?;
let res: Option<Vec<QueryProductWithPrices>> = redis
.get_deserialized_from_json(PRODUCTS_NAMESPACE, "all")
.await?;
if let Some(res) = res {
return Ok(res);
}
let all_products = product_item::DBProduct::get_all(exec).await?;
let prices =
product_item::DBProductPrice::get_all_public_products_prices(
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
exec,
)
.await?;
let products = all_products
.into_iter()
.filter_map(|x| {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?
.into_iter()
.map(|x| DBProductPrice {
id: x.id,
product_id: x.product_id,
prices: x.prices,
currency_code: x.currency_code,
})
.collect(),
unitary: x.unitary,
})
})
.collect::<Vec<_>>();
redis
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
.await?;
Ok(products)
}
pub async fn list_by_product_type<'a, E>(
exec: E,
r#type: &str,
) -> Result<Vec<Self>, DatabaseError>
where
E: sqlx::PgExecutor<'a> + Copy,
{
let all_products = DBProduct::get_by_type(exec, r#type).await?;
let prices = DBProductPrice::get_all_products_prices(
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
exec,
)
.await?;
let products = all_products
.into_iter()
.filter_map(|x| {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?
.into_iter()
.map(|x| DBProductPrice {
id: x.id,
product_id: x.product_id,
prices: x.prices,
currency_code: x.currency_code,
})
.collect(),
unitary: x.unitary,
})
})
.collect::<Vec<_>>();
Ok(products)
}
}
#[derive(Deserialize, Serialize)]
pub struct DBProductPrice {
pub id: DBProductPriceId,
pub product_id: DBProductId,
pub prices: Price,
pub currency_code: String,
}
struct ProductPriceQueryResult {
id: i64,
product_id: i64,
prices: serde_json::Value,
currency_code: String,
}
macro_rules! select_prices_with_predicate {
($predicate:tt, $param1:ident) => {
select_prices_with_predicate!($predicate, $param1, )
};
($predicate:tt, $($param:ident,)+) => {
sqlx::query_as!(
ProductPriceQueryResult,
r#"
SELECT id, product_id, prices, currency_code
FROM products_prices
"#
+ $predicate,
$($param),+
)
};
}
impl TryFrom<ProductPriceQueryResult> for DBProductPrice {
type Error = serde_json::Error;
fn try_from(r: ProductPriceQueryResult) -> Result<Self, Self::Error> {
Ok(DBProductPrice {
id: DBProductPriceId(r.id),
product_id: DBProductId(r.product_id),
prices: serde_json::from_value(r.prices)?,
currency_code: r.currency_code,
})
}
}
impl DBProductPrice {
pub async fn get(
id: DBProductPriceId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBProductPrice>, DatabaseError> {
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
}
pub async fn get_many(
ids: &[DBProductPriceId],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProductPrice>, DatabaseError> {
let ids = ids.iter().map(|id| id.0).collect_vec();
let ids_ref: &[i64] = &ids;
let results = select_prices_with_predicate!(
"WHERE id = ANY($1::bigint[])",
ids_ref
)
.fetch_all(exec)
.await?;
Ok(results
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_all_product_prices(
product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProductPrice>, DatabaseError> {
let res = Self::get_all_products_prices(&[product_id], exec).await?;
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
}
pub async fn get_all_public_product_prices(
product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProductPrice>, DatabaseError> {
let res =
Self::get_all_public_products_prices(&[product_id], exec).await?;
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
}
/// Gets all public prices for the given products. If a product has no public price,
/// it won't be included in the resulting map.
pub async fn get_all_public_products_prices(
product_ids: &[DBProductId],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
Self::get_all_products_prices_with_visibility(
product_ids,
Some(true),
exec,
)
.await
}
pub async fn get_all_products_prices(
product_ids: &[DBProductId],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
Self::get_all_products_prices_with_visibility(product_ids, None, exec)
.await
}
async fn get_all_products_prices_with_visibility(
product_ids: &[DBProductId],
public_filter: Option<bool>,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
let ids = product_ids.iter().map(|id| id.0).collect_vec();
let ids_ref: &[i64] = &ids;
use futures_util::TryStreamExt;
let predicate = |acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
if let Ok(item) = <ProductPriceQueryResult as TryInto<
DBProductPrice,
>>::try_into(x)
{
acc.entry(item.product_id).or_default().push(item);
}
async move { Ok(acc) }
};
let prices = match public_filter {
None => {
select_prices_with_predicate!(
"WHERE product_id = ANY($1::bigint[])",
ids_ref,
)
.fetch(exec)
.try_fold(DashMap::new(), predicate)
.await?
}
Some(public) => {
select_prices_with_predicate!(
"WHERE product_id = ANY($1::bigint[]) AND public = $2",
ids_ref,
public,
)
.fetch(exec)
.try_fold(DashMap::new(), predicate)
.await?
}
};
Ok(prices)
}
}