1
0

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:
François-Xavier Talbot
2025-08-25 12:34:58 -04:00
committed by GitHub
parent ca36d11570
commit 006b19e3c9
16 changed files with 773 additions and 37 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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)
);

View File

@@ -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;

View 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(())
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>>,

View 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
)
}
}

View File

@@ -1,5 +1,6 @@
pub mod actix;
pub mod archon;
pub mod avalara1099;
pub mod bitflag;
pub mod captcha;
pub mod cors;