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
File diff suppressed because it is too large
Load Diff
798
apps/labrinth/src/routes/internal/billing/payments.rs
Normal file
798
apps/labrinth/src/routes/internal/billing/payments.rs
Normal file
@@ -0,0 +1,798 @@
|
||||
use crate::database::models::charge_item::DBCharge;
|
||||
use crate::database::models::{
|
||||
generate_charge_id, generate_user_subscription_id, product_item,
|
||||
products_tax_identifier_item, user_subscription_item,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::*;
|
||||
use crate::models::v3::billing::SubscriptionStatus;
|
||||
use crate::models::v3::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::anrok;
|
||||
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::*;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use stripe::{
|
||||
self, CreateCustomer, CreatePaymentIntent, Currency, CustomerId,
|
||||
PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethod,
|
||||
PaymentMethodId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ChargeRequestType, ChargeType, PaymentRequestMetadata, PaymentRequestType,
|
||||
Price, PriceDuration,
|
||||
};
|
||||
|
||||
const DEFAULT_USER_COUNTRY: &str = "US";
|
||||
|
||||
pub const MODRINTH_SUBSCRIPTION_ID: &str = "modrinth_subscription_id";
|
||||
pub const MODRINTH_PRICE_ID: &str = "modrinth_price_id";
|
||||
pub const MODRINTH_SUBSCRIPTION_INTERVAL: &str =
|
||||
"modrinth_subscription_interval";
|
||||
pub const MODRINTH_CHARGE_TYPE: &str = "modrinth_charge_type";
|
||||
pub const MODRINTH_NEW_REGION: &str = "modrinth_new_region";
|
||||
pub const MODRINTH_USER_ID: &str = "modrinth_user_id";
|
||||
pub const MODRINTH_CHARGE_ID: &str = "modrinth_charge_id";
|
||||
pub const MODRINTH_TAX_AMOUNT: &str = "modrinth_tax_amount";
|
||||
pub const MODRINTH_PAYMENT_METADATA: &str = "modrinth_payment_metadata";
|
||||
|
||||
pub enum AttachedCharge {
|
||||
/// Create a proration charge.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
Proration {
|
||||
next_product_id: ProductId,
|
||||
next_interval: PriceDuration,
|
||||
current_subscription: UserSubscriptionId,
|
||||
amount: i64,
|
||||
},
|
||||
/// Create a promotion charge.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
Promotion {
|
||||
product_id: ProductId,
|
||||
interval: PriceDuration,
|
||||
current_subscription: UserSubscriptionId,
|
||||
new_region: String,
|
||||
},
|
||||
/// Base the payment intent amount and tax on the product's price at this interval,
|
||||
/// but don't actually create a charge item until the payment intent is confirmed.
|
||||
///
|
||||
/// The amount will be based on the product's price at this interval,
|
||||
/// and tax calculated based on the payment method.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
BaseUpon {
|
||||
product_id: ProductId,
|
||||
interval: Option<PriceDuration>,
|
||||
},
|
||||
/// Use an existing charge to base the payment intent upon.
|
||||
///
|
||||
/// This can be used in the case of resubscription flows. The amount from this
|
||||
/// charge will be used, but the tax will be recalculated and the charge updated.
|
||||
///
|
||||
/// The charge's status will NOT be updated - it is the caller's responsability to
|
||||
/// update the charge's status on failure or success.
|
||||
///
|
||||
/// This may be accompanied by an automated payment session.
|
||||
UseExisting { charge: DBCharge },
|
||||
}
|
||||
|
||||
impl AttachedCharge {
|
||||
pub fn as_charge(&self) -> Option<&DBCharge> {
|
||||
if let AttachedCharge::UseExisting { charge } = self {
|
||||
Some(charge)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_charge_request_type(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
charge_request_type: ChargeRequestType,
|
||||
) -> Result<Self, ApiError> {
|
||||
Ok(match charge_request_type {
|
||||
ChargeRequestType::Existing { id } => AttachedCharge::UseExisting {
|
||||
charge: DBCharge::get(id.into(), exec).await?.ok_or_else(
|
||||
|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find charge".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
},
|
||||
ChargeRequestType::New {
|
||||
product_id,
|
||||
interval,
|
||||
} => AttachedCharge::BaseUpon {
|
||||
product_id,
|
||||
interval,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PaymentSession {
|
||||
Interactive {
|
||||
payment_request_type: PaymentRequestType,
|
||||
},
|
||||
AutomatedRenewal,
|
||||
}
|
||||
|
||||
impl PaymentSession {
|
||||
pub fn set_payment_intent_session_options(
|
||||
&self,
|
||||
intent: &mut CreatePaymentIntent,
|
||||
) {
|
||||
if matches!(self, PaymentSession::AutomatedRenewal) {
|
||||
intent.off_session = Some(PaymentIntentOffSession::Exists(true)); // Mark as the customer isn't able to perform manual verification/isn't on-session
|
||||
intent.confirm = Some(true); // Immediately confirm the PI
|
||||
} else {
|
||||
intent.off_session = None;
|
||||
intent.setup_future_usage =
|
||||
Some(PaymentIntentSetupFutureUsage::OffSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CurrencyMode {
|
||||
Set(Currency),
|
||||
Infer,
|
||||
}
|
||||
|
||||
pub struct PaymentBootstrapOptions<'a> {
|
||||
pub user: &'a User,
|
||||
/// Update this payment intent instead of creating a new intent.
|
||||
pub payment_intent: Option<stripe::PaymentIntentId>,
|
||||
/// The status of the current payment session. This is used to derive the payment
|
||||
/// method as well as set the appropriate parameters on the payment intent.
|
||||
///
|
||||
/// For interactive payment flows, a `PaymentRequestType` can be attached, we can be
|
||||
/// either an existing PaymentMethodId for existing payment methods, or a ConfirmationToken
|
||||
/// (ctoken) for new payment methods.
|
||||
///
|
||||
/// For automated subscription renewal flows, use the `AutomatedRenewal` variant to
|
||||
/// select the default payment method from the Stripe customer.
|
||||
///
|
||||
/// Taxes will always be collected.
|
||||
///
|
||||
/// Note the charge will NOT be updated. It is the caller's responsability to update the charge
|
||||
/// on success or failure.
|
||||
pub payment_session: PaymentSession,
|
||||
/// The charge the payment intent on should be based upon.
|
||||
pub attached_charge: AttachedCharge,
|
||||
/// The currency used for the payment amount.
|
||||
pub currency: CurrencyMode,
|
||||
/// Some products have additional provisioning metadata that should be attached to the payment
|
||||
/// intent.
|
||||
pub attach_payment_metadata: Option<PaymentRequestMetadata>,
|
||||
}
|
||||
|
||||
pub struct PaymentBootstrapResults {
|
||||
pub new_payment_intent: Option<stripe::PaymentIntent>,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub price_id: ProductPriceId,
|
||||
pub subtotal: i64,
|
||||
pub tax: i64,
|
||||
}
|
||||
|
||||
/// Updates a PaymentIntent or creates a new one, recalculating tax information and
|
||||
/// setting metadata fields based on the specified payment request and session options.
|
||||
///
|
||||
/// # Important notes
|
||||
///
|
||||
/// - This function does not perform any database writes. It is the caller's responsability to, for
|
||||
/// example, update the charge's status on success or failure, or update the charge's tax amount,
|
||||
/// tax eligibility or payment and tax platform IDs.
|
||||
/// - You may not update or create a payment intent for an off-session payment flow without
|
||||
/// attaching a charge.
|
||||
pub async fn create_or_update_payment_intent(
|
||||
pg: &PgPool,
|
||||
redis: &RedisPool,
|
||||
stripe_client: &stripe::Client,
|
||||
anrok_client: &anrok::Client,
|
||||
PaymentBootstrapOptions {
|
||||
user,
|
||||
payment_intent: existing_payment_intent,
|
||||
payment_session,
|
||||
attached_charge,
|
||||
currency: currency_mode,
|
||||
attach_payment_metadata,
|
||||
}: PaymentBootstrapOptions<'_>,
|
||||
) -> Result<PaymentBootstrapResults, ApiError> {
|
||||
let customer_id = get_or_create_customer(
|
||||
user.id,
|
||||
user.stripe_customer_id.as_deref(),
|
||||
user.email.as_deref(),
|
||||
stripe_client,
|
||||
pg,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let payment_method = match &payment_session {
|
||||
PaymentSession::Interactive {
|
||||
payment_request_type: PaymentRequestType::PaymentMethod { id },
|
||||
} => {
|
||||
let payment_method_id =
|
||||
PaymentMethodId::from_str(id).map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Invalid payment method id".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
PaymentMethod::retrieve(stripe_client, &payment_method_id, &[])
|
||||
.await?
|
||||
}
|
||||
PaymentSession::Interactive {
|
||||
payment_request_type:
|
||||
PaymentRequestType::ConfirmationToken { token },
|
||||
} => {
|
||||
#[derive(Deserialize)]
|
||||
struct ConfirmationToken {
|
||||
payment_method_preview: Option<PaymentMethod>,
|
||||
}
|
||||
|
||||
let mut confirmation: serde_json::Value = stripe_client
|
||||
.get(&format!("confirmation_tokens/{token}"))
|
||||
.await?;
|
||||
|
||||
// We patch the JSONs to support the PaymentMethod struct
|
||||
let p: json_patch::Patch = serde_json::from_value(serde_json::json!([
|
||||
{ "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" },
|
||||
{ "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 },
|
||||
{ "op": "add", "path": "/payment_method_preview/livemode", "value": false }
|
||||
])).unwrap();
|
||||
json_patch::patch(&mut confirmation, &p).unwrap();
|
||||
|
||||
let confirmation: ConfirmationToken =
|
||||
serde_json::from_value(confirmation)?;
|
||||
|
||||
confirmation.payment_method_preview.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Confirmation token is missing payment method!".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
PaymentSession::AutomatedRenewal => {
|
||||
if attached_charge.as_charge().is_none() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Missing attached charge for automated renewal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let customer = stripe::Customer::retrieve(
|
||||
stripe_client,
|
||||
&customer_id,
|
||||
&["invoice_settings.default_payment_method"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
customer
|
||||
.invoice_settings
|
||||
.and_then(|x| {
|
||||
x.default_payment_method.and_then(|x| x.into_object())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Customer has no default payment method!".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let user_country = payment_method
|
||||
.billing_details
|
||||
.address
|
||||
.as_ref()
|
||||
.and_then(|x| x.country.as_deref())
|
||||
.unwrap_or(DEFAULT_USER_COUNTRY);
|
||||
|
||||
let inferred_stripe_currency = match currency_mode {
|
||||
CurrencyMode::Set(currency) => currency,
|
||||
CurrencyMode::Infer => infer_currency_code(user_country)
|
||||
.to_lowercase()
|
||||
.parse::<Currency>()
|
||||
.map_err(|_| {
|
||||
ApiError::InvalidInput("Invalid currency code".to_string())
|
||||
})?,
|
||||
};
|
||||
|
||||
let charge_data = match attached_charge {
|
||||
AttachedCharge::UseExisting { ref charge } => ChargeData {
|
||||
amount: charge.amount,
|
||||
currency_code: charge.currency_code.clone(),
|
||||
interval: charge.subscription_interval,
|
||||
price_id: charge.price_id.into(),
|
||||
charge_type: charge.type_,
|
||||
},
|
||||
AttachedCharge::Proration {
|
||||
amount,
|
||||
next_product_id,
|
||||
next_interval,
|
||||
current_subscription: _,
|
||||
} => {
|
||||
// Use the same data as we would use when basing the charge data on
|
||||
// a product/interval pair, except override the amount and charge type
|
||||
// to the proration values.
|
||||
//
|
||||
// Then, the tax will be based on the next product, and the metadata
|
||||
// will be inserted as is desired for proration charges, except
|
||||
// the actual payment intent amount will be the proration amount.
|
||||
|
||||
let mut charge_data = derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
next_product_id,
|
||||
Some(next_interval),
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?;
|
||||
|
||||
charge_data.amount = amount;
|
||||
charge_data.charge_type = ChargeType::Proration;
|
||||
charge_data
|
||||
}
|
||||
AttachedCharge::Promotion {
|
||||
product_id,
|
||||
interval,
|
||||
current_subscription: _,
|
||||
new_region: _,
|
||||
} => {
|
||||
derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
product_id,
|
||||
Some(interval),
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
AttachedCharge::BaseUpon {
|
||||
product_id,
|
||||
interval,
|
||||
} => {
|
||||
derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
product_id,
|
||||
interval,
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
// Create an ephemeral transaction to calculate the tax amount if needed
|
||||
|
||||
let tax_amount = 'tax: {
|
||||
// If a charge is attached, we must use the tax amount noted on the charge
|
||||
// as the tax amount.
|
||||
//
|
||||
// Note: if we supported interactive payments of existing charges, we may
|
||||
// want to update the charge's tax amount immediately here.
|
||||
if let Some(c) = attached_charge.as_charge() {
|
||||
break 'tax c.tax_amount;
|
||||
}
|
||||
|
||||
let product_info =
|
||||
products_tax_identifier_item::product_info_by_product_price_id(
|
||||
charge_data.price_id.into(),
|
||||
pg,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Missing product tax identifier for charge to continue"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let address =
|
||||
payment_method.billing_details.address.clone().ok_or_else(
|
||||
|| {
|
||||
ApiError::InvalidInput(
|
||||
"Missing billing details from payment method to continue"
|
||||
.to_owned(),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
let ephemeral_invoice = anrok_client
|
||||
.create_ephemeral_txn(&anrok::TransactionFields {
|
||||
customer_address: anrok::Address::from_stripe_address(&address),
|
||||
currency_code: charge_data.currency_code.clone(),
|
||||
accounting_time: chrono::Utc::now(),
|
||||
accounting_time_zone: anrok::AccountingTimeZone::Utc,
|
||||
line_items: vec![anrok::LineItem::new(
|
||||
product_info.tax_identifier.tax_processor_id,
|
||||
charge_data.amount,
|
||||
)],
|
||||
})
|
||||
.await?;
|
||||
|
||||
ephemeral_invoice.tax_amount_to_collect
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user.id.0));
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_TYPE.to_owned(),
|
||||
charge_data.charge_type.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(MODRINTH_TAX_AMOUNT.to_owned(), tax_amount.to_string());
|
||||
|
||||
if let Some(payment_metadata) = attach_payment_metadata {
|
||||
metadata.insert(
|
||||
MODRINTH_PAYMENT_METADATA.to_owned(),
|
||||
serde_json::to_string(&payment_metadata)?,
|
||||
);
|
||||
}
|
||||
|
||||
if let AttachedCharge::UseExisting { charge } = attached_charge {
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge.id.0 as u64),
|
||||
);
|
||||
|
||||
// These are only used to post-create the charge in the stripe webhook, so
|
||||
// unset them.
|
||||
metadata.insert(MODRINTH_PRICE_ID.to_owned(), String::new());
|
||||
metadata
|
||||
.insert(MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), String::new());
|
||||
metadata.insert(MODRINTH_SUBSCRIPTION_ID.to_owned(), String::new());
|
||||
} else if let AttachedCharge::Proration {
|
||||
amount: _,
|
||||
next_product_id: _,
|
||||
next_interval,
|
||||
current_subscription,
|
||||
} = attached_charge
|
||||
{
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
next_interval.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
current_subscription.to_string(),
|
||||
);
|
||||
} else if let AttachedCharge::Promotion {
|
||||
product_id: _,
|
||||
interval,
|
||||
current_subscription,
|
||||
new_region,
|
||||
} = attached_charge
|
||||
{
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
interval.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
current_subscription.to_string(),
|
||||
);
|
||||
metadata.insert(MODRINTH_NEW_REGION.to_owned(), new_region);
|
||||
} else {
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
let subscription_id =
|
||||
generate_user_subscription_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
to_base62(subscription_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
|
||||
if let Some(interval) = charge_data.interval {
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
interval.as_str().to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(payment_intent_id) = existing_payment_intent {
|
||||
let update_payment_intent = stripe::UpdatePaymentIntent {
|
||||
amount: Some(charge_data.amount + tax_amount),
|
||||
currency: Some(inferred_stripe_currency),
|
||||
customer: Some(customer_id),
|
||||
metadata: Some(metadata),
|
||||
payment_method: Some(payment_method.id.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
stripe::PaymentIntent::update(
|
||||
stripe_client,
|
||||
&payment_intent_id,
|
||||
update_payment_intent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(PaymentBootstrapResults {
|
||||
new_payment_intent: None,
|
||||
payment_method,
|
||||
price_id: charge_data.price_id,
|
||||
subtotal: charge_data.amount,
|
||||
tax: tax_amount,
|
||||
})
|
||||
} else {
|
||||
let mut intent = CreatePaymentIntent::new(
|
||||
charge_data.amount + tax_amount,
|
||||
inferred_stripe_currency,
|
||||
);
|
||||
|
||||
intent.customer = Some(customer_id);
|
||||
intent.metadata = Some(metadata);
|
||||
intent.receipt_email = user.email.as_deref();
|
||||
intent.payment_method = Some(payment_method.id.clone());
|
||||
|
||||
payment_session.set_payment_intent_session_options(&mut intent);
|
||||
|
||||
let payment_intent =
|
||||
stripe::PaymentIntent::create(stripe_client, intent).await?;
|
||||
|
||||
Ok(PaymentBootstrapResults {
|
||||
new_payment_intent: Some(payment_intent),
|
||||
payment_method,
|
||||
price_id: charge_data.price_id,
|
||||
subtotal: charge_data.amount,
|
||||
tax: tax_amount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_customer(
|
||||
user_id: ariadne::ids::UserId,
|
||||
stripe_customer_id: Option<&str>,
|
||||
user_email: Option<&str>,
|
||||
client: &stripe::Client,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<CustomerId, ApiError> {
|
||||
if let Some(customer_id) =
|
||||
stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok())
|
||||
{
|
||||
Ok(customer_id)
|
||||
} else {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user_id.0));
|
||||
|
||||
let customer = stripe::Customer::create(
|
||||
client,
|
||||
CreateCustomer {
|
||||
email: user_email,
|
||||
metadata: Some(metadata),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET stripe_customer_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
customer.id.as_str(),
|
||||
user_id.0 as i64
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
crate::database::models::user_item::DBUser::clear_caches(
|
||||
&[(user_id.into(), None)],
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(customer.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_currency_code(country: &str) -> String {
|
||||
match country {
|
||||
"US" => "USD",
|
||||
"GB" => "GBP",
|
||||
"EU" => "EUR",
|
||||
"AT" => "EUR",
|
||||
"BE" => "EUR",
|
||||
"CY" => "EUR",
|
||||
"EE" => "EUR",
|
||||
"ES" => "EUR",
|
||||
"FI" => "EUR",
|
||||
"FR" => "EUR",
|
||||
"DE" => "EUR",
|
||||
"GR" => "EUR",
|
||||
"IE" => "EUR",
|
||||
"IT" => "EUR",
|
||||
"LV" => "EUR",
|
||||
"LT" => "EUR",
|
||||
"LU" => "EUR",
|
||||
"MT" => "EUR",
|
||||
"NL" => "EUR",
|
||||
"PT" => "EUR",
|
||||
"SK" => "EUR",
|
||||
"SI" => "EUR",
|
||||
"RU" => "RUB",
|
||||
"BR" => "BRL",
|
||||
"JP" => "JPY",
|
||||
"ID" => "IDR",
|
||||
"MY" => "MYR",
|
||||
"PH" => "PHP",
|
||||
"TH" => "THB",
|
||||
"VN" => "VND",
|
||||
"KR" => "KRW",
|
||||
"TR" => "TRY",
|
||||
"UA" => "UAH",
|
||||
"MX" => "MXN",
|
||||
"CA" => "CAD",
|
||||
"NZ" => "NZD",
|
||||
"NO" => "NOK",
|
||||
"PL" => "PLN",
|
||||
"CH" => "CHF",
|
||||
"LI" => "CHF",
|
||||
"IN" => "INR",
|
||||
"CL" => "CLP",
|
||||
"PE" => "PEN",
|
||||
"CO" => "COP",
|
||||
"ZA" => "ZAR",
|
||||
"HK" => "HKD",
|
||||
"AR" => "ARS",
|
||||
"KZ" => "KZT",
|
||||
"UY" => "UYU",
|
||||
"CN" => "CNY",
|
||||
"AU" => "AUD",
|
||||
"TW" => "TWD",
|
||||
"SA" => "SAR",
|
||||
"QA" => "QAR",
|
||||
"SG" => "SGD",
|
||||
_ => "USD",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
struct ChargeData {
|
||||
pub amount: i64,
|
||||
pub currency_code: String,
|
||||
pub interval: Option<PriceDuration>,
|
||||
pub price_id: ProductPriceId,
|
||||
pub charge_type: ChargeType,
|
||||
}
|
||||
|
||||
async fn derive_charge_data_from_product_selector(
|
||||
pool: &PgPool,
|
||||
user_id: UserId,
|
||||
product_id: ProductId,
|
||||
interval: Option<PriceDuration>,
|
||||
stripe_currency: Currency,
|
||||
) -> Result<ChargeData, ApiError> {
|
||||
let recommended_currency_code = stripe_currency.to_string().to_uppercase();
|
||||
|
||||
let product = product_item::DBProduct::get(product_id.into(), pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Specified product could not be found!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut product_prices =
|
||||
product_item::DBProductPrice::get_all_public_product_prices(
|
||||
product.id, pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let price_item = if let Some(pos) = product_prices
|
||||
.iter()
|
||||
.position(|x| x.currency_code == recommended_currency_code)
|
||||
{
|
||||
product_prices.remove(pos)
|
||||
} else if let Some(pos) =
|
||||
product_prices.iter().position(|x| x.currency_code == "USD")
|
||||
{
|
||||
product_prices.remove(pos)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let price = match price_item.prices {
|
||||
Price::OneTime { price } => price,
|
||||
Price::Recurring { ref intervals } => {
|
||||
let interval = interval.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
*intervals.get(&interval).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if let Price::Recurring { .. } = price_item.prices
|
||||
&& product.unitary
|
||||
{
|
||||
let user_subscriptions =
|
||||
user_subscription_item::DBUserSubscription::get_all_user(
|
||||
user_id.into(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_products = product_item::DBProductPrice::get_many(
|
||||
&user_subscriptions
|
||||
.iter()
|
||||
.filter(|x| x.status == SubscriptionStatus::Provisioned)
|
||||
.map(|x| x.price_id)
|
||||
.collect::<Vec<_>>(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if user_products
|
||||
.into_iter()
|
||||
.any(|x| x.product_id == product.id)
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You are already subscribed to this product!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChargeData {
|
||||
amount: price as i64,
|
||||
currency_code: price_item.currency_code.clone(),
|
||||
interval,
|
||||
price_id: price_item.id.into(),
|
||||
charge_type: if let Price::Recurring { .. } = price_item.prices {
|
||||
ChargeType::Subscription
|
||||
} else {
|
||||
ChargeType::OneTime
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use crate::database::models::users_redeemals::{
|
||||
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::queue::billing::try_process_user_redeemal;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::billing::try_process_user_redeemal;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
|
||||
pub mod statuses;
|
||||
|
||||
pub use super::ApiError;
|
||||
|
||||
@@ -143,6 +143,8 @@ pub enum ApiError {
|
||||
Conflict(String),
|
||||
#[error("External tax compliance API Error")]
|
||||
TaxComplianceApi,
|
||||
#[error(transparent)]
|
||||
TaxProcessor(#[from] crate::util::anrok::AnrokError),
|
||||
#[error(
|
||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||
)]
|
||||
@@ -184,6 +186,7 @@ impl ApiError {
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
ApiError::Stripe(..) => "stripe_error",
|
||||
ApiError::TaxProcessor(..) => "tax_processor_error",
|
||||
ApiError::Slack(..) => "slack_error",
|
||||
},
|
||||
description: self.to_string(),
|
||||
@@ -223,6 +226,7 @@ impl actix_web::ResponseError for ApiError {
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user