opnform-host-nginx/client/composables/useStripeElements.js

377 lines
11 KiB
JavaScript

import { computed, reactive, readonly } from 'vue'
import { useI18n } from '#imports'
import { opnFetch } from '~/composables/useOpnApi.js'
/**
* Creates a Stripe elements instance with state management
* @param {String} initialAccountId - Optional account ID to initialize with
* @returns {Object} Stripe elements API with state and methods
*/
export const createStripeElements = (initialAccountId = null) => {
// Ensure we're on the client side
if (import.meta.server) {
console.warn('Stripe Elements can only be initialized in the browser')
return null
}
// Initialize i18n at the top level
const { t } = useI18n()
// Use reactive for the state to ensure changes propagate
const state = reactive({
// Loading states
isLoadingAccount: false,
hasAccountLoadingError: false,
isStripeInstanceReady: false,
isCardElementReady: false,
// Core Stripe objects
stripe: null,
elements: null,
card: null,
// Form data
cardHolderName: '',
cardHolderEmail: '',
// Account & payment state
stripeAccountId: initialAccountId, // Initialize with provided account ID
intentId: null,
showPreviewMessage: false,
error: null
})
// Reset state on initialization
state.stripe = null
state.elements = null
state.card = null
state.isStripeInstanceReady = false
state.isCardElementReady = false
state.error = null
// Computed properties
const isReadyForPayment = computed(() => {
return state.isStripeInstanceReady &&
state.isCardElementReady &&
state.stripeAccountId
})
const isCardPopulated = computed(() => {
return state.card && !state.card._empty
})
/**
* Resets the Stripe state to its initial values
*/
const resetStripeState = () => {
state.isLoadingAccount = false
state.hasAccountLoadingError = false
state.isStripeInstanceReady = false
state.isCardElementReady = false
state.stripe = null
state.elements = null
state.card = null
state.stripeAccountId = null
state.intentId = null
state.showPreviewMessage = false
state.error = null
}
/**
* Prepares the Stripe state by fetching account details
* @param {String} formSlug - Form slug
* @param {String|Number} providerId - OAuth provider ID
* @param {Boolean} isEditorPreview - Whether this is in editor preview mode
* @returns {Promise<Object>} Result object with success and message
*/
const prepareStripeState = async (formSlug, providerId, isEditorPreview = false) => {
if (!formSlug || !providerId) {
resetStripeState()
return { success: false, message: 'Missing form slug or OAuth provider ID' }
}
// Always ensure provider ID is a string
const providerIdStr = String(providerId)
resetStripeState()
state.isLoadingAccount = true
try {
console.debug('[useStripeElements] Preparing Stripe state for:', { formSlug, providerId: providerIdStr, isEditorPreview })
// Construct fetch options, adding providerId only for editor preview
const fetchOptions = {}
if (isEditorPreview) {
fetchOptions.query = { oauth_provider_id: providerIdStr }
}
const response = await opnFetch(`/forms/${formSlug}/stripe-connect/get-account`, fetchOptions)
console.debug('[useStripeElements] Got account response:', response)
if (response?.type === 'success' && response?.stripeAccount) {
// Ensure account ID is stored as string
state.stripeAccountId = String(response.stripeAccount)
state.isLoadingAccount = false
// If card is already set, mark card element as ready
if (state.card && state.stripe) {
state.isCardElementReady = true
}
return { success: true, accountId: String(response.stripeAccount) }
} else {
state.hasAccountLoadingError = true
state.isLoadingAccount = false
if (response?.message?.includes('save the form and try again')) {
state.showPreviewMessage = true
}
state.errorMessage = response?.message || 'Failed to get account details'
return {
success: false,
message: state.errorMessage,
requiresSave: state.showPreviewMessage
}
}
} catch (error) {
console.error('[useStripeElements] Error preparing state:', error)
state.hasAccountLoadingError = true
state.isLoadingAccount = false
const message = error?.data?.message || 'Payment setup error'
if (message.includes('save the form and try again')) {
state.showPreviewMessage = true
}
state.errorMessage = message
return {
success: false,
message: state.errorMessage,
requiresSave: state.showPreviewMessage
}
}
}
/**
* Sets the Stripe instance in the state
*/
const setStripeInstance = (instance) => {
console.debug('[useStripeElements] Setting Stripe instance:', {
hasInstance: !!instance
})
try {
if (!instance) {
console.warn('[useStripeElements] No Stripe instance provided')
return
}
const isValidStripeInstance = instance &&
typeof instance === 'object' &&
typeof instance.confirmCardPayment === 'function' &&
typeof instance.createToken === 'function'
if (isValidStripeInstance) {
console.debug('[useStripeElements] Valid Stripe instance detected')
state.stripe = instance
state.isStripeInstanceReady = true
// If we have all required components, ensure card element is ready
if (state.card && state.stripeAccountId) {
console.debug('[useStripeElements] Card element ready with account')
state.isCardElementReady = true
}
} else {
console.warn('[useStripeElements] Invalid Stripe instance provided')
state.isStripeInstanceReady = false
}
} catch (error) {
console.error('[useStripeElements] Error setting Stripe instance:', error)
}
}
/**
* Sets the Elements instance in the state
*/
const setElementsInstance = (elementsInstance) => {
if (elementsInstance) {
state.elements = elementsInstance
}
}
/**
* Sets the Card Element in the state
*/
const setCardElement = (cardElement) => {
if (cardElement) {
state.card = cardElement
state.isCardElementReady = true
} else {
state.card = null
state.isCardElementReady = false
}
}
/**
* Sets the billing details in the state
*/
const setBillingDetails = ({ name, email }) => {
if (name !== undefined) state.cardHolderName = name
if (email !== undefined) state.cardHolderEmail = email
}
/**
* Processes a payment using the Stripe API
*/
const processPayment = async (formSlug, isRequired = true) => {
if (!isReadyForPayment.value) {
return {
success: false,
error: { message: t('forms.payment.errors.systemNotReady') }
}
}
if (!state.stripe) {
return {
success: false,
error: { message: t('forms.payment.errors.misconfigured') }
}
}
if (!state.card) {
return {
success: false,
error: { message: t('forms.payment.errors.notFullyReady') }
}
}
if (isRequired && state.card._empty) {
return {
success: false,
error: { message: t('forms.payment.errors.paymentRequired') }
}
}
if (isRequired || !state.card._empty) {
if (!state.cardHolderName) {
return {
success: false,
error: { message: t('forms.payment.errors.nameRequired') }
}
}
if (!state.cardHolderEmail) {
return {
success: false,
error: { message: t('forms.payment.errors.emailRequired') }
}
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.cardHolderEmail)) {
return {
success: false,
error: { message: t('forms.payment.errors.invalidEmail') }
}
}
}
try {
const responseIntent = await opnFetch('/forms/' + formSlug + '/stripe-connect/payment-intent')
if (responseIntent?.type === 'success') {
const intentSecret = responseIntent?.intent?.secret
const result = await state.stripe.confirmCardPayment(intentSecret, {
payment_method: {
card: state.card,
billing_details: {
name: state.cardHolderName,
email: state.cardHolderEmail
},
},
receipt_email: state.cardHolderEmail,
})
if (result?.paymentIntent?.status === 'succeeded') {
state.intentId = result.paymentIntent.id
}
return {
success: result?.paymentIntent?.status === 'succeeded',
...result
}
} else {
return {
success: false,
error: { message: responseIntent?.message || t('forms.payment.errors.failedIntent') }
}
}
} catch (error) {
const errorMessage = error?.message || t('forms.payment.errors.processingFailed')
const errorType = error?.type || 'unknown'
const errorCode = error?.code || 'unknown'
return {
success: false,
error: {
message: errorMessage,
type: errorType,
code: errorCode
}
}
}
}
/**
* Sets the intent ID
* @param {String} intentId - The payment intent ID
*/
const setIntentId = (intentId) => {
console.debug('[useStripeElements] Setting intentId:', intentId)
if (intentId && typeof intentId === 'string' && intentId.startsWith('pi_')) {
state.intentId = intentId
}
}
/**
* Sets the Stripe Account ID in the state
* @param {String|Number} accountId - The Stripe account ID
*/
const setAccountId = (accountId) => {
if (accountId) {
// Always convert to string - Stripe.js requires a string account ID
const accountIdStr = String(accountId)
console.debug('[useStripeElements] Setting Stripe account ID:', accountIdStr)
state.stripeAccountId = accountIdStr
// If we have all required components, ensure card element is ready
if (state.card && state.stripe) {
state.isCardElementReady = true
}
}
}
const stripeElements = {
// Expose readonly state to prevent mutations outside of proper methods
state: readonly(state),
// Read-only computed values
isReadyForPayment,
isCardPopulated,
// Methods
resetStripeState,
prepareStripeState,
processPayment,
setStripeInstance,
setElementsInstance,
setCardElement,
setBillingDetails,
setIntentId,
setAccountId
}
return stripeElements
}