From b4eba5a0d55ec477be3d9a2557a29dece22db1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:13:48 +0100 Subject: [PATCH] Tax fixes (#4435) * Only update the PaymentMethod ID if not using placeholder ID * comment * Create Anrok transactions for all charges * Fix comment * Prefer using payment method's address rather than customer address * chore: query cache, clippy, fmt * Retrieve stripe address from PM * chore: query cache, clippy, fmt * fmt * bring back the query cache * Better address retrieval for updating tax amounts, always update tax_last_updated * chore: query cache, clippy, fmt * Don't set PM in ctoken interactive session for new PIs --- ...8a7edd9a97a125aee3142904bb0a057a82d98.json | 22 ++++ ...848690959f9ef946c8e7cf730e03cb92563b0.json | 34 ++++++ ...d976d837777e31abee642df80cb1460ac0845.json | 32 ++++++ ...e23dbc90c968f4342ec108e65fe3c455466d3.json | 17 +++ ...6eb5a74e238e352fe7745f98ce264074adde5.json | 22 ++++ ...4cf929ee73f620507ac868b28afc08d8ed6f5.json | 22 ++++ apps/labrinth/src/queue/billing.rs | 108 +++++++++--------- .../src/routes/internal/billing/payments.rs | 7 +- 8 files changed, 209 insertions(+), 55 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-263c6c447ee7070e0b503ffe3e98a7edd9a97a125aee3142904bb0a057a82d98.json create mode 100644 apps/labrinth/.sqlx/query-2900514e9ff89519201700d3a96848690959f9ef946c8e7cf730e03cb92563b0.json create mode 100644 apps/labrinth/.sqlx/query-382e4cceddc6b51f925e467b1e3d976d837777e31abee642df80cb1460ac0845.json create mode 100644 apps/labrinth/.sqlx/query-5184ad30a3a276892248037d43de23dbc90c968f4342ec108e65fe3c455466d3.json create mode 100644 apps/labrinth/.sqlx/query-c0d0cd9a1a80e6aa2f82decd9b46eb5a74e238e352fe7745f98ce264074adde5.json create mode 100644 apps/labrinth/.sqlx/query-f7d3a57e0a0bc81392d712a08944cf929ee73f620507ac868b28afc08d8ed6f5.json diff --git a/apps/labrinth/.sqlx/query-263c6c447ee7070e0b503ffe3e98a7edd9a97a125aee3142904bb0a057a82d98.json b/apps/labrinth/.sqlx/query-263c6c447ee7070e0b503ffe3e98a7edd9a97a125aee3142904bb0a057a82d98.json new file mode 100644 index 00000000..a5bc8645 --- /dev/null +++ b/apps/labrinth/.sqlx/query-263c6c447ee7070e0b503ffe3e98a7edd9a97a125aee3142904bb0a057a82d98.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM organizations o\n JOIN teams t ON o.team_id = t.id\n JOIN team_members tm ON t.id = tm.team_id\n WHERE tm.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "263c6c447ee7070e0b503ffe3e98a7edd9a97a125aee3142904bb0a057a82d98" +} diff --git a/apps/labrinth/.sqlx/query-2900514e9ff89519201700d3a96848690959f9ef946c8e7cf730e03cb92563b0.json b/apps/labrinth/.sqlx/query-2900514e9ff89519201700d3a96848690959f9ef946c8e7cf730e03cb92563b0.json new file mode 100644 index 00000000..7c51140d --- /dev/null +++ b/apps/labrinth/.sqlx/query-2900514e9ff89519201700d3a96848690959f9ef946c8e7cf730e03cb92563b0.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT projects, organizations, collections\n FROM user_limits\n WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "projects", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "organizations", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "collections", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "2900514e9ff89519201700d3a96848690959f9ef946c8e7cf730e03cb92563b0" +} diff --git a/apps/labrinth/.sqlx/query-382e4cceddc6b51f925e467b1e3d976d837777e31abee642df80cb1460ac0845.json b/apps/labrinth/.sqlx/query-382e4cceddc6b51f925e467b1e3d976d837777e31abee642df80cb1460ac0845.json new file mode 100644 index 00000000..90a7de1c --- /dev/null +++ b/apps/labrinth/.sqlx/query-382e4cceddc6b51f925e467b1e3d976d837777e31abee642df80cb1460ac0845.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT projects, organizations, collections\n FROM user_limits\n WHERE user_id IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "projects", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "organizations", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "collections", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "382e4cceddc6b51f925e467b1e3d976d837777e31abee642df80cb1460ac0845" +} diff --git a/apps/labrinth/.sqlx/query-5184ad30a3a276892248037d43de23dbc90c968f4342ec108e65fe3c455466d3.json b/apps/labrinth/.sqlx/query-5184ad30a3a276892248037d43de23dbc90c968f4342ec108e65fe3c455466d3.json new file mode 100644 index 00000000..cd721147 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5184ad30a3a276892248037d43de23dbc90c968f4342ec108e65fe3c455466d3.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_limits (user_id, projects, organizations, collections)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id) DO UPDATE\n SET projects = EXCLUDED.projects,\n organizations = EXCLUDED.organizations,\n collections = EXCLUDED.collections", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "5184ad30a3a276892248037d43de23dbc90c968f4342ec108e65fe3c455466d3" +} diff --git a/apps/labrinth/.sqlx/query-c0d0cd9a1a80e6aa2f82decd9b46eb5a74e238e352fe7745f98ce264074adde5.json b/apps/labrinth/.sqlx/query-c0d0cd9a1a80e6aa2f82decd9b46eb5a74e238e352fe7745f98ce264074adde5.json new file mode 100644 index 00000000..111aa412 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c0d0cd9a1a80e6aa2f82decd9b46eb5a74e238e352fe7745f98ce264074adde5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM collections\n WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c0d0cd9a1a80e6aa2f82decd9b46eb5a74e238e352fe7745f98ce264074adde5" +} diff --git a/apps/labrinth/.sqlx/query-f7d3a57e0a0bc81392d712a08944cf929ee73f620507ac868b28afc08d8ed6f5.json b/apps/labrinth/.sqlx/query-f7d3a57e0a0bc81392d712a08944cf929ee73f620507ac868b28afc08d8ed6f5.json new file mode 100644 index 00000000..97f44543 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f7d3a57e0a0bc81392d712a08944cf929ee73f620507ac868b28afc08d8ed6f5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mods m\n JOIN teams t ON m.team_id = t.id\n JOIN team_members tm ON t.id = tm.team_id\n WHERE tm.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f7d3a57e0a0bc81392d712a08944cf929ee73f620507ac868b28afc08d8ed6f5" +} diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs index 687a2d54..4cff820c 100644 --- a/apps/labrinth/src/queue/billing.rs +++ b/apps/labrinth/src/queue/billing.rs @@ -24,6 +24,7 @@ use crate::util::archon::ArchonClient; use crate::util::archon::{CreateServerRequest, Specs}; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; +use futures::FutureExt; use futures::stream::{FuturesUnordered, StreamExt}; use sqlx::PgPool; use std::collections::HashSet; @@ -115,7 +116,6 @@ pub async fn index_subscriptions( let redis_ref = redis.clone(); struct ProcessedCharge { - charge: DBCharge, new_tax_amount: i64, product_name: String, } @@ -128,7 +128,9 @@ pub async fn index_subscriptions( let pg = pg_ref.clone(); let redis = redis_ref.clone(); - async move { + let charge_clone = charge.clone(); + + let op_fut = async move { let tax_id = DBProductsTaxIdentifier::get_price( charge.price_id, &pg, @@ -148,38 +150,6 @@ pub async fn index_subscriptions( })?; let stripe_address = 'a: { - let stripe_id: stripe::PaymentIntentId = charge - .payment_platform_id - .as_ref() - .and_then(|x| x.parse().ok()) - .ok_or_else(|| { - ApiError::InvalidInput( - "Charge has no payment platform ID" - .to_owned(), - ) - })?; - - // Attempt retrieving the address via the payment intent's payment method - - let pi = stripe::PaymentIntent::retrieve( - &stripe_client, - &stripe_id, - &["payment_method"], - ) - .await?; - - let pi_stripe_address = pi - .payment_method - .and_then(|x| x.into_object()) - .and_then(|x| x.billing_details.address); - - match pi_stripe_address { - Some(address) => break 'a address, - None => { - warn!("PaymentMethod had no address"); - } - }; - let stripe_customer_id = DBUser::get_id(charge.user_id, &pg, &redis) .await? @@ -197,23 +167,48 @@ pub async fn index_subscriptions( ) }, ) + })? + .parse() + .map_err(|_| { + ApiError::InvalidInput( + "User Stripe customer ID was invalid".to_owned(), + ) })?; - let customer_id = stripe_customer_id.parse().map_err(|e| ApiError::InvalidInput(format!("Charge's Stripe customer ID was invalid ({e})")))?; - let customer = stripe::Customer::retrieve( &stripe_client, - &customer_id, - &[], + &stripe_customer_id, + &["invoice_settings.default_payment_method"], ) .await?; - customer.address.ok_or_else(|| { + let payment_method = customer + .invoice_settings + .and_then(|x| { + x.default_payment_method.and_then(|x| x.into_object()) + }) + .ok_or_else(|| { ApiError::InvalidInput( - "Stripe customer had no address" - .to_owned(), + "Customer has no default payment method!".to_string(), ) - })? + })?; + + let stripe_address = payment_method.billing_details.address; + + // Attempt the default payment method's address first, then the customer's address. + match stripe_address { + Some(address) => break 'a address, + None => { + warn!("PaymentMethod had no address"); + } + }; + + customer.address.ok_or_else(|| { + ApiError::InvalidInput( + "Couldn't get an address for the Stripe customer" + .to_owned(), + ) + })? }; let customer_address = @@ -238,28 +233,29 @@ pub async fn index_subscriptions( Result::::Ok( ProcessedCharge { - charge, new_tax_amount: tax_amount, product_name: product .name .unwrap_or_else(|| "Modrinth".to_owned()), }, ) - } + }; + + op_fut.then(move |res| async move { (charge_clone, res) }) }) .collect::>(); while let Some(result) = futures.next().await { processed_charges += 1; - match result { - Ok(ProcessedCharge { + let mut charge = match result { + ( mut charge, - new_tax_amount, - product_name, - }) => { - charge.tax_last_updated = Some(Utc::now()); - + Ok(ProcessedCharge { + new_tax_amount, + product_name, + }), + ) => { if new_tax_amount != charge.tax_amount { // The price of the subscription has changed, we need to insert a notification // for this. @@ -293,12 +289,16 @@ pub async fn index_subscriptions( charge.tax_amount = new_tax_amount; } - charge.upsert(&mut txn).await?; + charge } - Err(error) => { + (charge, Err(error)) => { error!(%error, "Error indexing tax amount on charge"); + charge } - } + }; + + charge.tax_last_updated = Some(Utc::now()); + charge.upsert(&mut txn).await?; } txn.commit().await?; diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs index ec5819a8..1f9149a8 100644 --- a/apps/labrinth/src/routes/internal/billing/payments.rs +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -575,7 +575,12 @@ pub async fn create_or_update_payment_intent( intent.customer = Some(customer_id); intent.metadata = Some(metadata); intent.receipt_email = user.email.as_deref(); - intent.payment_method = Some(payment_method.id.clone()); + if let PaymentSession::Interactive { + payment_request_type: PaymentRequestType::PaymentMethod { .. }, + } = &payment_session + { + intent.payment_method = Some(payment_method.id.clone()); + } payment_session.set_payment_intent_session_options(&mut intent);