From 006b19e3c9ec7e48dd4de36e114ad4896f3a4d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:34:58 -0400 Subject: [PATCH] Creator tax compliance (#4254) * Initial implementation * Remove test code * Query cache * Appease clippy * Precise TIN/SSN * Make tax threshold customizable via env variable * Address review comments --- apps/labrinth/.env.docker-compose | 7 + apps/labrinth/.env.local | 7 + ...2c6d0c7fb15e375d734bf34c365e71d623780.json | 28 -- ...d092020ac9a06582a2c183c1b430b5919c6ce.json | 76 ++++++ ...fe28d540625e9713fcd88b4a965130245c1ee.json | 34 +++ ...c3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json | 30 +++ ...091bca07fcad4004451def9576dc587495d4c.json | 22 ++ .../20250823233518_user-compliance.sql | 17 ++ apps/labrinth/src/database/models/mod.rs | 1 + .../src/database/models/users_compliance.rs | 178 +++++++++++++ apps/labrinth/src/lib.rs | 7 + apps/labrinth/src/models/v3/pats.rs | 2 +- apps/labrinth/src/routes/mod.rs | 4 + apps/labrinth/src/routes/v3/payouts.rs | 241 +++++++++++++++++- apps/labrinth/src/util/avalara1099.rs | 155 +++++++++++ apps/labrinth/src/util/mod.rs | 1 + 16 files changed, 773 insertions(+), 37 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json create mode 100644 apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json create mode 100644 apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json create mode 100644 apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json create mode 100644 apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json create mode 100644 apps/labrinth/migrations/20250823233518_user-compliance.sql create mode 100644 apps/labrinth/src/database/models/users_compliance.rs create mode 100644 apps/labrinth/src/util/avalara1099.rs diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 9644111a..64125b65 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -126,4 +126,11 @@ BREX_API_KEY=none DELPHI_URL=none DELPHI_SLACK_WEBHOOK=none +AVALARA_1099_API_URL=https://www.track1099.com/api +AVALARA_1099_API_KEY=none +AVALARA_1099_API_TEAM_ID=none +AVALARA_1099_COMPANY_ID=207337084 + +COMPLIANCE_PAYOUT_THRESHOLD=disabled + ARCHON_URL=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index aa07fa20..b5afb933 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -126,4 +126,11 @@ BREX_API_KEY=none DELPHI_URL=none DELPHI_SLACK_WEBHOOK=none +AVALARA_1099_API_URL=https://www.track1099.com/api +AVALARA_1099_API_KEY=none +AVALARA_1099_API_TEAM_ID=none +AVALARA_1099_COMPANY_ID=207337084 + +COMPLIANCE_PAYOUT_THRESHOLD=disabled + ARCHON_URL=none diff --git a/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json b/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json deleted file mode 100644 index 0c3a38d4..00000000 --- a/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT SUM(amount) amount, SUM(fee) fee\n FROM payouts\n WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit')\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "amount", - "type_info": "Numeric" - }, - { - "ordinal": 1, - "name": "fee", - "type_info": "Numeric" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null, - null - ] - }, - "hash": "0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780" -} diff --git a/apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json b/apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json new file mode 100644 index 00000000..f605a80f --- /dev/null +++ b/apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM users_compliance WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "requested", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "signed", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "e_delivery_consented", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "tin_matched", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "last_checked", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "external_request_id", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "reference_id", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "form_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce" +} diff --git a/apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json b/apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json new file mode 100644 index 00000000..e54ca5d9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n SUM(amount) amount,\n SUM(fee) fee,\n SUM(amount) FILTER (WHERE created >= DATE_TRUNC('year', NOW())) amount_this_year\n FROM payouts\n WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 1, + "name": "fee", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "amount_this_year", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee" +} diff --git a/apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json b/apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json new file mode 100644 index 00000000..19210aaf --- /dev/null +++ b/apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_compliance\n (\n user_id,\n requested,\n signed,\n e_delivery_consented,\n tin_matched,\n last_checked,\n external_request_id,\n reference_id,\n form_type\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ON CONFLICT (user_id)\n DO UPDATE SET\n requested = EXCLUDED.requested,\n signed = EXCLUDED.signed,\n e_delivery_consented = EXCLUDED.e_delivery_consented,\n tin_matched = EXCLUDED.tin_matched,\n last_checked = EXCLUDED.last_checked,\n external_request_id = EXCLUDED.external_request_id,\n reference_id = EXCLUDED.reference_id,\n form_type = EXCLUDED.form_type\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Timestamptz", + "Bool", + "Bool", + "Timestamptz", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b" +} diff --git a/apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json b/apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json new file mode 100644 index 00000000..201d39f0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users_compliance\n SET\n requested = $2,\n signed = $3,\n e_delivery_consented = $4,\n tin_matched = $5,\n last_checked = $6,\n external_request_id = $7,\n reference_id = $8,\n form_type = $9\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Timestamptz", + "Bool", + "Bool", + "Timestamptz", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c" +} diff --git a/apps/labrinth/migrations/20250823233518_user-compliance.sql b/apps/labrinth/migrations/20250823233518_user-compliance.sql new file mode 100644 index 00000000..f1397d9a --- /dev/null +++ b/apps/labrinth/migrations/20250823233518_user-compliance.sql @@ -0,0 +1,17 @@ +CREATE TABLE users_compliance ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + + requested TIMESTAMP WITH TIME ZONE NOT NULL, + signed TIMESTAMP WITH TIME ZONE, + e_delivery_consented BOOLEAN NOT NULL, + tin_matched BOOLEAN NOT NULL, + last_checked TIMESTAMP WITH TIME ZONE NOT NULL, + + external_request_id VARCHAR NOT NULL, + reference_id VARCHAR NOT NULL, + + form_type VARCHAR NOT NULL, + + UNIQUE (user_id) +); \ No newline at end of file diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 4ef40cf1..25f2ff11 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -25,6 +25,7 @@ pub mod team_item; pub mod thread_item; pub mod user_item; pub mod user_subscription_item; +pub mod users_compliance; pub mod users_redeemals; pub mod version_item; diff --git a/apps/labrinth/src/database/models/users_compliance.rs b/apps/labrinth/src/database/models/users_compliance.rs new file mode 100644 index 00000000..8fb8874c --- /dev/null +++ b/apps/labrinth/src/database/models/users_compliance.rs @@ -0,0 +1,178 @@ +use crate::database::models::DBUserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_scalar}; +use std::fmt; + +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub enum FormType { + #[serde(rename = "W-8BEN")] + ForeignIndividual, + #[serde(rename = "W-8BEN-E")] + ForeignEntity, + #[default] + #[serde(rename = "W-9")] + DomesticPerson, +} + +impl FormType { + pub fn as_str(&self) -> &'static str { + match self { + FormType::ForeignIndividual => "W8-BEN", + FormType::ForeignEntity => "W8-BEN-E", + FormType::DomesticPerson => "W-9", + } + } + + pub fn from_str_or_default(s: &str) -> Self { + match s { + "W8-BEN" => FormType::ForeignIndividual, + "W8-BEN-E" => FormType::ForeignEntity, + "W-9" => FormType::DomesticPerson, + _ => FormType::default(), + } + } + + pub fn requires_domestic_tin_match(self) -> bool { + match self { + FormType::ForeignIndividual | FormType::ForeignEntity => false, + FormType::DomesticPerson => true, + } + } +} + +impl fmt::Display for FormType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug)] +pub struct UserCompliance { + pub id: i64, + pub user_id: DBUserId, + pub requested: DateTime, + pub signed: Option>, + pub e_delivery_consented: bool, + pub tin_matched: bool, + pub last_checked: DateTime, + pub external_request_id: String, + pub reference_id: String, + pub form_type: FormType, +} + +impl UserCompliance { + pub async fn get_by_user_id<'a, E>( + exec: E, + id: DBUserId, + ) -> sqlx::Result> + where + E: sqlx::PgExecutor<'a>, + { + let maybe_compliance = query!( + r#"SELECT * FROM users_compliance WHERE user_id = $1"#, + id.0 + ) + .fetch_optional(exec) + .await? + .map(|row| UserCompliance { + id: row.id, + user_id: id, + requested: row.requested, + signed: row.signed, + e_delivery_consented: row.e_delivery_consented, + tin_matched: row.tin_matched, + last_checked: row.last_checked, + external_request_id: row.external_request_id, + reference_id: row.reference_id, + form_type: FormType::from_str_or_default(&row.form_type), + }); + + Ok(maybe_compliance) + } + + pub async fn upsert<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + let id = query_scalar!( + r#" + INSERT INTO users_compliance + ( + user_id, + requested, + signed, + e_delivery_consented, + tin_matched, + last_checked, + external_request_id, + reference_id, + form_type + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_id) + DO UPDATE SET + requested = EXCLUDED.requested, + signed = EXCLUDED.signed, + e_delivery_consented = EXCLUDED.e_delivery_consented, + tin_matched = EXCLUDED.tin_matched, + last_checked = EXCLUDED.last_checked, + external_request_id = EXCLUDED.external_request_id, + reference_id = EXCLUDED.reference_id, + form_type = EXCLUDED.form_type + RETURNING id + "#, + self.user_id.0, + self.requested, + self.signed, + self.e_delivery_consented, + self.tin_matched, + self.last_checked, + self.external_request_id, + self.reference_id, + self.form_type.as_str(), + ) + .fetch_one(exec) + .await?; + + self.id = id; + + Ok(()) + } + + pub async fn update<'a, E>(&self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + query!( + r#" + UPDATE users_compliance + SET + requested = $2, + signed = $3, + e_delivery_consented = $4, + tin_matched = $5, + last_checked = $6, + external_request_id = $7, + reference_id = $8, + form_type = $9 + WHERE id = $1 + "#, + self.id, + self.requested, + self.signed, + self.e_delivery_consented, + self.tin_matched, + self.last_checked, + self.external_request_id, + self.reference_id, + self.form_type.as_str(), + ) + .execute(exec) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 30b85d8d..6fc0e8d8 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -501,6 +501,13 @@ pub fn check_env_vars() -> bool { failed |= check_var::("DELPHI_URL"); + failed |= check_var::("AVALARA_1099_API_URL"); + failed |= check_var::("AVALARA_1099_API_KEY"); + failed |= check_var::("AVALARA_1099_API_TEAM_ID"); + failed |= check_var::("AVALARA_1099_COMPANY_ID"); + + failed |= check_var::("COMPLIANCE_PAYOUT_THRESHOLD"); + failed |= check_var::("ARCHON_URL"); failed diff --git a/apps/labrinth/src/models/v3/pats.rs b/apps/labrinth/src/models/v3/pats.rs index fc700238..b3a89901 100644 --- a/apps/labrinth/src/models/v3/pats.rs +++ b/apps/labrinth/src/models/v3/pats.rs @@ -26,7 +26,7 @@ bitflags::bitflags! { // read a user's payouts data const PAYOUTS_READ = 1 << 7; // withdraw money from a user's account - const PAYOUTS_WRITE = 1<< 8; + const PAYOUTS_WRITE = 1 << 8; // access user analytics (payout analytics at the moment) const ANALYTICS = 1 << 9; diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index c637c79a..4b3225bd 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -139,6 +139,8 @@ pub enum ApiError { NotFound, #[error("Conflict: {0}")] Conflict(String), + #[error("External tax compliance API Error")] + TaxComplianceApi, #[error( "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." )] @@ -175,6 +177,7 @@ impl ApiError { ApiError::Reroute(..) => "reroute_error", ApiError::NotFound => "not_found", ApiError::Conflict(..) => "conflict", + ApiError::TaxComplianceApi => "tax_compliance_api_error", ApiError::Zip(..) => "zip_error", ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", @@ -212,6 +215,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::Conflict(..) => StatusCode::CONFLICT, + ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Zip(..) => StatusCode::BAD_REQUEST, ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 19a0d481..04cc339d 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -1,6 +1,7 @@ use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::{AuthenticationError, get_user_from_headers}; -use crate::database::models::generate_payout_id; +use crate::database::models::DBUserId; +use crate::database::models::{generate_payout_id, users_compliance}; use crate::database::redis::RedisPool; use crate::models::ids::PayoutId; use crate::models::pats::Scopes; @@ -8,6 +9,7 @@ use crate::models::payouts::{PayoutMethodType, PayoutStatus}; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::avalara1099; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use chrono::{DateTime, Duration, Utc}; use hex::ToHex; @@ -19,6 +21,10 @@ use serde_json::json; use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; +use tracing::error; + +const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration = + chrono::Duration::seconds(15); pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -30,10 +36,102 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(cancel_payout) .service(payment_methods) .service(get_balance) - .service(platform_revenue), + .service(platform_revenue) + .service(post_compliance_form), ); } +#[derive(Deserialize)] +pub struct RequestForm { + form_type: users_compliance::FormType, +} + +#[post("compliance")] +pub async fn post_compliance_form( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + body: web::Json, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PAYOUTS_WRITE, + ) + .await? + .1; + + let user_id = DBUserId(user.id.0 as i64); + + let mut txn = pool.begin().await?; + + let maybe_compliance = + users_compliance::UserCompliance::get_by_user_id(&mut *txn, user_id) + .await?; + + let mut compliance = match maybe_compliance { + Some(c) => c, + None => users_compliance::UserCompliance { + id: 0, + user_id, + requested: Utc::now(), + signed: None, + last_checked: Utc::now() - COMPLIANCE_CHECK_DEBOUNCE, + external_request_id: String::new(), + reference_id: String::new(), + e_delivery_consented: false, + tin_matched: false, + form_type: body.0.form_type, + }, + }; + + let result = avalara1099::request_form(user_id, body.0.form_type).await?; + + match result { + Ok( + ref toplevel @ avalara1099::DataWrapper { + data: + avalara1099::Data { + r#type: _, + id: Some(ref request_id), + ref attributes, + links: _, + }, + }, + ) => { + compliance.external_request_id = request_id.clone(); + compliance.reference_id = attributes.reference_id.clone(); + compliance.requested = Utc::now(); + compliance.e_delivery_consented = false; + compliance.tin_matched = false; + compliance.signed = None; + compliance.form_type = body.0.form_type; + compliance.last_checked = Utc::now() - COMPLIANCE_CHECK_DEBOUNCE; + + compliance.upsert(&mut *txn).await?; + txn.commit().await?; + + Ok(HttpResponse::Ok().json(toplevel)) + } + + Ok(_) => { + error!("Missing form request ID in Avalara response"); + Err(ApiError::TaxComplianceApi) + } + + Err(json_error) => { + error!( + "Error sending request to Avalara: {}", + serde_json::to_string_pretty(&json_error).unwrap() + ); + Err(ApiError::TaxComplianceApi) + } + } +} + #[post("_paypal")] pub async fn paypal_webhook( req: HttpRequest, @@ -391,6 +489,45 @@ pub async fn create_payout( )); } + if let Some(threshold) = tax_compliance_payout_threshold() { + let maybe_compliance = update_compliance_status(&pool, user.id).await?; + + let (tin_matched, signed, requested, api_check_failed) = + match maybe_compliance { + Some(ComplianceCheck { + model, + compliance_api_check_failed, + }) => { + let tin = model.tin_matched; + let signed = model.signed.is_some(); + + (tin, signed, true, compliance_api_check_failed) + } + None => (false, false, false, false), + }; + + if !(tin_matched && signed) + && balance.withdrawn_ytd + body.amount >= threshold + { + // We propagate the error this way because we don't want to block payouts + // that would be acceptable regardless of the tax form submission status + // if the compliance API is down. + + // In this case the payout is going to be blocked, so do return that we hit an + // error with the API, as this is more accurate than saying the form wasn't completed + // properly as this might be wrong! + if api_check_failed { + return Err(ApiError::TaxComplianceApi); + } + + return Err(ApiError::InvalidInput(match (tin_matched, signed, requested) { + (_, false, true) => "Tax form isn't signed yet!", + (false, true, true) => "Tax form is signed, but the Tax Identification Number/SSN didn't match the IRS records. Withdrawals are blocked until the TIN/SSN matches.", + _ => "Tax compliance form is required to withdraw more!", + }.to_owned())); + } + } + let payout_method = payouts_queue .get_payout_methods() .await? @@ -765,6 +902,8 @@ pub async fn payment_methods( #[derive(Serialize)] pub struct UserBalance { pub available: Decimal, + pub withdrawn_lifetime: Decimal, + pub withdrawn_ytd: Decimal, pub pending: Decimal, pub dates: HashMap, Decimal>, } @@ -819,7 +958,10 @@ async fn get_user_balance( let withdrawn = sqlx::query!( " - SELECT SUM(amount) amount, SUM(fee) fee + SELECT + SUM(amount) amount, + SUM(fee) fee, + SUM(amount) FILTER (WHERE created >= DATE_TRUNC('year', NOW())) amount_this_year FROM payouts WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit') ", @@ -828,18 +970,19 @@ async fn get_user_balance( .fetch_optional(pool) .await?; - let (withdrawn, fees) = - withdrawn.map_or((Decimal::ZERO, Decimal::ZERO), |x| { + let (withdrawn, fees, withdrawn_this_year) = + withdrawn.map_or((Decimal::ZERO, Decimal::ZERO, Decimal::ZERO), |x| { ( x.amount.unwrap_or(Decimal::ZERO), x.fee.unwrap_or(Decimal::ZERO), + x.amount_this_year.unwrap_or(Decimal::ZERO), ) }); Ok(UserBalance { - available: available.round_dp(16) - - withdrawn.round_dp(16) - - fees.round_dp(16), + available: (available - withdrawn - fees).round_dp(16), + withdrawn_lifetime: withdrawn.round_dp(16), + withdrawn_ytd: withdrawn_this_year.round_dp(16), pending, dates: payouts .iter() @@ -848,6 +991,88 @@ async fn get_user_balance( }) } +struct ComplianceCheck { + model: users_compliance::UserCompliance, + compliance_api_check_failed: bool, +} + +async fn update_compliance_status( + pg: &PgPool, + user_id: crate::database::models::ids::DBUserId, +) -> Result, ApiError> { + let maybe_compliance = + users_compliance::UserCompliance::get_by_user_id(pg, user_id).await?; + + let Some(mut compliance) = maybe_compliance else { + return Ok(None); + }; + + if compliance.signed.is_some() + || Utc::now().signed_duration_since(compliance.last_checked) + < COMPLIANCE_CHECK_DEBOUNCE + { + Ok(Some(ComplianceCheck { + model: compliance, + compliance_api_check_failed: false, + })) + } else { + let result = avalara1099::check_form(&compliance.reference_id).await?; + let mut compliance_api_check_failed = false; + + compliance.last_checked = Utc::now(); + + match result { + Ok(None) => { + // Means the form wasn't signed yet + compliance.signed = None; + compliance.e_delivery_consented = false; + compliance.tin_matched = false; + } + + Ok(Some(avalara1099::DataWrapper { + data: avalara1099::Data { attributes, .. }, + })) => { + // It's unclear what timezone the DateTime is in (as it returns a naive RFC-3339 timestamp) + // so we can just say it was signed now + compliance.signed = + (&attributes.entry_status == "signed").then(Utc::now); + compliance.e_delivery_consented = + attributes.e_delivery_consented_at.is_some(); + + if compliance.form_type.requires_domestic_tin_match() { + compliance.tin_matched = attributes + .tin_match_status + .as_ref() + .is_some_and(|x| x == "matched"); + } else { + compliance.tin_matched = true; + } + } + + Err(json_error) => { + error!( + "Error sending request to Avalara: {}", + serde_json::to_string_pretty(&json_error).unwrap() + ); + compliance_api_check_failed = true; + } + } + + compliance.update(pg).await?; + + Ok(Some(ComplianceCheck { + model: compliance, + compliance_api_check_failed, + })) + } +} + +fn tax_compliance_payout_threshold() -> Option { + dotenvy::var("COMPLIANCE_PAYOUT_THRESHOLD") + .ok() + .and_then(|s| s.parse().ok()) +} + #[derive(Deserialize)] pub struct RevenueQuery { pub start: Option>, diff --git a/apps/labrinth/src/util/avalara1099.rs b/apps/labrinth/src/util/avalara1099.rs new file mode 100644 index 00000000..b861b8b2 --- /dev/null +++ b/apps/labrinth/src/util/avalara1099.rs @@ -0,0 +1,155 @@ +use crate::database::models::{DBUserId, users_compliance::FormType}; +use crate::routes::ApiError; +use ariadne::ids::base62_impl::to_base62; +use chrono::{Datelike, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DataWrapper { + pub data: Data, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListWrapper { + pub data: Vec>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Data { + #[serde(rename = "type")] + pub r#type: Option, + pub id: Option, + pub attributes: T, + pub links: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct FormResponse { + pub form_type: FormType, + pub form_id: Option, + pub company_id: u32, + pub company_name: String, + pub company_email: String, + pub reference_id: String, + pub signed_at: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct W9FormsResponse { + pub e_delivery_consented_at: Option, + pub tin_match_status: Option, + pub entry_status: String, +} + +pub async fn request_form( + user_id: DBUserId, + form_type: FormType, +) -> Result, serde_json::Value>, ApiError> { + const DEFAULT_TTL: u32 = 3600; + + #[derive(Serialize, Deserialize)] + struct FormRequest { + form_type: FormType, + company_id: String, + reference_id: String, + ttl: u32, + } + + let (request_builder, company_id) = + team_request(reqwest::Method::POST, "/form_requests")?; + + let response = request_builder + .json(&DataWrapper { + data: Data { + r#type: Some("form_request".to_owned()), + id: None, + attributes: FormRequest { + form_type, + company_id, + ttl: DEFAULT_TTL, + reference_id: Reference { + user_id, + form_type, + current_year: chrono::Utc::now().year_ce().1, + } + .to_string(), + }, + links: None, + }, + }) + .send() + .await?; + + Ok(if response.status().is_success() { + Ok(response.json::>().await?) + } else { + Err(response.json().await?) + }) +} + +pub async fn check_form( + reference_id: &str, +) -> Result< + Result>, serde_json::Value>, + ApiError, +> { + let (request_builder, _company_id) = team_request( + reqwest::Method::GET, + &format!( + "/w9forms?filter[reference_id_eq]={reference_id}&page[number]=1&page[size]=1" + ), + )?; + + let response = request_builder.send().await?; + + Ok(if response.status().is_success() { + let mut list_wrapper = + response.json::>().await?; + + Ok(list_wrapper.data.pop().map(|data| DataWrapper { data })) + } else { + Err(response.json().await?) + }) +} + +fn team_request( + method: reqwest::Method, + route: &str, +) -> Result<(reqwest::RequestBuilder, String), ApiError> { + let key = dotenvy::var("AVALARA_1099_API_KEY")?; + let url = dotenvy::var("AVALARA_1099_API_URL")?; + let team = dotenvy::var("AVALARA_1099_API_TEAM_ID")?; + let company = dotenvy::var("AVALARA_1099_COMPANY_ID")?; + + let url = url.trim_end_matches('/'); + + let client = reqwest::Client::new(); + + Ok(( + client + .request(method, format!("{url}/v1/{team}{route}")) + .header(reqwest::header::USER_AGENT, "Modrinth") + .bearer_auth(&key), + company, + )) +} + +struct Reference { + user_id: DBUserId, + form_type: FormType, + current_year: u32, +} + +impl fmt::Display for Reference { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}_{}_{}", + to_base62(self.user_id.0 as u64), + self.form_type, + self.current_year + ) + } +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index fa9b16ff..9ac089cd 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -1,5 +1,6 @@ pub mod actix; pub mod archon; +pub mod avalara1099; pub mod bitflag; pub mod captcha; pub mod cors;