You've already forked AstralRinth
forked from didirus/AstralRinth
Charge tax on products (#4361)
* Initial Anrok integration * Query cache, fmt, clippy * Fmt * Use payment intent function in edit_subscription * Attach Anrok client, use payments in index_billing * Integrate Anrok with refunds * Bug fixes * More bugfixes * Fix resubscriptions * Medal promotion bugfixes * Use stripe metadata constants everywhere * Pre-fill values in products_tax_identifiers * Cleanup billing route module * Cleanup * Email notification for tax charge * Don't charge tax on users which haven't been notified of tax change * Fix taxnotification.amount templates * Update .env.docker-compose * Update .env.local * Clippy * Fmt * Query cache * Periodically update tax amount on upcoming charges * Fix queries * Skip indexing tax amount on charges if no charges to process * chore: query cache, clippy, fmt * Fix a lot of things * Remove test code * chore: query cache, clippy, fmt * Fix money formatting * Fix conflicts * Extra documentation, handle tax association properly * Track loss in tax drift * chore: query cache, clippy, fmt * Add subscription.id variable * chore: query cache, clippy, fmt * chore: query cache, clippy, fmt
This commit is contained in:
committed by
GitHub
parent
47020f34b6
commit
4228a193e9
293
apps/labrinth/src/util/anrok.rs
Normal file
293
apps/labrinth/src/util/anrok.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::Rng;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{DisplayFromStr, serde_as};
|
||||
use thiserror::Error;
|
||||
use tracing::trace;
|
||||
|
||||
pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String {
|
||||
format!("stripe:charge:{pi}")
|
||||
}
|
||||
|
||||
pub fn transaction_id_stripe_pyr(charge: &stripe::RefundId) -> String {
|
||||
format!("stripe:refund:{charge}")
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvoiceResponse {
|
||||
pub tax_amount_to_collect: i64,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct EmptyResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Address {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub line1: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub postal_code: Option<String>,
|
||||
}
|
||||
|
||||
impl Address {
|
||||
pub fn from_stripe_address(address: &stripe::Address) -> Self {
|
||||
Self {
|
||||
country: address.country.clone(),
|
||||
line1: address.line1.clone(),
|
||||
city: address.city.clone(),
|
||||
region: address.state.clone(),
|
||||
postal_code: address.postal_code.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub id: Option<String>,
|
||||
pub product_external_id: String,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub quantity: u32,
|
||||
#[serde(rename = "amount")]
|
||||
pub amount_in_smallest_denominations: i64,
|
||||
pub is_tax_included_in_amount: bool,
|
||||
}
|
||||
|
||||
impl LineItem {
|
||||
pub const fn new(
|
||||
product_external_id: String,
|
||||
amount_in_smallest_denominations: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
product_external_id,
|
||||
quantity: 1,
|
||||
amount_in_smallest_denominations,
|
||||
is_tax_included_in_amount: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn new_including_tax_amount(
|
||||
product_external_id: String,
|
||||
amount_in_smallest_denominations: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
product_external_id,
|
||||
quantity: 1,
|
||||
amount_in_smallest_denominations,
|
||||
is_tax_included_in_amount: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AccountingTimeZone {
|
||||
#[default]
|
||||
#[serde(rename = "UTC")]
|
||||
Utc,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransactionFields {
|
||||
pub customer_address: Address,
|
||||
pub currency_code: String,
|
||||
pub accounting_time: DateTime<Utc>,
|
||||
pub accounting_time_zone: AccountingTimeZone,
|
||||
pub line_items: Vec<LineItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Transaction {
|
||||
#[serde(flatten)]
|
||||
pub fields: TransactionFields,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AnrokError {
|
||||
#[error("Anrok API Error: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Anrok API Error: Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("Rate limit exceeded using Anrok API")]
|
||||
RateLimit,
|
||||
#[error("Anrok API error: {0}")]
|
||||
Other(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_env() -> Result<Self, dotenvy::Error> {
|
||||
let api_key = dotenvy::var("ANROK_API_KEY")?;
|
||||
let api_url = dotenvy::var("ANROK_API_URL")?
|
||||
.trim_start_matches('/')
|
||||
.to_owned();
|
||||
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Modrinth")
|
||||
.build()
|
||||
.expect("AnrokClient to build"),
|
||||
api_key,
|
||||
api_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_ephemeral_txn(
|
||||
&self,
|
||||
body: &TransactionFields,
|
||||
) -> Result<InvoiceResponse, AnrokError> {
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
"/v1/seller/transactions/createEphemeral",
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_or_update_txn(
|
||||
&self,
|
||||
body: &Transaction,
|
||||
) -> Result<InvoiceResponse, AnrokError> {
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
"/v1/seller/transactions/createOrUpdate",
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn void_txn(
|
||||
&self,
|
||||
id: String,
|
||||
version: i32,
|
||||
) -> Result<EmptyResponse, AnrokError> {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Body {
|
||||
transaction_expected_version: i32,
|
||||
}
|
||||
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
&format!("/v1/seller/transactions/id:{id}/void"),
|
||||
Some(&Body {
|
||||
transaction_expected_version: version,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn make_request<T: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<R, AnrokError> {
|
||||
let mut n = 0u64;
|
||||
|
||||
loop {
|
||||
if n >= 3 {
|
||||
return Err(AnrokError::RateLimit);
|
||||
}
|
||||
|
||||
match self.make_request_inner(method.clone(), path, body).await {
|
||||
Err(AnrokError::RateLimit) => {
|
||||
n += 1;
|
||||
// 1000 + ~500, 2000 + ~1000, 5000 + ~2500
|
||||
let base = (n - 1).pow(2) * 1000 + 1000;
|
||||
let random = rand::thread_rng().gen_range(0..(base / 2));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
base + random,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_request_inner<T: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<R, AnrokError> {
|
||||
let then = std::time::Instant::now();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConflictResponse {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
}
|
||||
|
||||
let mut builder = self
|
||||
.client
|
||||
.request(method.clone(), format!("{}/{}", self.api_url, path))
|
||||
.bearer_auth(&self.api_key);
|
||||
|
||||
if let Some(body) = body {
|
||||
builder = builder.json(&body);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
|
||||
trace!(
|
||||
http.status = %response.status().as_u16(),
|
||||
http.method = %method,
|
||||
http.path = %path,
|
||||
duration = format!("{}ms", then.elapsed().as_millis()),
|
||||
"Received Anrok response",
|
||||
);
|
||||
|
||||
match response.status() {
|
||||
StatusCode::CONFLICT => {
|
||||
return Err(AnrokError::Conflict(
|
||||
response.json::<ConflictResponse>().await?.type_,
|
||||
));
|
||||
}
|
||||
|
||||
StatusCode::BAD_REQUEST => {
|
||||
return Err(AnrokError::BadRequest(
|
||||
response.json::<String>().await.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(AnrokError::RateLimit),
|
||||
|
||||
s if !s.is_success() => {
|
||||
if let Err(error) = response.error_for_status_ref() {
|
||||
return Err(AnrokError::Other(error));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let body = response.json::<R>().await?;
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod actix;
|
||||
pub mod anrok;
|
||||
pub mod archon;
|
||||
pub mod avalara1099;
|
||||
pub mod bitflag;
|
||||
|
||||
Reference in New Issue
Block a user