Prospector c0accb42fa
Servers new purchase flow (#3719)
* New purchase flow for servers, region selector, etc.

* Lint

* Lint

* Fix expanding total
2025-06-03 09:20:53 -07:00

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,
}
}