* New purchase flow for servers, region selector, etc. * Lint * Lint * Fix expanding total
377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
import type Stripe from 'stripe'
|
|
import type { StripeElementsOptionsMode } from '@stripe/stripe-js/dist/stripe-js/elements-group'
|
|
import {
|
|
type Stripe as StripeJs,
|
|
loadStripe,
|
|
type StripeAddressElement,
|
|
type StripeElements,
|
|
type StripePaymentElement,
|
|
} from '@stripe/stripe-js'
|
|
import { computed, ref, type Ref } from 'vue'
|
|
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
|
|
import type {
|
|
ServerPlan,
|
|
BasePaymentIntentResponse,
|
|
ChargeRequestType,
|
|
CreatePaymentIntentRequest,
|
|
CreatePaymentIntentResponse,
|
|
PaymentRequestType,
|
|
ServerBillingInterval,
|
|
UpdatePaymentIntentRequest,
|
|
UpdatePaymentIntentResponse,
|
|
} from '../../utils/billing'
|
|
|
|
export type CreateElements = (
|
|
paymentMethods: Stripe.PaymentMethod[],
|
|
options: StripeElementsOptionsMode,
|
|
) => {
|
|
elements: StripeElements
|
|
paymentElement: StripePaymentElement
|
|
addressElement: StripeAddressElement
|
|
}
|
|
|
|
export const useStripe = (
|
|
publishableKey: string,
|
|
customer: Stripe.Customer,
|
|
paymentMethods: Stripe.PaymentMethod[],
|
|
clientSecret: string,
|
|
currency: string,
|
|
product: Ref<ServerPlan>,
|
|
interval: Ref<ServerBillingInterval>,
|
|
initiatePayment: (
|
|
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
|
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
|
|
) => {
|
|
const stripe = ref<StripeJs | null>(null)
|
|
|
|
let elements: StripeElements | undefined = undefined
|
|
const elementsLoaded = ref<0 | 1 | 2>(0)
|
|
const loadingElementsFailed = ref<boolean>(false)
|
|
|
|
const paymentMethodLoading = ref(false)
|
|
const loadingFailed = ref<string>()
|
|
const paymentIntentId = ref<string>()
|
|
const tax = ref<number>()
|
|
const total = ref<number>()
|
|
const confirmationToken = ref<string>()
|
|
const submittingPayment = ref(false)
|
|
const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
|
|
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
|
|
|
|
async function initialize() {
|
|
stripe.value = await loadStripe(publishableKey)
|
|
}
|
|
|
|
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
|
|
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
|
|
}
|
|
|
|
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
|
|
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
|
|
}
|
|
|
|
const planPrices = computed(() => {
|
|
return product.value.prices.find((x) => x.currency_code === currency)
|
|
})
|
|
|
|
const createElements: CreateElements = (options) => {
|
|
const styles = getComputedStyle(document.body)
|
|
|
|
if (!stripe.value) {
|
|
throw new Error('Stripe API not yet loaded')
|
|
}
|
|
|
|
elements = stripe.value.elements({
|
|
appearance: {
|
|
variables: {
|
|
colorPrimary: styles.getPropertyValue('--color-brand'),
|
|
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
|
|
colorText: styles.getPropertyValue('--color-base'),
|
|
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
|
|
colorDanger: styles.getPropertyValue('--color-red'),
|
|
fontFamily: styles.getPropertyValue('--font-standard'),
|
|
spacingUnit: '0.25rem',
|
|
borderRadius: '0.75rem',
|
|
},
|
|
},
|
|
loader: 'never',
|
|
...options,
|
|
})
|
|
|
|
const paymentElement = elements.create('payment', {
|
|
layout: {
|
|
type: 'tabs',
|
|
defaultCollapsed: false,
|
|
},
|
|
})
|
|
paymentElement.mount('#payment-element')
|
|
|
|
const contacts: ContactOption[] = []
|
|
|
|
paymentMethods.forEach((method) => {
|
|
const addr = method.billing_details?.address
|
|
if (
|
|
addr &&
|
|
addr.line1 &&
|
|
addr.city &&
|
|
addr.postal_code &&
|
|
addr.country &&
|
|
addr.state &&
|
|
method.billing_details.name
|
|
) {
|
|
contacts.push({
|
|
address: {
|
|
line1: addr.line1,
|
|
line2: addr.line2 ?? undefined,
|
|
city: addr.city,
|
|
state: addr.state,
|
|
postal_code: addr.postal_code,
|
|
country: addr.country,
|
|
},
|
|
name: method.billing_details.name,
|
|
})
|
|
}
|
|
})
|
|
|
|
const addressElement = elements.create('address', {
|
|
mode: 'billing',
|
|
contacts: contacts.length > 0 ? contacts : undefined,
|
|
})
|
|
addressElement.mount('#address-element')
|
|
|
|
return { elements, paymentElement, addressElement }
|
|
}
|
|
|
|
const primaryPaymentMethodId = computed<string | null>(() => {
|
|
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
|
|
const method = customer.invoice_settings.default_payment_method
|
|
if (typeof method === 'string') {
|
|
return method
|
|
} else {
|
|
return method.id
|
|
}
|
|
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
|
|
return paymentMethods[0].id
|
|
} else {
|
|
return null
|
|
}
|
|
})
|
|
|
|
const loadStripeElements = async () => {
|
|
loadingFailed.value = false
|
|
try {
|
|
if (!customer) {
|
|
paymentMethodLoading.value = true
|
|
await props.refreshPaymentMethods()
|
|
paymentMethodLoading.value = false
|
|
}
|
|
|
|
if (!selectedPaymentMethod.value) {
|
|
elementsLoaded.value = 0
|
|
|
|
const {
|
|
elements: newElements,
|
|
addressElement,
|
|
paymentElement,
|
|
} = createElements({
|
|
mode: 'payment',
|
|
currency: currency.toLowerCase(),
|
|
amount: product.value.prices.find((x) => x.currency_code === currency)?.prices.intervals[
|
|
interval.value
|
|
],
|
|
paymentMethodCreation: 'manual',
|
|
setupFutureUsage: 'off_session',
|
|
})
|
|
|
|
elements = newElements
|
|
paymentElement.on('ready', () => {
|
|
elementsLoaded.value += 1
|
|
})
|
|
addressElement.on('ready', () => {
|
|
elementsLoaded.value += 1
|
|
})
|
|
}
|
|
} catch (err) {
|
|
loadingFailed.value = String(err)
|
|
console.log(err)
|
|
}
|
|
}
|
|
|
|
async function refreshPaymentIntent(id: string, confirmation: boolean) {
|
|
try {
|
|
paymentMethodLoading.value = true
|
|
if (!confirmation) {
|
|
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
|
|
}
|
|
|
|
const requestType: PaymentRequestType = confirmation
|
|
? {
|
|
type: 'confirmation_token',
|
|
token: id,
|
|
}
|
|
: {
|
|
type: 'payment_method',
|
|
id: id,
|
|
}
|
|
|
|
const charge: ChargeRequestType = {
|
|
type: 'new',
|
|
product_id: product.value?.id,
|
|
interval: interval.value,
|
|
}
|
|
|
|
let result: BasePaymentIntentResponse
|
|
|
|
if (paymentIntentId.value) {
|
|
result = await updateIntent({
|
|
...requestType,
|
|
charge,
|
|
existing_payment_intent: paymentIntentId.value,
|
|
})
|
|
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
|
|
} else {
|
|
;({
|
|
payment_intent_id: paymentIntentId.value,
|
|
client_secret: clientSecret,
|
|
...result
|
|
} = await createIntent({
|
|
...requestType,
|
|
charge,
|
|
}))
|
|
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
|
|
}
|
|
|
|
tax.value = result.tax
|
|
total.value = result.total
|
|
|
|
if (confirmation) {
|
|
confirmationToken.value = id
|
|
if (result.payment_method) {
|
|
inputtedPaymentMethod.value = result.payment_method
|
|
}
|
|
}
|
|
} catch (err) {
|
|
emit('error', err)
|
|
}
|
|
paymentMethodLoading.value = false
|
|
}
|
|
|
|
async function createConfirmationToken() {
|
|
if (!elements) {
|
|
return handlePaymentError('No elements')
|
|
}
|
|
|
|
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
|
|
elements,
|
|
})
|
|
|
|
if (error) {
|
|
emit('error', error)
|
|
return
|
|
}
|
|
|
|
return confirmation.id
|
|
}
|
|
|
|
function handlePaymentError(err: string | Error) {
|
|
paymentMethodLoading.value = false
|
|
emit('error', typeof err === 'string' ? new Error(err) : err)
|
|
}
|
|
|
|
async function createNewPaymentMethod() {
|
|
paymentMethodLoading.value = true
|
|
|
|
if (!elements) {
|
|
return handlePaymentError('No elements')
|
|
}
|
|
|
|
const { error: submitError } = await elements.submit()
|
|
|
|
if (submitError) {
|
|
return handlePaymentError(submitError)
|
|
}
|
|
|
|
const token = await createConfirmationToken()
|
|
if (!token) {
|
|
return handlePaymentError('Failed to create confirmation token')
|
|
}
|
|
await refreshPaymentIntent(token, true)
|
|
|
|
if (!planPrices.value) {
|
|
return handlePaymentError('No plan prices')
|
|
}
|
|
if (!total.value) {
|
|
return handlePaymentError('No total amount')
|
|
}
|
|
|
|
elements.update({ currency: planPrices.value.currency_code.toLowerCase(), amount: total.value })
|
|
|
|
elementsLoaded.value = 0
|
|
confirmationToken.value = token
|
|
paymentMethodLoading.value = false
|
|
|
|
return token
|
|
}
|
|
|
|
async function selectPaymentMethod(paymentMethod: Stripe.PaymentMethod | undefined) {
|
|
selectedPaymentMethod.value = paymentMethod
|
|
if (paymentMethod === undefined) {
|
|
await loadStripeElements()
|
|
} else {
|
|
refreshPaymentIntent(paymentMethod.id, false)
|
|
}
|
|
}
|
|
|
|
const loadingElements = computed(() => elementsLoaded.value < 2)
|
|
|
|
async function submitPayment(returnUrl: string) {
|
|
submittingPayment.value = true
|
|
const { error } = await stripe.value.confirmPayment({
|
|
clientSecret,
|
|
confirmParams: {
|
|
confirmation_token: confirmationToken.value,
|
|
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
|
|
},
|
|
})
|
|
|
|
if (error) {
|
|
props.onError(error)
|
|
return false
|
|
}
|
|
submittingPayment.value = false
|
|
return true
|
|
}
|
|
|
|
async function reloadPaymentIntent() {
|
|
console.log('selected:', selectedPaymentMethod.value)
|
|
console.log('token:', confirmationToken.value)
|
|
if (selectedPaymentMethod.value) {
|
|
await refreshPaymentIntent(selectedPaymentMethod.value.id, false)
|
|
} else if (confirmationToken.value) {
|
|
await refreshPaymentIntent(confirmationToken.value, true)
|
|
} else {
|
|
throw new Error('No payment method selected')
|
|
}
|
|
}
|
|
|
|
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
|
|
|
|
return {
|
|
initializeStripe: initialize,
|
|
selectPaymentMethod,
|
|
reloadPaymentIntent,
|
|
primaryPaymentMethodId,
|
|
selectedPaymentMethod,
|
|
inputtedPaymentMethod,
|
|
hasPaymentMethod,
|
|
createNewPaymentMethod,
|
|
loadingElements,
|
|
loadingElementsFailed,
|
|
paymentMethodLoading,
|
|
loadStripeElements,
|
|
tax,
|
|
total,
|
|
submitPayment,
|
|
}
|
|
}
|