Fixes to billing
This commit is contained in:
parent
c0accb42fa
commit
4441be5380
@ -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");
|
||||||
|
@ -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>
|
||||||
|
@ -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) }}
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user