Initial servers upgrades frontend (#3219)

* Initial servers upgrades frontend

* Fix error when purchasing non-custom servers

* fix backend

* Fix comment

---------

Signed-off-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2025-02-12 18:22:49 -08:00
committed by GitHub
parent 6d810a421a
commit 56ba342346
5 changed files with 389 additions and 171 deletions

View File

@@ -1,5 +1,5 @@
<template>
<section class="universal-card">
<section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
<div class="universal-card recessed">
@@ -88,67 +88,69 @@
v-if="midasCharge && midasCharge.status === 'failed'"
class="ml-auto flex flex-row-reverse items-center gap-2"
>
<ButtonStyled v-if="midasCharge && midasCharge.status === 'failed'">
<button
@click="
() => {
$refs.midasPurchaseModal.show();
}
"
>
<UpdatedIcon />
Update method
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:dropdown-id="`${baseId}-cancel-midas`"
:options="[
{
id: 'cancel',
action: () => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
},
},
]"
>
<MoreVerticalIcon />
<template #cancel><XIcon /> Cancel</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else-if="midasCharge && midasCharge.status !== 'cancelled'">
<button
v-if="midasCharge && midasCharge.status === 'failed'"
class="iconified-button raised-button"
class="ml-auto"
@click="
() => {
purchaseModalStep = 0;
$refs.purchaseModal.show();
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<UpdatedIcon />
Update method
<XIcon /> Cancel
</button>
<OverflowMenu
class="btn icon-only transparent"
:options="[
{
id: 'cancel',
action: () => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
},
},
]"
>
<MoreVerticalIcon />
<template #cancel><XIcon /> Cancel</template>
</OverflowMenu>
</div>
<button
v-else-if="midasCharge && midasCharge.status !== 'cancelled'"
class="iconified-button raised-button !ml-auto"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<XIcon /> Cancel
</button>
<button
</ButtonStyled>
<ButtonStyled
v-else-if="midasCharge && midasCharge.status === 'cancelled'"
class="btn btn-purple btn-large ml-auto"
@click="cancelSubscription(midasSubscription.id, false)"
color="purple"
>
<RightArrowIcon /> Resubscribe
</button>
<button
v-else
class="btn btn-purple btn-large ml-auto"
@click="
() => {
purchaseModalStep = 0;
$refs.purchaseModal.show();
}
"
>
<RightArrowIcon />
Subscribe
</button>
<button class="ml-auto" @click="cancelSubscription(midasSubscription.id, false)">
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else color="purple" size="large">
<button
class="ml-auto"
@click="
() => {
$refs.midasPurchaseModal.show();
}
"
>
Subscribe <RightArrowIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -282,25 +284,37 @@
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
type="standard"
@click="showPyroCancelModal(subscription.id)"
>
<button class="text-contrast">
<button @click="showPyroCancelModal(subscription.id)">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
color="green"
color-fill="text"
>
<button @click="showPyroUpgradeModal(subscription)">
<ArrowBigUpDashIcon />
Upgrade
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="
getPyroCharge(subscription) &&
(getPyroCharge(subscription).status === 'cancelled' ||
getPyroCharge(subscription).status === 'failed')
"
type="standard"
color="green"
@click="resubscribePyro(subscription.id)"
>
<button class="text-contrast">Resubscribe</button>
<button @click="resubscribePyro(subscription.id)">
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled>
</div>
</div>
@@ -312,7 +326,7 @@
</div>
</section>
<section class="universal-card">
<section class="universal-card experimental-styles-within">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
@@ -321,7 +335,7 @@
@proceed="removePaymentMethod(removePaymentMethodIndex)"
/>
<PurchaseModal
ref="purchaseModal"
ref="midasPurchaseModal"
:product="midasProduct"
:country="country"
:publishable-key="config.public.stripePublishableKey"
@@ -342,6 +356,37 @@
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal
ref="pyroPurchaseModal"
:product="upgradeProducts"
:country="country"
custom-server
:existing-subscription="currentSubscription"
:existing-plan="currentProduct"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
internal: true,
method: `PATCH`,
body: body,
})
"
:renewal-date="currentSubRenewalDate"
:on-error="
(err) =>
data.$notify({
group: 'main',
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<NewModal ref="addPaymentMethodModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
@@ -359,15 +404,19 @@
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group push-right mt-auto pt-4">
<button class="btn" @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button class="btn btn-primary" :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
@@ -442,6 +491,7 @@
</div>
</div>
<OverflowMenu
:dropdown-id="`${baseId}-payment-method-overflow-${index}`"
class="btn icon-only transparent"
:options="
[
@@ -493,6 +543,7 @@ import {
} from "@modrinth/ui";
import {
PlusIcon,
ArrowBigUpDashIcon,
XIcon,
CardIcon,
MoreVerticalIcon,
@@ -515,6 +566,9 @@ definePageMeta({
middleware: "auth",
});
const auth = await useAuth();
const baseId = useId();
useHead({
script: [
{
@@ -704,7 +758,7 @@ const pyroSubscriptions = computed(() => {
});
});
const purchaseModal = ref();
const midasPurchaseModal = ref();
const country = useUserCountry();
const price = computed(() =>
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
@@ -896,6 +950,46 @@ const showPyroCancelModal = (subscriptionId) => {
}
};
const pyroPurchaseModal = ref();
const currentSubscription = ref(null);
const currentProduct = ref(null);
const upgradeProducts = ref([]);
upgradeProducts.value.metadata = { type: "pyro" };
const currentSubRenewalDate = ref();
const showPyroUpgradeModal = async (subscription) => {
currentSubscription.value = subscription;
currentSubRenewalDate.value = getPyroCharge(subscription).due;
currentProduct.value = getPyroProduct(subscription);
upgradeProducts.value = products.filter(
(p) =>
p.metadata.type === "pyro" &&
(!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram),
);
upgradeProducts.value.metadata = { type: "pyro" };
await nextTick();
if (!currentProduct.value) {
console.error("Could not find product for current subscription");
data.$notify({
group: "main",
title: "An error occurred",
text: "Could not find product for current subscription",
type: "error",
});
return;
}
if (!pyroPurchaseModal.value) {
console.error("pyroPurchaseModal ref is undefined");
return;
}
pyroPurchaseModal.value.show();
};
const resubscribePyro = async (subscriptionId) => {
try {
await useBaseFetch(`billing/subscription/${subscriptionId}`, {

View File

@@ -176,7 +176,7 @@ pub struct Charge {
pub net: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ChargeType {
OneTime,

View File

@@ -411,11 +411,7 @@ pub async fn edit_subscription(
}
let interval = open_charge.due - Utc::now();
let duration = PriceDuration::iterator()
.min_by_key(|x| {
(x.duration().num_seconds() - interval.num_seconds()).abs()
})
.unwrap_or(PriceDuration::Monthly);
let duration = PriceDuration::Monthly;
let current_amount = match &current_price.prices {
Price::OneTime { price } => *price,
@@ -461,23 +457,6 @@ pub async fn edit_subscription(
}
let charge_id = generate_charge_id(&mut transaction).await?;
let mut charge = ChargeItem {
id: charge_id,
user_id: user.id.into(),
price_id: product_price.id,
amount: proration as i64,
currency_code: current_price.currency_code.clone(),
status: ChargeStatus::Processing,
due: Utc::now(),
last_attempt: None,
type_: ChargeType::Proration,
subscription_id: Some(subscription.id),
subscription_interval: Some(duration),
payment_platform: PaymentPlatform::Stripe,
payment_platform_id: None,
parent_charge_id: None,
net: None,
};
let customer_id = get_or_create_customer(
user.id,
@@ -504,6 +483,30 @@ pub async fn edit_subscription(
"modrinth_user_id".to_string(),
to_base62(user.id.0),
);
metadata.insert(
"modrinth_charge_id".to_string(),
to_base62(charge_id.0 as u64),
);
metadata.insert(
"modrinth_subscription_id".to_string(),
to_base62(subscription.id.0 as u64),
);
metadata.insert(
"modrinth_price_id".to_string(),
to_base62(product_price.id.0 as u64),
);
metadata.insert(
"modrinth_subscription_interval".to_string(),
open_charge
.subscription_interval
.unwrap_or(PriceDuration::Monthly)
.as_str()
.to_string(),
);
metadata.insert(
"modrinth_charge_type".to_string(),
ChargeType::Proration.as_str().to_string(),
);
intent.customer = Some(customer_id);
intent.metadata = Some(metadata);
@@ -529,9 +532,6 @@ pub async fn edit_subscription(
stripe::PaymentIntent::create(&stripe_client, intent)
.await?;
charge.payment_platform_id = Some(intent.id.to_string());
charge.upsert(&mut transaction).await?;
Some((proration, 0, intent))
}
} else {
@@ -1139,7 +1139,7 @@ pub async fn initiate_payment(
let country = user_country.as_deref().unwrap_or("US");
let recommended_currency_code = infer_currency_code(country);
let (price, currency_code, interval, price_id, charge_id) =
let (price, currency_code, interval, price_id, charge_id, charge_type) =
match payment_request.charge {
ChargeRequestType::Existing { id } => {
let charge =
@@ -1160,6 +1160,7 @@ pub async fn initiate_payment(
charge.subscription_interval,
charge.price_id,
Some(id),
charge.type_,
)
}
ChargeRequestType::New {
@@ -1256,6 +1257,11 @@ pub async fn initiate_payment(
interval,
price_item.id,
None,
if let Price::Recurring { .. } = price_item.prices {
ChargeType::Subscription
} else {
ChargeType::OneTime
},
)
}
};
@@ -1314,6 +1320,11 @@ pub async fn initiate_payment(
);
}
metadata.insert(
"modrinth_charge_type".to_string(),
charge_type.as_str().to_string(),
);
if let Some(charge_id) = charge_id {
metadata.insert(
"modrinth_charge_id".to_string(),
@@ -1399,10 +1410,15 @@ pub async fn stripe_webhook(
pub user_subscription_item:
Option<user_subscription_item::UserSubscriptionItem>,
pub payment_metadata: Option<PaymentRequestMetadata>,
#[allow(dead_code)]
pub charge_type: ChargeType,
}
#[allow(clippy::too_many_arguments)]
async fn get_payment_intent_metadata(
payment_intent_id: PaymentIntentId,
amount: i64,
currency: String,
metadata: HashMap<String, String>,
pool: &PgPool,
redis: &RedisPool,
@@ -1445,6 +1461,15 @@ pub async fn stripe_webhook(
break 'metadata;
};
let charge_type = if let Some(charge_type) = metadata
.get("modrinth_charge_type")
.map(|x| ChargeType::from_string(x))
{
charge_type
} else {
break 'metadata;
};
let (charge, price, product, subscription) = if let Some(
mut charge,
) =
@@ -1549,8 +1574,8 @@ pub async fn stripe_webhook(
break 'metadata;
};
let (amount, subscription) = match &price.prices {
Price::OneTime { price } => (*price, None),
let subscription = match &price.prices {
Price::OneTime { .. } => None,
Price::Recurring { intervals } => {
let interval = if let Some(interval) = metadata
.get("modrinth_subscription_interval")
@@ -1561,7 +1586,7 @@ pub async fn stripe_webhook(
break 'metadata;
};
if let Some(price) = intervals.get(&interval) {
if intervals.get(&interval).is_some() {
let subscription_id = if let Some(subscription_id) = metadata
.get("modrinth_subscription_id")
.and_then(|x| parse_base62(x).ok())
@@ -1573,21 +1598,29 @@ pub async fn stripe_webhook(
break 'metadata;
};
let subscription = user_subscription_item::UserSubscriptionItem {
id: subscription_id,
user_id,
price_id,
interval,
created: Utc::now(),
status: SubscriptionStatus::Unprovisioned,
metadata: None,
let subscription = if let Some(mut subscription) = user_subscription_item::UserSubscriptionItem::get(subscription_id, pool).await? {
subscription.status = SubscriptionStatus::Unprovisioned;
subscription.price_id = price_id;
subscription.interval = interval;
subscription
} else {
user_subscription_item::UserSubscriptionItem {
id: subscription_id,
user_id,
price_id,
interval,
created: Utc::now(),
status: SubscriptionStatus::Unprovisioned,
metadata: None,
}
};
if charge_status != ChargeStatus::Failed {
subscription.upsert(transaction).await?;
}
(*price, Some(subscription))
Some(subscription)
} else {
break 'metadata;
}
@@ -1598,16 +1631,12 @@ pub async fn stripe_webhook(
id: charge_id,
user_id,
price_id,
amount: amount as i64,
currency_code: price.currency_code.clone(),
amount,
currency_code: currency,
status: charge_status,
due: Utc::now(),
last_attempt: Some(Utc::now()),
type_: if subscription.is_some() {
ChargeType::Subscription
} else {
ChargeType::OneTime
},
type_: charge_type,
subscription_id: subscription.as_ref().map(|x| x.id),
subscription_interval: subscription
.as_ref()
@@ -1634,6 +1663,7 @@ pub async fn stripe_webhook(
charge_item: charge,
user_subscription_item: subscription,
payment_metadata,
charge_type,
});
}
@@ -1651,6 +1681,8 @@ pub async fn stripe_webhook(
let mut metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -1899,6 +1931,8 @@ pub async fn stripe_webhook(
let mut transaction = pool.begin().await?;
get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -1917,6 +1951,8 @@ pub async fn stripe_webhook(
let metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -2320,6 +2356,10 @@ pub async fn task(
"modrinth_charge_id".to_string(),
to_base62(charge.id.0 as u64),
);
metadata.insert(
"modrinth_charge_type".to_string(),
charge.type_.as_str().to_string(),
);
intent.metadata = Some(metadata);
intent.customer = Some(customer.id);