Skip to content

Commit 5ac569a

Browse files
authored
fix: show proper review charges page on resubscribe modal (#8050)
1 parent 0201a11 commit 5ac569a

44 files changed

Lines changed: 756 additions & 855 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/frontend/src/pages/settings/billing/index.vue‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
<template>
22
<ServersUpgradeModalWrapper ref="upgradeModal" />
3-
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
3+
<ResubscribeModal
4+
ref="pyroResubscribeModal"
5+
:payment-methods="paymentMethods"
6+
:regions="regions"
7+
:on-add-payment-method="addPaymentMethod"
8+
@resubscribe="handlePyroResubscribeConfirm"
9+
/>
410
<section class="universal-card">
511
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
612
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
@@ -1057,6 +1063,11 @@ const { data: serverFullList } = useQuery({
10571063
queryFn: () => client.archon.servers_v1.list(),
10581064
})
10591065
1066+
const { data: regions } = useQuery({
1067+
queryKey: ['servers', 'regions'],
1068+
queryFn: () => client.archon.servers_v1.getRegions(),
1069+
})
1070+
10601071
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
10611072
const midasSubscription = computed(() =>
10621073
subscriptions.value?.find(
@@ -1372,22 +1383,13 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {
13721383
13731384
function openPyroResubscribeModal(subscription) {
13741385
const charge = getPyroCharge(subscription)
1375-
const product = getPyroProduct(subscription)
1376-
const interval = charge?.subscription_interval || subscription?.interval
1377-
const productPrice = getProductPrice(product, interval)
13781386
13791387
pyroResubscribeModal.value?.show({
13801388
subscriptionId: subscription?.id ?? '',
13811389
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
13821390
serverName: subscription?.serverInfo?.name ?? 'this server',
1383-
planName: `${getProductSize(product)} plan`,
1384-
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
1385-
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
1386-
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
1387-
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
1388-
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
1389-
interval,
1390-
nextChargeDate: charge?.due,
1391+
plan: getPyroProduct(subscription),
1392+
regionShortcode: subscription?.metadata?.region,
13911393
})
13921394
}
13931395

‎apps/labrinth/src/queue/billing.rs‎

Lines changed: 9 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,6 @@ async fn update_tax_amounts(
8383
let charge_clone = charge.clone();
8484

8585
let op_fut = async move {
86-
let tax_id = DBProductsTaxIdentifier::get_price(
87-
charge.price_id,
88-
&pg,
89-
)
90-
.await
91-
.wrap_api_err("fetching price")?
92-
.wrap_internal_err(
93-
"finding product tax identifier for price",
94-
)?;
95-
9686
let product = DBProduct::get_price(charge.price_id, &pg)
9787
.await
9888
.wrap_internal_err(
@@ -102,83 +92,15 @@ async fn update_tax_amounts(
10292
"finding product price in database",
10393
)?;
10494

105-
let stripe_address = 'a: {
106-
let stripe_customer =
107-
DBUser::get_id(charge.user_id, &pg, &redis)
108-
.await
109-
.wrap_internal_err(
110-
"fetching Stripe customer from database",
111-
)?
112-
.wrap_internal_err(
113-
"finding Stripe customer in database",
114-
)?;
115-
let stripe_customer_id = stripe_customer
116-
.stripe_customer_id
117-
.wrap_request_err(
118-
"finding Stripe customer ID on user",
119-
)?
120-
.parse()
121-
.wrap_request_err(
122-
"parsing user Stripe customer ID",
123-
)?;
124-
125-
let payment_method = get_default_payment_method(
126-
&stripe_client,
127-
&stripe_customer_id,
128-
)
129-
.await?
130-
.wrap_request_err_with(|| {
131-
"customer has no default payment method"
132-
.to_string()
133-
})?;
134-
135-
let stripe_address =
136-
payment_method.billing_details.address;
137-
138-
// Attempt the default payment method's address first, then the customer's address.
139-
match stripe_address {
140-
Some(address) => break 'a address,
141-
None => {
142-
warn!("PaymentMethod had no address");
143-
}
144-
};
145-
146-
let customer = stripe::Customer::retrieve(
147-
&stripe_client,
148-
&stripe_customer_id,
149-
&[],
150-
)
151-
.await
152-
.wrap_failed_dependency_err(
153-
"communicating with payment provider",
154-
)?;
155-
156-
customer.address.wrap_request_err_with(|| {
157-
"couldn't get an address for the Stripe customer"
158-
.to_owned()
159-
})?
160-
};
161-
162-
let customer_address =
163-
anrok::Address::from_stripe_address(&stripe_address);
164-
165-
let tax_amount = anrok_client
166-
.create_ephemeral_txn(&anrok::TransactionFields {
167-
customer_address,
168-
currency_code: charge.currency_code.clone(),
169-
accounting_time: charge.due,
170-
accounting_time_zone:
171-
anrok::AccountingTimeZone::Utc,
172-
line_items: vec![anrok::LineItem::new(
173-
tax_id.tax_processor_id,
174-
charge.amount,
175-
)],
176-
customer_id: None,
177-
customer_name: None,
178-
})
179-
.await
180-
.wrap_internal_err("inserting database records for `update_tax_amounts`")?
181-
.tax_amount_to_collect;
95+
let tax_amount = quote_charge_tax(
96+
&pg,
97+
&redis,
98+
&stripe_client,
99+
&anrok_client,
100+
&charge,
101+
)
102+
.await?
103+
.tax_amount;
182104

183105
Result::<ProcessedCharge, ApiError>::Ok(ProcessedCharge {
184106
new_tax_amount: tax_amount,

‎apps/labrinth/src/routes/internal/billing.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,38 @@ pub struct SubscriptionEditQuery {
678678
pub dry: Option<bool>,
679679
}
680680

681+
/// Returned by a dry run of resubscribing to a subscription (`cancelled: false`).
682+
#[derive(Serialize)]
683+
struct ResubscribePreview {
684+
pub subtotal: i64,
685+
pub tax: i64,
686+
pub total: i64,
687+
pub currency_code: String,
688+
pub interval: Option<PriceDuration>,
689+
pub due: chrono::DateTime<Utc>,
690+
pub payment_method: stripe::PaymentMethod,
691+
}
692+
693+
/// Recalculates the tax amount of a charge being resubscribed to, as the tax amount
694+
/// isn't kept up to date while the charge is cancelled or failed.
695+
async fn refresh_charge_tax(
696+
pool: &PgPool,
697+
redis: &RedisPool,
698+
stripe_client: &stripe::Client,
699+
anrok_client: &anrok::Client,
700+
charge: &mut charge_item::DBCharge,
701+
) -> Result<ChargeTaxQuote, ApiError> {
702+
let quote =
703+
quote_charge_tax(pool, redis, stripe_client, anrok_client, charge)
704+
.await
705+
.wrap_api_err("calculating tax amount for resubscription")?;
706+
707+
charge.tax_amount = quote.tax_amount;
708+
charge.tax_last_updated = Some(Utc::now());
709+
710+
Ok(quote)
711+
}
712+
681713
/// Update a subscription.
682714
#[utoipa::path(
683715
context_path = "/billing",
@@ -871,6 +903,8 @@ pub async fn edit_subscription(
871903
"could not find current product price".to_string()
872904
})?;
873905

906+
let mut resubscribe_quote = None;
907+
874908
let maybe_intent_metadata = match edit_subscription.into_inner() {
875909
// Case of toggling cancellation when the next charge is a failed charge
876910
SubscriptionEdit {
@@ -880,6 +914,16 @@ pub async fn edit_subscription(
880914
if cancelled {
881915
open_charge.status = ChargeStatus::Cancelled;
882916
} else {
917+
resubscribe_quote = Some(
918+
refresh_charge_tax(
919+
&pool,
920+
&redis,
921+
&stripe_client,
922+
&anrok_client,
923+
&mut open_charge,
924+
)
925+
.await?,
926+
);
883927
// Forces another resubscription attempt
884928
open_charge.last_attempt = Some(Utc::now() - Duration::days(2));
885929
}
@@ -899,6 +943,16 @@ pub async fn edit_subscription(
899943
open_charge.status = if cancelled {
900944
ChargeStatus::Cancelled
901945
} else {
946+
resubscribe_quote = Some(
947+
refresh_charge_tax(
948+
&pool,
949+
&redis,
950+
&stripe_client,
951+
&anrok_client,
952+
&mut open_charge,
953+
)
954+
.await?,
955+
);
902956
ChargeStatus::Open
903957
};
904958

@@ -1134,6 +1188,18 @@ pub async fn edit_subscription(
11341188
}
11351189
};
11361190

1191+
if dry && let Some(quote) = resubscribe_quote {
1192+
return Ok(HttpResponse::Ok().json(ResubscribePreview {
1193+
subtotal: open_charge.amount,
1194+
tax: quote.tax_amount,
1195+
total: open_charge.amount + quote.tax_amount,
1196+
currency_code: open_charge.currency_code,
1197+
interval: open_charge.subscription_interval,
1198+
due: open_charge.due,
1199+
payment_method: quote.payment_method,
1200+
}));
1201+
}
1202+
11371203
if !dry {
11381204
// If `?dry=true`, don't actually commit the changes.
11391205
//

‎apps/labrinth/src/routes/internal/billing/payments.rs‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,95 @@ pub async fn get_default_payment_method(
735735
Ok(Some(payment_method))
736736
}
737737

738+
pub struct ChargeTaxQuote {
739+
pub tax_amount: i64,
740+
/// The default payment method which will be charged for this charge.
741+
pub payment_method: PaymentMethod,
742+
}
743+
744+
/// Calculates the current tax amount for an existing charge, based on the address of the
745+
/// customer's default payment method, falling back to the customer's address.
746+
///
747+
/// This does not update the charge.
748+
pub async fn quote_charge_tax(
749+
pg: &PgPool,
750+
redis: &RedisPool,
751+
stripe_client: &stripe::Client,
752+
anrok_client: &anrok::Client,
753+
charge: &DBCharge,
754+
) -> Result<ChargeTaxQuote, ApiError> {
755+
let tax_id =
756+
products_tax_identifier_item::DBProductsTaxIdentifier::get_price(
757+
charge.price_id,
758+
pg,
759+
)
760+
.await
761+
.wrap_api_err("fetching price")?
762+
.wrap_internal_err("finding product tax identifier for price")?;
763+
764+
let user = crate::database::models::user_item::DBUser::get_id(
765+
charge.user_id,
766+
pg,
767+
redis,
768+
)
769+
.await
770+
.wrap_internal_err("fetching Stripe customer from database")?
771+
.wrap_internal_err("finding Stripe customer in database")?;
772+
let stripe_customer_id: CustomerId = user
773+
.stripe_customer_id
774+
.wrap_request_err("finding Stripe customer ID on user")?
775+
.parse()
776+
.wrap_request_err("parsing user Stripe customer ID")?;
777+
778+
let payment_method =
779+
get_default_payment_method(stripe_client, &stripe_customer_id)
780+
.await?
781+
.wrap_request_err_with(|| {
782+
"customer has no default payment method".to_string()
783+
})?;
784+
785+
let stripe_address = match &payment_method.billing_details.address {
786+
Some(address) => address.clone(),
787+
None => {
788+
tracing::warn!("PaymentMethod had no address");
789+
790+
stripe::Customer::retrieve(stripe_client, &stripe_customer_id, &[])
791+
.await
792+
.wrap_failed_dependency_err(
793+
"communicating with payment provider",
794+
)?
795+
.address
796+
.wrap_request_err_with(|| {
797+
"couldn't get an address for the Stripe customer".to_owned()
798+
})?
799+
}
800+
};
801+
802+
let tax_amount = anrok_client
803+
.create_ephemeral_txn(&anrok::TransactionFields {
804+
customer_address: anrok::Address::from_stripe_address(
805+
&stripe_address,
806+
),
807+
currency_code: charge.currency_code.clone(),
808+
accounting_time: charge.due.max(chrono::Utc::now()),
809+
accounting_time_zone: anrok::AccountingTimeZone::Utc,
810+
line_items: vec![anrok::LineItem::new(
811+
tax_id.tax_processor_id,
812+
charge.amount,
813+
)],
814+
customer_id: None,
815+
customer_name: None,
816+
})
817+
.await
818+
.wrap_internal_err("calculating tax amount for charge")?
819+
.tax_amount_to_collect;
820+
821+
Ok(ChargeTaxQuote {
822+
tax_amount,
823+
payment_method,
824+
})
825+
}
826+
738827
/// Makes the customer's newest payment method the default if the customer has no
739828
/// default payment method left after `removed` was detached.
740829
pub async fn promote_default_payment_method(

‎packages/api-client/src/modules/labrinth/billing/internal.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ export class LabrinthBillingInternalModule extends AbstractModule {
7171
)
7272
}
7373

74+
/**
75+
* Preview the cost of resubscribing to a cancelled subscription, with freshly calculated tax
76+
* PATCH /_internal/billing/subscription/{id}?dry=true
77+
*/
78+
public async previewResubscribe(
79+
id: string,
80+
): Promise<Labrinth.Billing.Internal.ResubscribePreview> {
81+
return this.client.request<Labrinth.Billing.Internal.ResubscribePreview>(
82+
`/billing/subscription/${id}?dry=true`,
83+
{
84+
api: 'labrinth',
85+
version: 'internal',
86+
method: 'PATCH',
87+
body: { cancelled: false },
88+
},
89+
)
90+
}
91+
7492
/**
7593
* Get user's payment methods
7694
* GET /_internal/billing/payment_methods

‎packages/api-client/src/modules/labrinth/types.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ export namespace Labrinth {
164164
total: number
165165
}
166166

167+
export type ResubscribePreview = {
168+
subtotal: number
169+
tax: number
170+
total: number
171+
currency_code: string
172+
interval: PriceDuration | null
173+
due: string
174+
payment_method: unknown
175+
}
176+
167177
export type AddPaymentMethodFlowResponse = {
168178
client_secret: string
169179
}

0 commit comments

Comments
 (0)