From e837d9fa30df9e63f51fa7785c794655a1178b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Tue, 18 Nov 2025 03:36:55 -0800 Subject: [PATCH] Add route to reprocess a refund charge's tax record (#4791) --- apps/labrinth/src/routes/internal/billing.rs | 188 ++++++++++++++++++- 1 file changed, 184 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 6e6c180e..6c8276e3 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -394,16 +394,196 @@ pub async fn refund_charge( transaction.commit().await?; if let Some(Err(error)) = anrok_result { - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ - "error": "partial_failure", - "description": &format!("This refund was not processed by the tax processing system. It was still processed on Stripe's end. Manual intervention is required to add a tax record for the refund charge. This will not impact the customer. Tax API Error: {error}") - }))); + if let anrok::AnrokError::Conflict(m) = &error + && m.contains("transactionExpectedVersionMismatch") + { + return Err(ApiError::InvalidInput( + "This refund has been processed on Stripe's end, but not on the tax processor's end. The tax transaction has been modified externally since its creation. \ + This is likely caused by a change in nexus for the customer's jurisdiction, which lead to a new tax amount paid by the seller being calculated on the transaction. \ + Manual intervention is required to verify the tax transaction on the platform's end and update the refund's tax transaction record." + .to_owned(), + )); + } else { + return Err(ApiError::InvalidInput(format!( + "This refund has been processed on Stripe's end, but not on the tax processor's end. An unexpected error occurred, preventing the refund transaction from being processed \ + on the tax platform's end. Error: {error}" + ))); + } } } Ok(HttpResponse::NoContent().finish()) } +#[post("charge/{id}/tax/reprocess")] +pub async fn reprocess_charge_tax( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + info: web::Path<(crate::models::ids::ChargeId,)>, + anrok_client: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + let (id,) = info.into_inner(); + + if !user.role.is_admin() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to reprocess a tax transaction!" + .to_string(), + )); + } + + let mut txn = pool.begin().await?; + + let charge_refund = charge_item::DBCharge::get(id.into(), &mut *txn) + .await? + .ok_or_else(|| ApiError::NotFound)?; + + let Some(parent_charge_id) = charge_refund.parent_charge_id else { + return Err(ApiError::InvalidInput( + "This charge does not have a parent!".to_string(), + )); + }; + + match charge_refund.tax_platform_id { + Some(_) => { + return Err(ApiError::InvalidInput( + "Refund charge already has a tax transaction ID!".to_string(), + )); + } + None => { + let charge = + charge_item::DBCharge::get(parent_charge_id, &mut *txn) + .await? + .ok_or_else(|| ApiError::NotFound)?; + + let payment_platform_id = charge + .payment_platform_id + .ok_or_else(|| { + ApiError::Internal(eyre::eyre!( + "parent charge is missing a payment platform ID" + )) + })? + .parse::() + .map_err(|_| { + ApiError::Internal(eyre::eyre!( + "parent charge has an invalid payment platform ID." + )) + })?; + + let pi = stripe::PaymentIntent::retrieve( + &stripe_client, + &payment_platform_id, + &["payment_method"], + ) + .await?; + + let Some(billing_address) = pi + .payment_method + .and_then(|x| x.into_object()) + .and_then(|x| x.billing_details.address) + else { + return Err(ApiError::InvalidInput( + "Missing billing address for payment method.".to_owned(), + )); + }; + + let tax_id = + product_info_by_product_price_id(charge.price_id, &mut *txn) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find product tax info for price ID!" + .to_owned(), + ) + })? + .tax_identifier + .tax_processor_id; + + let Some(( + (original_tax_platform_id, original_tax_transaction_version), + original_tax_platform_accounting_time, + )) = charge + .tax_platform_id + .clone() + .zip(charge.tax_transaction_version) + .zip(charge.tax_platform_accounting_time) + else { + return Err(ApiError::InvalidInput( + "Charge is missing full tax information. Please wait for the original charge to be synchronized with the tax processor." + .to_owned(), + )); + }; + let refund_id = + charge_refund.payment_platform_id.ok_or_else(|| { + ApiError::Internal(eyre::eyre!( + "Refund charge is missing a payment platform ID!" + )) + })?; + + let refund_id = + stripe::RefundId::from_str(&refund_id).map_err(|_| { + ApiError::Internal(eyre::eyre!("Invalid refund ID!")) + })?; + + let anrok_txn_result = anrok_client + .negate_or_create_partial_negation( + original_tax_platform_id, + original_tax_transaction_version, + charge.amount + charge.tax_amount, + &anrok::Transaction { + id: anrok::transaction_id_stripe_pyr(&refund_id), + fields: anrok::TransactionFields { + customer_address: + anrok::Address::from_stripe_address( + &billing_address, + ), + currency_code: charge.currency_code.clone(), + accounting_time: + original_tax_platform_accounting_time, + accounting_time_zone: + anrok::AccountingTimeZone::Utc, + line_items: vec![ + anrok::LineItem::new_including_tax_amount( + tax_id, + -charge_refund.amount, + ), + ], + customer_id: Some(format!( + "stripe:cust:{}", + user.stripe_customer_id + .unwrap_or_else(|| "unknown".to_owned()) + )), + customer_name: Some("Customer".to_owned()), + }, + }, + ) + .await; + + if let Err(error) = anrok_txn_result { + return Err(ApiError::InvalidInput(format!( + "There was an error processing the tax transaction: {error}. Please make sure the version has been incremented in case of an external modification." + ))); + } + } + } + + txn.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + #[derive(Deserialize)] pub struct SubscriptionEdit { pub interval: Option,