[backport cloud/1.34] feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7292)

Backport of #7288 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7292-backport-cloud-1-34-feat-add-Stripe-pricing-table-integration-for-subscription-dialog--2c46d73d36508111869ddf32de921b29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2025-12-09 21:03:59 +09:00
committed by GitHub
parent d2f5f0dce1
commit 27dcb152ff
14 changed files with 651 additions and 18 deletions

View File

@@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123

2
global.d.ts vendored
View File

@@ -13,6 +13,8 @@ interface Window {
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
stripe_publishable_key?: string
stripe_pricing_table_id?: string
firebase_config?: {
apiKey: string
authDomain: string

View File

@@ -0,0 +1,34 @@
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

@@ -97,6 +97,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
@@ -1883,10 +1884,20 @@
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"pricingTable": {
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
"loading": "Loading pricing options...",
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner Nodes pricing table"
},
"userSettings": {

View File

@@ -0,0 +1,117 @@
<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

@@ -24,8 +24,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -51,12 +52,18 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const { flags } = useFeatureFlags()
const shouldUseStripePricing = computed(
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
)
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
@@ -102,11 +109,27 @@ const stopPolling = () => {
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
if (shouldUseStripePricing.value && awaiting && isActive) {
emit('subscribed')
isAwaitingStripeSubscription.value = false
}
}
)
const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}
if (shouldUseStripePricing.value) {
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -1,5 +1,68 @@
<template>
<div class="relative grid h-full grid-cols-5">
<div
v-if="showStripePricingTable"
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
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
<div
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
>
{{ $t('subscription.required.title') }}
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
<div class="text-3xl font-semibold leading-tight md:text-4xl">
{{ $t('subscription.description') }}
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="handleClose"
/>
</div>
<StripePricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<Button
:label="$t('subscription.contactUs')"
text
severity="secondary"
icon="pi pi-comments"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleContactUs"
/>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
:label="$t('subscription.viewEnterprise')"
text
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleViewEnterprise"
/>
</div>
</div>
</div>
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
icon="pi pi-times"
@@ -7,7 +70,7 @@
rounded
class="absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="onClose"
@click="handleClose"
/>
<div
@@ -72,13 +135,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.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'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
defineProps<{
const props = defineProps<{
onClose: () => void
}>()
@@ -86,19 +155,119 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { formattedMonthlyPrice } = useSubscription()
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
useSubscription()
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
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
let pollInterval: number | null = null
let pollStartTime = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollStartTime = Date.now()
const poll = async () => {
try {
await fetchStatus()
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
}
}
void poll()
pollInterval = window.setInterval(() => {
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
void poll()
}, POLL_INTERVAL_MS)
}
watch(
showStripePricingTable,
(enabled) => {
if (enabled) {
startPolling()
} else {
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showStripePricingTable.value) {
emit('close', true)
}
}
)
const handleSubscribed = () => {
emit('close', true)
}
const handleClose = () => {
stopPolling()
props.onClose()
}
const handleContactUs = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleViewEnterprise = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'subscription'
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
:deep(.p-button) {
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -0,0 +1,118 @@
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

@@ -9,11 +9,11 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
@@ -37,7 +37,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -102,7 +102,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
void dialogService.showSubscriptionRequiredDialog()
void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>

View File

@@ -1,4 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const showStripeDialog = computed(
() =>
flags.subscriptionTiersEnabled &&
isCloud &&
window.__CONFIG__?.subscription_required
)
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -25,7 +36,19 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: 700px;'
style: showStripeDialog.value
? 'width: min(1100px, 90vw); max-height: 90vh;'
: 'width: 700px;',
pt: showStripeDialog.value
? {
root: {
class: '!rounded-[32px] overflow-visible'
},
content: {
class: '!p-0 bg-transparent'
}
}
: undefined
}
})
}

View File

@@ -38,4 +38,6 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
}

View File

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
@@ -33,6 +35,8 @@ export function useSettingUI(
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { flags } = useFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
@@ -102,6 +106,12 @@ export function useSettingUI(
)
}
const shouldShowPlanCreditsPanel = computed(() => {
if (!subscriptionPanel) return false
if (!flags.subscriptionTiersEnabled) return true
return isActiveSubscription.value
})
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -154,9 +164,7 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud &&
window.__CONFIG__?.subscription_required &&
subscriptionPanel
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
@@ -191,8 +199,7 @@ export function useSettingUI(
children: [
userPanel.node,
...(isLoggedIn.value &&
isCloud &&
window.__CONFIG__?.subscription_required &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
? [subscriptionPanel.node]
: []),

9
src/vite-env.d.ts vendored
View File

@@ -16,6 +16,15 @@ declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
readonly VITE_STRIPE_PRICING_TABLE_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}
export {}

View File

@@ -0,0 +1,113 @@
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)
})
})