Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions apps/frontend/src/pages/settings/billing/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<template>
<ServersUpgradeModalWrapper ref="upgradeModal" />
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
<ResubscribeModal
ref="pyroResubscribeModal"
:payment-methods="paymentMethods"
:regions="regions"
:on-add-payment-method="addPaymentMethod"
@resubscribe="handlePyroResubscribeConfirm"
/>
<section class="universal-card">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
Expand Down Expand Up @@ -1057,6 +1063,11 @@ const { data: serverFullList } = useQuery({
queryFn: () => client.archon.servers_v1.list(),
})

const { data: regions } = useQuery({
queryKey: ['servers', 'regions'],
queryFn: () => client.archon.servers_v1.getRegions(),
})

const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
const midasSubscription = computed(() =>
subscriptions.value?.find(
Expand Down Expand Up @@ -1372,22 +1383,13 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {

function openPyroResubscribeModal(subscription) {
const charge = getPyroCharge(subscription)
const product = getPyroProduct(subscription)
const interval = charge?.subscription_interval || subscription?.interval
const productPrice = getProductPrice(product, interval)

pyroResubscribeModal.value?.show({
subscriptionId: subscription?.id ?? '',
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
serverName: subscription?.serverInfo?.name ?? 'this server',
planName: `${getProductSize(product)} plan`,
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
interval,
nextChargeDate: charge?.due,
plan: getPyroProduct(subscription),
regionShortcode: subscription?.metadata?.region,
})
}

Expand Down
96 changes: 9 additions & 87 deletions apps/labrinth/src/queue/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,6 @@ async fn update_tax_amounts(
let charge_clone = charge.clone();

let op_fut = async move {
let tax_id = DBProductsTaxIdentifier::get_price(
charge.price_id,
&pg,
)
.await
.wrap_api_err("fetching price")?
.wrap_internal_err(
"finding product tax identifier for price",
)?;

let product = DBProduct::get_price(charge.price_id, &pg)
.await
.wrap_internal_err(
Expand All @@ -102,83 +92,15 @@ async fn update_tax_amounts(
"finding product price in database",
)?;

let stripe_address = 'a: {
let stripe_customer =
DBUser::get_id(charge.user_id, &pg, &redis)
.await
.wrap_internal_err(
"fetching Stripe customer from database",
)?
.wrap_internal_err(
"finding Stripe customer in database",
)?;
let stripe_customer_id = stripe_customer
.stripe_customer_id
.wrap_request_err(
"finding Stripe customer ID on user",
)?
.parse()
.wrap_request_err(
"parsing user Stripe customer ID",
)?;

let payment_method = get_default_payment_method(
&stripe_client,
&stripe_customer_id,
)
.await?
.wrap_request_err_with(|| {
"customer has no default payment method"
.to_string()
})?;

let stripe_address =
payment_method.billing_details.address;

// Attempt the default payment method's address first, then the customer's address.
match stripe_address {
Some(address) => break 'a address,
None => {
warn!("PaymentMethod had no address");
}
};

let customer = stripe::Customer::retrieve(
&stripe_client,
&stripe_customer_id,
&[],
)
.await
.wrap_failed_dependency_err(
"communicating with payment provider",
)?;

customer.address.wrap_request_err_with(|| {
"couldn't get an address for the Stripe customer"
.to_owned()
})?
};

let customer_address =
anrok::Address::from_stripe_address(&stripe_address);

let tax_amount = anrok_client
.create_ephemeral_txn(&anrok::TransactionFields {
customer_address,
currency_code: charge.currency_code.clone(),
accounting_time: charge.due,
accounting_time_zone:
anrok::AccountingTimeZone::Utc,
line_items: vec![anrok::LineItem::new(
tax_id.tax_processor_id,
charge.amount,
)],
customer_id: None,
customer_name: None,
})
.await
.wrap_internal_err("inserting database records for `update_tax_amounts`")?
.tax_amount_to_collect;
let tax_amount = quote_charge_tax(
&pg,
&redis,
&stripe_client,
&anrok_client,
&charge,
)
.await?
.tax_amount;

Result::<ProcessedCharge, ApiError>::Ok(ProcessedCharge {
new_tax_amount: tax_amount,
Expand Down
66 changes: 66 additions & 0 deletions apps/labrinth/src/routes/internal/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,38 @@ pub struct SubscriptionEditQuery {
pub dry: Option<bool>,
}

/// Returned by a dry run of resubscribing to a subscription (`cancelled: false`).
#[derive(Serialize)]
struct ResubscribePreview {
pub subtotal: i64,
pub tax: i64,
pub total: i64,
pub currency_code: String,
pub interval: Option<PriceDuration>,
pub due: chrono::DateTime<Utc>,
pub payment_method: stripe::PaymentMethod,
}

/// Recalculates the tax amount of a charge being resubscribed to, as the tax amount
/// isn't kept up to date while the charge is cancelled or failed.
async fn refresh_charge_tax(
pool: &PgPool,
redis: &RedisPool,
stripe_client: &stripe::Client,
anrok_client: &anrok::Client,
charge: &mut charge_item::DBCharge,
) -> Result<ChargeTaxQuote, ApiError> {
let quote =
quote_charge_tax(pool, redis, stripe_client, anrok_client, charge)
.await
.wrap_api_err("calculating tax amount for resubscription")?;

charge.tax_amount = quote.tax_amount;
charge.tax_last_updated = Some(Utc::now());

Ok(quote)
}

/// Update a subscription.
#[utoipa::path(
context_path = "/billing",
Expand Down Expand Up @@ -871,6 +903,8 @@ pub async fn edit_subscription(
"could not find current product price".to_string()
})?;

let mut resubscribe_quote = None;

let maybe_intent_metadata = match edit_subscription.into_inner() {
// Case of toggling cancellation when the next charge is a failed charge
SubscriptionEdit {
Expand All @@ -880,6 +914,16 @@ pub async fn edit_subscription(
if cancelled {
open_charge.status = ChargeStatus::Cancelled;
} else {
resubscribe_quote = Some(
refresh_charge_tax(
&pool,
&redis,
&stripe_client,
&anrok_client,
&mut open_charge,
)
.await?,
);
// Forces another resubscription attempt
open_charge.last_attempt = Some(Utc::now() - Duration::days(2));
}
Expand All @@ -899,6 +943,16 @@ pub async fn edit_subscription(
open_charge.status = if cancelled {
ChargeStatus::Cancelled
} else {
resubscribe_quote = Some(
refresh_charge_tax(
&pool,
&redis,
&stripe_client,
&anrok_client,
&mut open_charge,
)
.await?,
);
ChargeStatus::Open
};

Expand Down Expand Up @@ -1134,6 +1188,18 @@ pub async fn edit_subscription(
}
};

if dry && let Some(quote) = resubscribe_quote {
return Ok(HttpResponse::Ok().json(ResubscribePreview {
subtotal: open_charge.amount,
tax: quote.tax_amount,
total: open_charge.amount + quote.tax_amount,
currency_code: open_charge.currency_code,
interval: open_charge.subscription_interval,
due: open_charge.due,
payment_method: quote.payment_method,
}));
}

if !dry {
// If `?dry=true`, don't actually commit the changes.
//
Expand Down
89 changes: 89 additions & 0 deletions apps/labrinth/src/routes/internal/billing/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,95 @@ pub async fn get_default_payment_method(
Ok(Some(payment_method))
}

pub struct ChargeTaxQuote {
pub tax_amount: i64,
/// The default payment method which will be charged for this charge.
pub payment_method: PaymentMethod,
}

/// Calculates the current tax amount for an existing charge, based on the address of the
/// customer's default payment method, falling back to the customer's address.
///
/// This does not update the charge.
pub async fn quote_charge_tax(
pg: &PgPool,
redis: &RedisPool,
stripe_client: &stripe::Client,
anrok_client: &anrok::Client,
charge: &DBCharge,
) -> Result<ChargeTaxQuote, ApiError> {
let tax_id =
products_tax_identifier_item::DBProductsTaxIdentifier::get_price(
charge.price_id,
pg,
)
.await
.wrap_api_err("fetching price")?
.wrap_internal_err("finding product tax identifier for price")?;

let user = crate::database::models::user_item::DBUser::get_id(
charge.user_id,
pg,
redis,
)
.await
.wrap_internal_err("fetching Stripe customer from database")?
.wrap_internal_err("finding Stripe customer in database")?;
let stripe_customer_id: CustomerId = user
.stripe_customer_id
.wrap_request_err("finding Stripe customer ID on user")?
.parse()
.wrap_request_err("parsing user Stripe customer ID")?;

let payment_method =
get_default_payment_method(stripe_client, &stripe_customer_id)
.await?
.wrap_request_err_with(|| {
"customer has no default payment method".to_string()
})?;

let stripe_address = match &payment_method.billing_details.address {
Some(address) => address.clone(),
None => {
tracing::warn!("PaymentMethod had no address");

stripe::Customer::retrieve(stripe_client, &stripe_customer_id, &[])
.await
.wrap_failed_dependency_err(
"communicating with payment provider",
)?
.address
.wrap_request_err_with(|| {
"couldn't get an address for the Stripe customer".to_owned()
})?
}
};

let tax_amount = anrok_client
.create_ephemeral_txn(&anrok::TransactionFields {
customer_address: anrok::Address::from_stripe_address(
&stripe_address,
),
currency_code: charge.currency_code.clone(),
accounting_time: charge.due.max(chrono::Utc::now()),
accounting_time_zone: anrok::AccountingTimeZone::Utc,
line_items: vec![anrok::LineItem::new(
tax_id.tax_processor_id,
charge.amount,
)],
customer_id: None,
customer_name: None,
})
.await
.wrap_internal_err("calculating tax amount for charge")?
.tax_amount_to_collect;

Ok(ChargeTaxQuote {
tax_amount,
payment_method,
})
}

/// Makes the customer's newest payment method the default if the customer has no
/// default payment method left after `removed` was detached.
pub async fn promote_default_payment_method(
Expand Down
18 changes: 18 additions & 0 deletions packages/api-client/src/modules/labrinth/billing/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ export class LabrinthBillingInternalModule extends AbstractModule {
)
}

/**
* Preview the cost of resubscribing to a cancelled subscription, with freshly calculated tax
* PATCH /_internal/billing/subscription/{id}?dry=true
*/
public async previewResubscribe(
id: string,
): Promise<Labrinth.Billing.Internal.ResubscribePreview> {
return this.client.request<Labrinth.Billing.Internal.ResubscribePreview>(
`/billing/subscription/${id}?dry=true`,
{
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: { cancelled: false },
},
)
}

/**
* Get user's payment methods
* GET /_internal/billing/payment_methods
Expand Down
10 changes: 10 additions & 0 deletions packages/api-client/src/modules/labrinth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ export namespace Labrinth {
total: number
}

export type ResubscribePreview = {
subtotal: number
tax: number
total: number
currency_code: string
interval: PriceDuration | null
due: string
payment_method: unknown
}

export type AddPaymentMethodFlowResponse = {
client_secret: string
}
Expand Down
Loading
Loading