You've already forked AstralRinth
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_URL=none
|
||||||
DELPHI_SLACK_WEBHOOK=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
|
ARCHON_URL=none
|
||||||
|
|||||||
@@ -126,4 +126,11 @@ BREX_API_KEY=none
|
|||||||
DELPHI_URL=none
|
DELPHI_URL=none
|
||||||
DELPHI_SLACK_WEBHOOK=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
|
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 thread_item;
|
||||||
pub mod user_item;
|
pub mod user_item;
|
||||||
pub mod user_subscription_item;
|
pub mod user_subscription_item;
|
||||||
|
pub mod users_compliance;
|
||||||
pub mod users_redeemals;
|
pub mod users_redeemals;
|
||||||
pub mod version_item;
|
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>("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 |= check_var::<String>("ARCHON_URL");
|
||||||
|
|
||||||
failed
|
failed
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ bitflags::bitflags! {
|
|||||||
// read a user's payouts data
|
// read a user's payouts data
|
||||||
const PAYOUTS_READ = 1 << 7;
|
const PAYOUTS_READ = 1 << 7;
|
||||||
// withdraw money from a user's account
|
// 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)
|
// access user analytics (payout analytics at the moment)
|
||||||
const ANALYTICS = 1 << 9;
|
const ANALYTICS = 1 << 9;
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ pub enum ApiError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[error("Conflict: {0}")]
|
#[error("Conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
#[error("External tax compliance API Error")]
|
||||||
|
TaxComplianceApi,
|
||||||
#[error(
|
#[error(
|
||||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||||
)]
|
)]
|
||||||
@@ -175,6 +177,7 @@ impl ApiError {
|
|||||||
ApiError::Reroute(..) => "reroute_error",
|
ApiError::Reroute(..) => "reroute_error",
|
||||||
ApiError::NotFound => "not_found",
|
ApiError::NotFound => "not_found",
|
||||||
ApiError::Conflict(..) => "conflict",
|
ApiError::Conflict(..) => "conflict",
|
||||||
|
ApiError::TaxComplianceApi => "tax_compliance_api_error",
|
||||||
ApiError::Zip(..) => "zip_error",
|
ApiError::Zip(..) => "zip_error",
|
||||||
ApiError::Io(..) => "io_error",
|
ApiError::Io(..) => "io_error",
|
||||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||||
@@ -212,6 +215,7 @@ impl actix_web::ResponseError for ApiError {
|
|||||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||||
|
ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
use crate::auth::{AuthenticationError, get_user_from_headers};
|
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::database::redis::RedisPool;
|
||||||
use crate::models::ids::PayoutId;
|
use crate::models::ids::PayoutId;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
@@ -8,6 +9,7 @@ use crate::models::payouts::{PayoutMethodType, PayoutStatus};
|
|||||||
use crate::queue::payouts::PayoutsQueue;
|
use crate::queue::payouts::PayoutsQueue;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
|
use crate::util::avalara1099;
|
||||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use hex::ToHex;
|
use hex::ToHex;
|
||||||
@@ -19,6 +21,10 @@ use serde_json::json;
|
|||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration =
|
||||||
|
chrono::Duration::seconds(15);
|
||||||
|
|
||||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -30,10 +36,102 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(cancel_payout)
|
.service(cancel_payout)
|
||||||
.service(payment_methods)
|
.service(payment_methods)
|
||||||
.service(get_balance)
|
.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")]
|
#[post("_paypal")]
|
||||||
pub async fn paypal_webhook(
|
pub async fn paypal_webhook(
|
||||||
req: HttpRequest,
|
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
|
let payout_method = payouts_queue
|
||||||
.get_payout_methods()
|
.get_payout_methods()
|
||||||
.await?
|
.await?
|
||||||
@@ -765,6 +902,8 @@ pub async fn payment_methods(
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UserBalance {
|
pub struct UserBalance {
|
||||||
pub available: Decimal,
|
pub available: Decimal,
|
||||||
|
pub withdrawn_lifetime: Decimal,
|
||||||
|
pub withdrawn_ytd: Decimal,
|
||||||
pub pending: Decimal,
|
pub pending: Decimal,
|
||||||
pub dates: HashMap<DateTime<Utc>, Decimal>,
|
pub dates: HashMap<DateTime<Utc>, Decimal>,
|
||||||
}
|
}
|
||||||
@@ -819,7 +958,10 @@ async fn get_user_balance(
|
|||||||
|
|
||||||
let withdrawn = sqlx::query!(
|
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
|
FROM payouts
|
||||||
WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit')
|
WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit')
|
||||||
",
|
",
|
||||||
@@ -828,18 +970,19 @@ async fn get_user_balance(
|
|||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (withdrawn, fees) =
|
let (withdrawn, fees, withdrawn_this_year) =
|
||||||
withdrawn.map_or((Decimal::ZERO, Decimal::ZERO), |x| {
|
withdrawn.map_or((Decimal::ZERO, Decimal::ZERO, Decimal::ZERO), |x| {
|
||||||
(
|
(
|
||||||
x.amount.unwrap_or(Decimal::ZERO),
|
x.amount.unwrap_or(Decimal::ZERO),
|
||||||
x.fee.unwrap_or(Decimal::ZERO),
|
x.fee.unwrap_or(Decimal::ZERO),
|
||||||
|
x.amount_this_year.unwrap_or(Decimal::ZERO),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(UserBalance {
|
Ok(UserBalance {
|
||||||
available: available.round_dp(16)
|
available: (available - withdrawn - fees).round_dp(16),
|
||||||
- withdrawn.round_dp(16)
|
withdrawn_lifetime: withdrawn.round_dp(16),
|
||||||
- fees.round_dp(16),
|
withdrawn_ytd: withdrawn_this_year.round_dp(16),
|
||||||
pending,
|
pending,
|
||||||
dates: payouts
|
dates: payouts
|
||||||
.iter()
|
.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)]
|
#[derive(Deserialize)]
|
||||||
pub struct RevenueQuery {
|
pub struct RevenueQuery {
|
||||||
pub start: Option<DateTime<Utc>>,
|
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 actix;
|
||||||
pub mod archon;
|
pub mod archon;
|
||||||
|
pub mod avalara1099;
|
||||||
pub mod bitflag;
|
pub mod bitflag;
|
||||||
pub mod captcha;
|
pub mod captcha;
|
||||||
pub mod cors;
|
pub mod cors;
|
||||||
|
|||||||
Reference in New Issue
Block a user