Fixes to billing

This commit is contained in:
Prospector 2025-06-03 09:21:19 -07:00
parent c0accb42fa
commit 4441be5380
7 changed files with 115 additions and 91 deletions

View File

@ -520,27 +520,19 @@
</OptionGroup> </OptionGroup>
<template v-if="billingPeriods.includes('quarterly')"> <template v-if="billingPeriods.includes('quarterly')">
<button <button
v-if="billingPeriod !== 'quarterly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95" class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'quarterly'" @click="billingPeriod = 'quarterly'"
> >
Save 16% with quarterly billing! Save 16% with quarterly billing!
</button> </button>
<span v-else class="text-sm font-medium text-primary">
Saving 16% with quarterly billing!
</span>
</template> </template>
<template v-else-if="billingPeriods.includes('yearly')"> <template v-else-if="billingPeriods.includes('yearly')">
<button <button
v-if="billingPeriod !== 'yearly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95" class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'yearly'" @click="billingPeriod = 'yearly'"
> >
Save 16% with yearly billing! Save 16% with yearly billing!
</button> </button>
<span v-else class="text-sm font-medium text-primary">
Saving 16% with yearly billing!
</span>
</template> </template>
<span v-else></span> <span v-else></span>
</div> </div>
@ -639,7 +631,7 @@ import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue";
const billingPeriods = ref(["monthly", "yearly"]); const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly"); const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
const pyroProducts = products.filter((p) => p.metadata.type === "pyro"); const pyroProducts = products.filter((p) => p.metadata.type === "pyro");

View File

@ -8,6 +8,7 @@ import {
RightArrowIcon, RightArrowIcon,
XIcon, XIcon,
CheckCircleIcon, CheckCircleIcon,
SpinnerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import type { import type {
CreatePaymentIntentRequest, CreatePaymentIntentRequest,
@ -27,6 +28,7 @@ import RegionSelector from './ServersPurchase1Region.vue'
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue' import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue' import ConfirmPurchase from './ServersPurchase3Review.vue'
import { useStripe } from '../../composables/stripe' import { useStripe } from '../../composables/stripe'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@ -49,11 +51,12 @@ const props = defineProps<{
initiatePayment: ( initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse> ) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
onError: (err: Error) => void
}>() }>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal') const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<ServerPlan>() const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>() const selectedInterval = ref<ServerBillingInterval>('quarterly')
const loading = ref(false) const loading = ref(false)
const { const {
@ -72,21 +75,22 @@ const {
reloadPaymentIntent, reloadPaymentIntent,
hasPaymentMethod, hasPaymentMethod,
submitPayment, submitPayment,
completingPurchase,
} = useStripe( } = useStripe(
props.publishableKey, props.publishableKey,
props.customer, props.customer,
props.paymentMethods, props.paymentMethods,
props.clientSecret,
props.currency, props.currency,
selectedPlan, selectedPlan,
selectedInterval, selectedInterval,
props.initiatePayment, props.initiatePayment,
console.error, props.onError,
) )
const selectedRegion = ref<string>() const selectedRegion = ref<string>()
const customServer = ref<boolean>(false) const customServer = ref<boolean>(false)
const acceptedEula = ref<boolean>(false) const acceptedEula = ref<boolean>(false)
const firstTimeThru = ref<boolean>(true)
type Step = 'region' | 'payment' | 'review' type Step = 'region' | 'payment' | 'review'
@ -111,9 +115,13 @@ const currentPing = computed(() => {
const currentStep = ref<Step>() const currentStep = ref<Step>()
const currentStepIndex = computed(() => steps.indexOf(currentStep.value)) const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1))
const previousStep = computed(() => steps[steps.indexOf(currentStep.value) - 1]) const previousStep = computed(() =>
const nextStep = computed(() => steps[steps.indexOf(currentStep.value) + 1]) currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined,
)
const nextStep = computed(() =>
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
)
const canProceed = computed(() => { const canProceed = computed(() => {
switch (currentStep.value) { switch (currentStep.value) {
@ -122,7 +130,7 @@ const canProceed = computed(() => {
case 'payment': case 'payment':
return selectedPaymentMethod.value || !loadingElements.value return selectedPaymentMethod.value || !loadingElements.value
case 'review': case 'review':
return acceptedEula.value && hasPaymentMethod.value return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
default: default:
return false return false
} }
@ -135,13 +143,14 @@ async function beforeProceed(step: string) {
case 'payment': case 'payment':
await initializeStripe() await initializeStripe()
if (primaryPaymentMethodId.value) { if (primaryPaymentMethodId.value && firstTimeThru.value) {
const paymentMethod = await props.paymentMethods.find( const paymentMethod = await props.paymentMethods.find(
(x) => x.id === primaryPaymentMethodId.value, (x) => x.id === primaryPaymentMethodId.value,
) )
await selectPaymentMethod(paymentMethod) await selectPaymentMethod(paymentMethod)
await setStep('review', true) await setStep('review', true)
return true firstTimeThru.value = false
return false
} }
return true return true
case 'review': case 'review':
@ -166,13 +175,13 @@ async function afterProceed(step: string) {
} }
} }
async function setStep(step: Step, skipValidation = false) { async function setStep(step: Step | undefined, skipValidation = false) {
if (!step) { if (!step) {
await submitPayment(props.returnUrl) await submitPayment(props.returnUrl)
return return
} }
if (!canProceed.value || skipValidation) { if (!skipValidation && !canProceed.value) {
return return
} }
@ -191,6 +200,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
customServer.value = !selectedPlan.value customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined selectedPaymentMethod.value = undefined
currentStep.value = steps[0] currentStep.value = steps[0]
firstTimeThru.value = true
modal.value?.show() modal.value?.show()
} }
@ -206,7 +216,7 @@ defineExpose({
<button <button
v-if="index < currentStepIndex" v-if="index < currentStepIndex"
class="bg-transparent active:scale-95 font-bold text-secondary p-0" class="bg-transparent active:scale-95 font-bold text-secondary p-0"
@click="setStep(id)" @click="setStep(id, true)"
> >
{{ formatMessage(title) }} {{ formatMessage(title) }}
</button> </button>
@ -249,31 +259,48 @@ defineExpose({
v-else-if=" v-else-if="
currentStep === 'review' && currentStep === 'review' &&
hasPaymentMethod && hasPaymentMethod &&
selectedRegion && currentRegion &&
selectedInterval && selectedInterval &&
selectedPlan selectedPlan
" "
ref="currentStepRef"
v-model:interval="selectedInterval" v-model:interval="selectedInterval"
v-model:accepted-eula="acceptedEula" v-model:accepted-eula="acceptedEula"
:currency="currency" :currency="currency"
:plan="selectedPlan" :plan="selectedPlan"
:region="regions.find((x) => x.shortcode === selectedRegion)" :region="currentRegion"
:ping="currentPing" :ping="currentPing"
:loading="paymentMethodLoading" :loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod" :selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:tax="tax" :tax="tax"
:total="total" :total="total"
:on-error="console.error" @change-payment-method="setStep('payment', true)"
@change-payment-method="setStep('payment')"
@reload-payment-intent="reloadPaymentIntent" @reload-payment-intent="reloadPaymentIntent"
@error="console.error"
/> />
<div v-else>Something went wrong</div> <div v-else>Something went wrong</div>
<div
v-show="
selectedPaymentMethod === undefined &&
currentStep === 'payment' &&
selectedPlan &&
selectedInterval
"
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
>
<div v-show="loadingElements">
<ModalLoadingIndicator :error="loadingElementsFailed">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</div> </div>
<div class="flex gap-2 justify-between mt-4"> <div class="flex gap-2 justify-between mt-4">
<ButtonStyled> <ButtonStyled>
<button v-if="previousStep" @click="previousStep && setStep(previousStep)"> <button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }} <LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button> </button>
<button v-else @click="modal?.hide()"> <button v-else @click="modal?.hide()">
@ -282,9 +309,10 @@ defineExpose({
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!canProceed" @click="setStep(nextStep)"> <button v-tooltip="currentStep === 'review' && !acceptedEula ? 'You must accept the Minecraft EULA to proceed.' : undefined" :disabled="!canProceed" @click="setStep(nextStep)">
<template v-if="currentStep === 'review'"> <template v-if="currentStep === 'review'">
<CheckCircleIcon /> <SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe Subscribe
</template> </template>
<template v-else> <template v-else>

View File

@ -214,10 +214,10 @@
{{ interval }} {{ interval }}
</span> </span>
<span <span
v-if="interval === 'yearly'" v-if="interval === 'yearly' || interval === 'quarterly'"
class="rounded-full bg-brand px-2 py-1 font-bold text-brand-inverted" class="rounded-full bg-brand px-2 py-1 font-bold text-brand-inverted"
> >
SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice) }}% SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice, interval === 'quarterly' ? 3 : 12) }}%
</span> </span>
<span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }"> <span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }">
{{ formatPrice(locale, rawPrice, price.currency_code) }} {{ formatPrice(locale, rawPrice, price.currency_code) }}

View File

@ -51,19 +51,4 @@ const messages = defineMessages({
@select="emit('select', undefined)" @select="emit('select', undefined)"
/> />
</div> </div>
<div
v-show="selected === undefined"
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
>
<div v-show="loadingElements">
<ModalLoadingIndicator :error="loadingElementsFailed">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template> </template>

View File

@ -38,11 +38,10 @@ const props = defineProps<{
ping?: number ping?: number
loading?: boolean loading?: boolean
selectedPaymentMethod: Stripe.PaymentMethod | undefined selectedPaymentMethod: Stripe.PaymentMethod | undefined
onError: (error: Error) => void
}>() }>()
const interval = defineModel<ServerBillingInterval>('interval') const interval = defineModel<ServerBillingInterval>('interval', { required: true })
const acceptedEula = defineModel<boolean>('accepted-eula', { required: true }) const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
const prices = computed(() => { const prices = computed(() => {
return props.plan.prices.find((x) => x.currency_code === props.currency) return props.plan.prices.find((x) => x.currency_code === props.currency)
@ -143,14 +142,14 @@ function setInterval(newInterval: ServerBillingInterval) {
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2 mt-4">
<button <button
:class=" :class="
interval === 'monthly' interval === 'monthly'
? 'bg-button-bg border-transparent' ? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border' : 'bg-transparent border-button-border'
" "
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2" class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('monthly')" @click="setInterval('monthly')"
> >
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" /> <RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
@ -167,27 +166,27 @@ function setInterval(newInterval: ServerBillingInterval) {
</button> </button>
<button <button
:class=" :class="
interval === 'yearly' interval === 'quarterly'
? 'bg-button-bg border-transparent' ? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border' : 'bg-transparent border-button-border'
" "
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2" class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('yearly')" @click="setInterval('quarterly')"
> >
<RadioButtonCheckedIcon v-if="interval === 'yearly'" class="size-6 text-brand" /> <RadioButtonCheckedIcon v-if="interval === 'quarterly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" /> <RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary"> <div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'yearly' }" <span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'quarterly' }"
>Pay yearly >Pay quarterly
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight" <span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span >{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
></span ></span
> >
<span class="text-sm text-secondary flex items-center gap-1" <span class="text-sm text-secondary flex items-center gap-1"
>{{ >{{
formatPrice( formatPrice(
locale, locale,
prices?.prices?.intervals?.['yearly'] ?? 0 / monthsInInterval['yearly'], prices?.prices?.intervals?.['quarterly'] ?? 0 / monthsInInterval['quarterly'],
currency, currency,
true, true,
) )

View File

@ -1,11 +1,8 @@
import type Stripe from 'stripe' import type Stripe from 'stripe'
import type { StripeElementsOptionsMode } from '@stripe/stripe-js/dist/stripe-js/elements-group'
import { import {
type Stripe as StripeJs, type Stripe as StripeJs,
loadStripe, loadStripe,
type StripeAddressElement,
type StripeElements, type StripeElements,
type StripePaymentElement,
} from '@stripe/stripe-js' } from '@stripe/stripe-js'
import { computed, ref, type Ref } from 'vue' import { computed, ref, type Ref } from 'vue'
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address' import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
@ -19,28 +16,28 @@ import type {
ServerBillingInterval, ServerBillingInterval,
UpdatePaymentIntentRequest, UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse, UpdatePaymentIntentResponse,
} from '../../utils/billing' } from '../utils/billing.ts'
export type CreateElements = ( // export type CreateElements = (
paymentMethods: Stripe.PaymentMethod[], // paymentMethods: Stripe.PaymentMethod[],
options: StripeElementsOptionsMode, // options: StripeElementsOptionsMode,
) => { // ) => {
elements: StripeElements // elements: StripeElements
paymentElement: StripePaymentElement // paymentElement: StripePaymentElement
addressElement: StripeAddressElement // addressElement: StripeAddressElement
} // }
export const useStripe = ( export const useStripe = (
publishableKey: string, publishableKey: string,
customer: Stripe.Customer, customer: Stripe.Customer,
paymentMethods: Stripe.PaymentMethod[], paymentMethods: Stripe.PaymentMethod[],
clientSecret: string,
currency: string, currency: string,
product: Ref<ServerPlan>, product: Ref<ServerPlan | undefined>,
interval: Ref<ServerBillingInterval>, interval: Ref<ServerBillingInterval>,
initiatePayment: ( initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>, ) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
onError: (err: Error) => void,
) => { ) => {
const stripe = ref<StripeJs | null>(null) const stripe = ref<StripeJs | null>(null)
@ -57,6 +54,8 @@ export const useStripe = (
const submittingPayment = ref(false) const submittingPayment = ref(false)
const selectedPaymentMethod = ref<Stripe.PaymentMethod>() const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>() const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
const clientSecret = ref<string>()
const completingPurchase = ref<boolean>(false)
async function initialize() { async function initialize() {
stripe.value = await loadStripe(publishableKey) stripe.value = await loadStripe(publishableKey)
@ -71,10 +70,10 @@ export const useStripe = (
} }
const planPrices = computed(() => { const planPrices = computed(() => {
return product.value.prices.find((x) => x.currency_code === currency) return product.value?.prices.find((x) => x.currency_code === currency)
}) })
const createElements: CreateElements = (options) => { const createElements = (options) => {
const styles = getComputedStyle(document.body) const styles = getComputedStyle(document.body)
if (!stripe.value) { if (!stripe.value) {
@ -158,11 +157,11 @@ export const useStripe = (
}) })
const loadStripeElements = async () => { const loadStripeElements = async () => {
loadingFailed.value = false loadingFailed.value = undefined
try { try {
if (!customer) { if (!customer && primaryPaymentMethodId.value) {
paymentMethodLoading.value = true paymentMethodLoading.value = true
await props.refreshPaymentMethods() await refreshPaymentIntent(primaryPaymentMethodId.value, false)
paymentMethodLoading.value = false paymentMethodLoading.value = false
} }
@ -176,7 +175,7 @@ export const useStripe = (
} = createElements({ } = createElements({
mode: 'payment', mode: 'payment',
currency: currency.toLowerCase(), currency: currency.toLowerCase(),
amount: product.value.prices.find((x) => x.currency_code === currency)?.prices.intervals[ amount: product.value?.prices.find((x) => x.currency_code === currency)?.prices.intervals[
interval.value interval.value
], ],
paymentMethodCreation: 'manual', paymentMethodCreation: 'manual',
@ -214,6 +213,10 @@ export const useStripe = (
id: id, id: id,
} }
if (!product.value) {
return handlePaymentError('No product selected')
}
const charge: ChargeRequestType = { const charge: ChargeRequestType = {
type: 'new', type: 'new',
product_id: product.value?.id, product_id: product.value?.id,
@ -232,7 +235,7 @@ export const useStripe = (
} else { } else {
;({ ;({
payment_intent_id: paymentIntentId.value, payment_intent_id: paymentIntentId.value,
client_secret: clientSecret, client_secret: clientSecret.value,
...result ...result
} = await createIntent({ } = await createIntent({
...requestType, ...requestType,
@ -251,7 +254,7 @@ export const useStripe = (
} }
} }
} catch (err) { } catch (err) {
emit('error', err) handlePaymentError(err as string)
} }
paymentMethodLoading.value = false paymentMethodLoading.value = false
} }
@ -260,13 +263,16 @@ export const useStripe = (
if (!elements) { if (!elements) {
return handlePaymentError('No elements') return handlePaymentError('No elements')
} }
if (!stripe.value) {
return handlePaymentError('No stripe')
}
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({ const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
elements, elements,
}) })
if (error) { if (error) {
emit('error', error) handlePaymentError(error.message ?? 'Unknown error creating confirmation token')
return return
} }
@ -275,7 +281,8 @@ export const useStripe = (
function handlePaymentError(err: string | Error) { function handlePaymentError(err: string | Error) {
paymentMethodLoading.value = false paymentMethodLoading.value = false
emit('error', typeof err === 'string' ? new Error(err) : err) completingPurchase.value = false
onError(typeof err === 'string' ? new Error(err) : err)
} }
async function createNewPaymentMethod() { async function createNewPaymentMethod() {
@ -288,7 +295,7 @@ export const useStripe = (
const { error: submitError } = await elements.submit() const { error: submitError } = await elements.submit()
if (submitError) { if (submitError) {
return handlePaymentError(submitError) return handlePaymentError(submitError.message ?? 'Unknown error creating payment method')
} }
const token = await createConfirmationToken() const token = await createConfirmationToken()
@ -325,9 +332,20 @@ export const useStripe = (
const loadingElements = computed(() => elementsLoaded.value < 2) const loadingElements = computed(() => elementsLoaded.value < 2)
async function submitPayment(returnUrl: string) { async function submitPayment(returnUrl: string) {
completingPurchase.value = true
const secert = clientSecret.value
if (!secert) {
return handlePaymentError('No client secret')
}
if (!stripe.value) {
return handlePaymentError('No stripe')
}
submittingPayment.value = true submittingPayment.value = true
const { error } = await stripe.value.confirmPayment({ const { error } = await stripe.value.confirmPayment({
clientSecret, clientSecret: secert,
confirmParams: { confirmParams: {
confirmation_token: confirmationToken.value, confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`, return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
@ -335,10 +353,11 @@ export const useStripe = (
}) })
if (error) { if (error) {
props.onError(error) handlePaymentError(error.message ?? 'Unknown error submitting payment')
return false return false
} }
submittingPayment.value = false submittingPayment.value = false
completingPurchase.value = false
return true return true
} }
@ -372,5 +391,6 @@ export const useStripe = (
tax, tax,
total, total,
submitPayment, submitPayment,
completingPurchase
} }
} }

View File

@ -84,10 +84,10 @@ export const formatPrice = (locale, price, currency, trimZeros = false) => {
return formatter.format(convertedPrice) return formatter.format(convertedPrice)
} }
export const calculateSavings = (monthlyPlan, annualPlan) => { export const calculateSavings = (monthlyPlan, plan, months = 12) => {
const monthlyAnnualized = monthlyPlan * 12 const monthlyAnnualized = monthlyPlan * months
return Math.floor(((monthlyAnnualized - annualPlan) / monthlyAnnualized) * 100) return Math.floor(((monthlyAnnualized - plan) / monthlyAnnualized) * 100)
} }
export const createStripeElements = (stripe, paymentMethods, options) => { export const createStripeElements = (stripe, paymentMethods, options) => {