forked from didirus/AstralRinth
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
This commit is contained in:
committed by
GitHub
parent
ca36d11570
commit
006b19e3c9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
76
apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json
generated
Normal file
76
apps/labrinth/.sqlx/query-23fed658506cab399009f2e9ff8d092020ac9a06582a2c183c1b430b5919c6ce.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
34
apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json
generated
Normal file
34
apps/labrinth/.sqlx/query-2fb4c034099267e2268821d1806fe28d540625e9713fcd88b4a965130245c1ee.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
30
apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json
generated
Normal file
30
apps/labrinth/.sqlx/query-8ad3460f73020decc59106f28cdc3313ca0dc8aaf8c7b4e0f2e3a6f87ba4104b.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json
generated
Normal file
22
apps/labrinth/.sqlx/query-8d1f5f24360d66442dff0f1de99091bca07fcad4004451def9576dc587495d4c.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
apps/labrinth/migrations/20250823233518_user-compliance.sql
Normal file
17
apps/labrinth/migrations/20250823233518_user-compliance.sql
Normal file
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
|
||||
178
apps/labrinth/src/database/models/users_compliance.rs
Normal file
178
apps/labrinth/src/database/models/users_compliance.rs
Normal file
@@ -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<Utc>,
|
||||
pub signed: Option<DateTime<Utc>>,
|
||||
pub e_delivery_consented: bool,
|
||||
pub tin_matched: bool,
|
||||
pub last_checked: DateTime<Utc>,
|
||||
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<Option<Self>>
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,13 @@ pub fn check_env_vars() -> bool {
|
||||
|
||||
failed |= check_var::<String>("DELPHI_URL");
|
||||
|
||||
failed |= check_var::<String>("AVALARA_1099_API_URL");
|
||||
failed |= check_var::<String>("AVALARA_1099_API_KEY");
|
||||
failed |= check_var::<String>("AVALARA_1099_API_TEAM_ID");
|
||||
failed |= check_var::<String>("AVALARA_1099_COMPANY_ID");
|
||||
|
||||
failed |= check_var::<String>("COMPLIANCE_PAYOUT_THRESHOLD");
|
||||
|
||||
failed |= check_var::<String>("ARCHON_URL");
|
||||
|
||||
failed
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
body: web::Json<RequestForm>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<DateTime<Utc>, 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<Option<ComplianceCheck>, 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<Decimal> {
|
||||
dotenvy::var("COMPLIANCE_PAYOUT_THRESHOLD")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RevenueQuery {
|
||||
pub start: Option<DateTime<Utc>>,
|
||||
|
||||
155
apps/labrinth/src/util/avalara1099.rs
Normal file
155
apps/labrinth/src/util/avalara1099.rs
Normal file
@@ -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<T> {
|
||||
pub data: Data<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListWrapper<T> {
|
||||
pub data: Vec<Data<T>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Data<T> {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub attributes: T,
|
||||
pub links: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FormResponse {
|
||||
pub form_type: FormType,
|
||||
pub form_id: Option<String>,
|
||||
pub company_id: u32,
|
||||
pub company_name: String,
|
||||
pub company_email: String,
|
||||
pub reference_id: String,
|
||||
pub signed_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct W9FormsResponse {
|
||||
pub e_delivery_consented_at: Option<NaiveDateTime>,
|
||||
pub tin_match_status: Option<String>,
|
||||
pub entry_status: String,
|
||||
}
|
||||
|
||||
pub async fn request_form(
|
||||
user_id: DBUserId,
|
||||
form_type: FormType,
|
||||
) -> Result<Result<DataWrapper<FormResponse>, 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::<DataWrapper<FormResponse>>().await?)
|
||||
} else {
|
||||
Err(response.json().await?)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_form(
|
||||
reference_id: &str,
|
||||
) -> Result<
|
||||
Result<Option<DataWrapper<W9FormsResponse>>, 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::<ListWrapper<W9FormsResponse>>().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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod actix;
|
||||
pub mod archon;
|
||||
pub mod avalara1099;
|
||||
pub mod bitflag;
|
||||
pub mod captcha;
|
||||
pub mod cors;
|
||||
|
||||
Reference in New Issue
Block a user