[backport cloud/1.34] feat: replace Stripe pricing table with custom implementation (#7361)

Backport of #7359 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Comfy Org PR Bot
2025-12-11 18:12:57 +09:00
committed by GitHub
parent 801ab024e5
commit bade95b2c5
8 changed files with 330 additions and 410 deletions

View File

@@ -1,34 +0,0 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const STRIPE_PRICING_TABLE_SCRIPT_SRC =
'https://js.stripe.com/v3/pricing-table.js'
interface StripePricingTableConfig {
publishableKey: string
pricingTableId: string
}
function getEnvValue(
key: 'VITE_STRIPE_PUBLISHABLE_KEY' | 'VITE_STRIPE_PRICING_TABLE_ID'
) {
return import.meta.env[key]
}
export function getStripePricingTableConfig(): StripePricingTableConfig {
const publishableKey =
remoteConfig.value.stripe_publishable_key ||
window.__CONFIG__?.stripe_publishable_key ||
getEnvValue('VITE_STRIPE_PUBLISHABLE_KEY') ||
''
const pricingTableId =
remoteConfig.value.stripe_pricing_table_id ||
window.__CONFIG__?.stripe_pricing_table_id ||
getEnvValue('VITE_STRIPE_PRICING_TABLE_ID') ||
''
return {
publishableKey,
pricingTableId
}
}

View File

@@ -1870,6 +1870,7 @@
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
@@ -1922,7 +1923,8 @@
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "120"
}
},
"creator": {
@@ -1935,7 +1937,8 @@
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "288"
}
},
"pro": {
@@ -1948,7 +1951,8 @@
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "815"
}
}
},
@@ -1971,7 +1975,29 @@
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing"
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"maxDurationLabel": "Max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
"videoEstimateHelp": "What is this?",
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"credits": {
"standard": "4,200",
"creator": "7,400",
"pro": "21,100"
},
"maxDuration": {
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr"
}
},
"userSettings": {
"title": "My Account Settings",

View File

@@ -0,0 +1,273 @@
<template>
<div class="flex flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<div class="flex flex-col gap-6 p-8">
<div class="flex flex-row items-center gap-2">
<span class="font-inter text-base font-bold leading-normal text-base-foreground">
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-white px-1 text-xs font-semibold uppercase tracking-wide text-black h-[13px] leading-[13px]"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-row items-baseline gap-2">
<span class="font-inter text-[32px] font-semibold leading-normal text-base-foreground">
${{ tier.price }}
</span>
<span class="font-inter text-base font-normal leading-normal text-base-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</div>
</div>
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span class="font-inter text-sm font-normal leading-normal text-muted-foreground">
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
{{ tier.credits }}
</span>
</div>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-white" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-white" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i v-if="tier.customLoRAs" class="pi pi-check text-xs text-white" />
<i v-else class="pi pi-times text-xs text-muted" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i class="pi pi-question-circle text-xs text-muted-foreground" />
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
{{ tier.videoEstimate }}
</span>
</div>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
class="h-10 w-full"
:pt="{
label: {
class: 'font-inter text-sm font-bold leading-normal text-white'
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
credits: string
maxDuration: string
customLoRAs: boolean
videoEstimate: string
isPopular?: boolean
}
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'standard'
}
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
isPopular: false
}
]
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier } = useSubscription()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const isCurrentPlan = (tierKey: TierKey): boolean =>
currentTierKey.value === tierKey
const getButtonLabel = (tier: PricingTierConfig): string => {
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
if (!isActiveSubscription.value) return t('subscription.subscribeTo', { plan: tier.name })
return t('subscription.changeTo', { plan: tier.name })
}
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
isCurrentPlan(tier.key) ? 'secondary' : tier.key === 'creator' ? 'primary' : 'secondary'
const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
let errorMessage = 'Failed to initiate checkout'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// If JSON parsing fails, try to get text response or use HTTP status
try {
const errorText = await response.text()
errorMessage = errorText || `HTTP ${response.status} ${response.statusText}`
} catch {
errorMessage = `HTTP ${response.status} ${response.statusText}`
}
}
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})
)
}
return await response.json()
}
const handleSubscribe = wrapWithErrorHandlingAsync(
async (tierKey: TierKey) => {
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
isLoading.value = true
loadingTier.value = tierKey
try {
if (isActiveSubscription.value) {
await accessBillingPortal()
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false
loadingTier.value = null
}
},
reportError
)
</script>

View File

@@ -1,117 +0,0 @@
<template>
<div
ref="tableContainer"
class="relative w-full rounded-[20px] border border-interface-stroke bg-interface-panel-background"
>
<div
v-if="!hasValidConfig"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-missing-config"
>
{{ $t('subscription.pricingTable.missingConfig') }}
</div>
<div
v-else-if="loadError"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-error"
>
{{ $t('subscription.pricingTable.loadError') }}
</div>
<div
v-else-if="!isReady"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-loading"
>
{{ $t('subscription.pricingTable.loading') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { getStripePricingTableConfig } from '@/config/stripePricingTableConfig'
import { useStripePricingTableLoader } from '@/platform/cloud/subscription/composables/useStripePricingTableLoader'
const props = defineProps<{
pricingTableId?: string
publishableKey?: string
}>()
const tableContainer = ref<HTMLDivElement | null>(null)
const isReady = ref(false)
const loadError = ref<string | null>(null)
const lastRenderedKey = ref('')
const stripeElement = ref<HTMLElement | null>(null)
const resolvedConfig = computed(() => {
const fallback = getStripePricingTableConfig()
return {
publishableKey: props.publishableKey || fallback.publishableKey,
pricingTableId: props.pricingTableId || fallback.pricingTableId
}
})
const hasValidConfig = computed(() => {
const { publishableKey, pricingTableId } = resolvedConfig.value
return Boolean(publishableKey && pricingTableId)
})
const { loadScript } = useStripePricingTableLoader()
const renderPricingTable = async () => {
if (!tableContainer.value) return
const { publishableKey, pricingTableId } = resolvedConfig.value
if (!publishableKey || !pricingTableId) {
return
}
const renderKey = `${publishableKey}:${pricingTableId}`
if (renderKey === lastRenderedKey.value && isReady.value) {
return
}
try {
await loadScript()
loadError.value = null
if (!tableContainer.value) {
return
}
if (stripeElement.value) {
stripeElement.value.remove()
stripeElement.value = null
}
const stripeTable = document.createElement('stripe-pricing-table')
stripeTable.setAttribute('publishable-key', publishableKey)
stripeTable.setAttribute('pricing-table-id', pricingTableId)
stripeTable.style.display = 'block'
stripeTable.style.width = '100%'
stripeTable.style.minHeight = '420px'
tableContainer.value.appendChild(stripeTable)
stripeElement.value = stripeTable
lastRenderedKey.value = renderKey
isReady.value = true
} catch (error) {
console.error('[StripePricingTable] Failed to load pricing table', error)
loadError.value = (error as Error).message
isReady.value = false
}
}
watch(
[resolvedConfig, () => tableContainer.value],
() => {
if (!hasValidConfig.value) return
if (!tableContainer.value) return
void renderPricingTable()
},
{ immediate: true }
)
onBeforeUnmount(() => {
stripeElement.value?.remove()
stripeElement.value = null
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="showStripePricingTable"
v-if="showCustomPricingTable"
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
>
<div
@@ -32,7 +32,7 @@
/>
</div>
<StripePricingTable class="flex-1" />
<PricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
@@ -138,9 +138,8 @@ import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -168,25 +167,18 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const { featureFlag } = useFeatureFlags()
const subscriptionTiersEnabled = featureFlag(
'subscription_tiers_enabled',
false
)
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const showStripePricingTable = computed(
() =>
subscriptionTiersEnabled.value &&
isCloud &&
window.__CONFIG__?.subscription_required
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(
() => isCloud && window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollStartTime = 0
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
@@ -197,35 +189,44 @@ const stopPolling = () => {
const startPolling = () => {
stopPolling()
pollStartTime = Date.now()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showStripePricingTable,
showCustomPricingTable,
(enabled) => {
if (enabled) {
startPolling()
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
@@ -235,7 +236,7 @@ watch(
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showStripePricingTable.value) {
if (isActive && showCustomPricingTable.value) {
emit('close', true)
}
}
@@ -270,6 +271,7 @@ const handleViewEnterprise = () => {
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>

View File

@@ -1,118 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
import { ref } from 'vue'
import { STRIPE_PRICING_TABLE_SCRIPT_SRC } from '@/config/stripePricingTableConfig'
function useStripePricingTableLoaderInternal() {
const isLoaded = ref(false)
const isLoading = ref(false)
const error = ref<Error | null>(null)
let pendingPromise: Promise<void> | null = null
const resolveLoaded = () => {
isLoaded.value = true
isLoading.value = false
pendingPromise = null
}
const resolveError = (err: Error) => {
error.value = err
isLoading.value = false
pendingPromise = null
}
const loadScript = (): Promise<void> => {
if (isLoaded.value) {
return Promise.resolve()
}
if (pendingPromise) {
return pendingPromise
}
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${STRIPE_PRICING_TABLE_SCRIPT_SRC}"]`
)
if (existingScript) {
isLoading.value = true
pendingPromise = new Promise<void>((resolve, reject) => {
existingScript.addEventListener(
'load',
() => {
existingScript.dataset.loaded = 'true'
resolveLoaded()
resolve()
},
{ once: true }
)
existingScript.addEventListener(
'error',
() => {
const err = new Error('Stripe pricing table script failed to load')
resolveError(err)
reject(err)
},
{ once: true }
)
// Check if script already loaded after attaching listeners
if (
existingScript.dataset.loaded === 'true' ||
(existingScript as any).readyState === 'complete' ||
(existingScript as any).complete
) {
existingScript.dataset.loaded = 'true'
resolveLoaded()
resolve()
}
})
return pendingPromise
}
isLoading.value = true
pendingPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = STRIPE_PRICING_TABLE_SCRIPT_SRC
script.async = true
script.dataset.loaded = 'false'
script.addEventListener(
'load',
() => {
script.dataset.loaded = 'true'
resolveLoaded()
resolve()
},
{ once: true }
)
script.addEventListener(
'error',
() => {
const err = new Error('Stripe pricing table script failed to load')
resolveError(err)
reject(err)
},
{ once: true }
)
document.head.appendChild(script)
})
return pendingPromise
}
return {
loadScript,
isLoaded,
isLoading,
error
}
}
export const useStripePricingTableLoader = createSharedComposable(
useStripePricingTableLoaderInternal
)

View File

@@ -242,6 +242,7 @@ function useSubscriptionInternal() {
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
// Actions
subscribe,

View File

@@ -1,113 +0,0 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { ref } from 'vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
const mockLoadStripeScript = vi.fn()
let currentConfig = {
publishableKey: 'pk_test_123',
pricingTableId: 'prctbl_123'
}
let hasConfig = true
vi.mock('@/config/stripePricingTableConfig', () => ({
getStripePricingTableConfig: () => currentConfig,
hasStripePricingTableConfig: () => hasConfig
}))
const mockIsLoaded = ref(false)
const mockIsLoading = ref(false)
const mockError = ref(null)
vi.mock(
'@/platform/cloud/subscription/composables/useStripePricingTableLoader',
() => ({
useStripePricingTableLoader: () => ({
loadScript: mockLoadStripeScript,
isLoaded: mockIsLoaded,
isLoading: mockIsLoading,
error: mockError
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const mountComponent = () =>
mount(StripePricingTable, {
global: {
plugins: [i18n]
}
})
describe('StripePricingTable', () => {
beforeEach(() => {
currentConfig = {
publishableKey: 'pk_test_123',
pricingTableId: 'prctbl_123'
}
hasConfig = true
mockLoadStripeScript.mockReset().mockResolvedValue(undefined)
mockIsLoaded.value = false
mockIsLoading.value = false
mockError.value = null
})
it('renders the Stripe pricing table when config is available', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(mockLoadStripeScript).toHaveBeenCalled()
const stripePricingTable = wrapper.find('stripe-pricing-table')
expect(stripePricingTable.exists()).toBe(true)
expect(stripePricingTable.attributes('publishable-key')).toBe('pk_test_123')
expect(stripePricingTable.attributes('pricing-table-id')).toBe('prctbl_123')
})
it('shows missing config message when credentials are absent', () => {
hasConfig = false
currentConfig = { publishableKey: '', pricingTableId: '' }
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="stripe-table-missing-config"]').exists()
).toBe(true)
expect(mockLoadStripeScript).not.toHaveBeenCalled()
})
it('shows loading indicator when script is loading', async () => {
// Mock loadScript to never resolve, simulating loading state
mockLoadStripeScript.mockImplementation(() => new Promise(() => {}))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="stripe-table-loading"]').exists()).toBe(
true
)
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
})
it('shows error indicator when script fails to load', async () => {
// Mock loadScript to reject, simulating error state
mockLoadStripeScript.mockRejectedValue(new Error('Script failed to load'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="stripe-table-error"]').exists()).toBe(
true
)
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
})
})