Compare commits

...

4 Commits

Author SHA1 Message Date
Wei Hai
744b105355 fix(billing): restore preview test compatibility after SubscriptionTermsNote refactor
SubscriptionTermsNote gained a <script setup> that imports useSettingsDialog,
pulling in dialogService -> @/i18n -> createI18n from vue-i18n. Two existing
tests mocked vue-i18n with only useI18n, so createI18n was undefined at module
load time, collapsing both test suites.

Use importOriginal in both test mocks so all real vue-i18n exports (including
createI18n) are preserved while only useI18n is overridden. Also stub
SubscriptionTermsNote in SubscriptionAddPaymentPreviewWorkspace.test.ts to
prevent the component's setup() from calling useSettingsDialog (which requires
an active Pinia instance not set up in that test).

Also simplify methodLabel computed in SpendLimitDialogContent.vue to look up
the single needed i18n key instead of eagerly translating all four method
labels on every recompute.
2026-07-01 00:34:04 -07:00
Wei Hai
8f2a24a73d fix(billing): address review findings on payment-method collection UX
- Validate currency code with /^[A-Z]{3}$/ before passing to
  toLocaleString, falling back to USD for empty/invalid values
- Gate owed-balance notice CTAs (terms note + action buttons) behind
  permissions.canTopUp so non-owner members see the balance text but
  not owner-only billing actions
- Add neutral message branch for null paymentMethodCapability (status
  still loading or unavailable) so users are never stuck with no
  feedback
- Add noopener,noreferrer to window.open calls in CreditsTile and
  SpendLimitDialogContent to prevent reverse-tabnabbing
- Fix SpendLimitDialogContent title/ctaLabel to branch on scenario as
  well as capability, making visible label, aria-label, and action
  self-consistent for reusable+limit_reached
- Replace hardcoded English METHOD_LABELS with vue-i18n keys under
  billing.spendLimit.methodLabels
- Import PaymentMethodCapability from workspaceApi instead of
  duplicating the union in SpendLimitDialogContent and dialogService
- Make the pay_owed processing-toast branch explicit in
  billingOperationStore to avoid a silent implicit else
- Add tests: currency fallback, canTopUp gate, null-capability branch
2026-07-01 00:05:04 -07:00
Wei Hai
a222ad0ba5 feat: add payment method consent disclosure and owed-balance pay-now flow
- Add SubscriptionTermsNote (context="payment_method") above the
  add-payment-method CTA in SpendLimitDialogContent (Variants A and B)
  and above the CTA in CreditsTile for none/one_time_only capability

- Add Pay now button to CreditsTile owed-balance notice when
  paymentMethodCapability=reusable and the settle endpoint flag is on;
  passes a crypto.randomUUID() idempotency key to settleOwedBalance()

- Guard handlePayNow with a local ref to prevent double-submission
  before the billing-operation store updates; focus management moves to
  the refresh button on success or pay-now button on failure/cancel

- Validate Stripe redirect URL (hostname must end with .stripe.com)
  before window.open() in both CreditsTile and SpendLimitDialogContent;
  toast + abort on mismatch

- Null-check the window.open() return value and toast a "popup blocked"
  warning when the browser suppresses the popup

- Widen showSpendLimitDialog options with capabilityError?: boolean
  and fix ctaAriaLabel to be consistent with ctaLabel when capabilityError

- Add billing.spendLimit.defaultMethod i18n key used as the fallback
  method label in SpendLimitDialogContent

- Fix test suite: mock useBillingOperationStore with a plain getter for
  isPayingOwed (not a ComputedRef) to match Pinia's auto-unwrap
  behaviour; add mocks for useFeatureFlags, workspaceApi, and
  useToastStore; add 9 owed-balance notice test cases
2026-06-30 23:37:33 -07:00
Wei Hai
2fae5a2089 feat(billing): method-aware payment-method collection UX
Add three surfaces for off-session payment collection:
- Spend-limit dialog (SpendLimitDialogContent) with method-aware variants
  driven by payment_method_capability; add-method routes to the setup flow,
  a failed auto-charge routes to the billing portal.
- Consent disclosure via a context prop on SubscriptionTermsNote.
- Owed-balance notice in CreditsTile with a capability-aware CTA, a
  settle-endpoint feature flag (default off -> read-only fallback), and a
  dedicated pay_owed billing operation type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:39:35 -07:00
17 changed files with 1008 additions and 50 deletions

View File

@@ -6,6 +6,7 @@ import type {
BillingSubscriptionStatus,
CreateTopupResponse,
CurrentTeamCreditStop,
PaymentMethodCapability,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
@@ -37,6 +38,7 @@ export interface BalanceInfo {
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
pendingChargesMicros?: number
}
export interface BillingActions {
@@ -101,6 +103,10 @@ export interface BillingState {
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
/** Workspace-only: what payment methods can be used for recurring charges. */
paymentMethodCapability: ComputedRef<PaymentMethodCapability | null>
/** Workspace-only: type slug of the default payment method on file. */
defaultPaymentMethodType: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -158,6 +158,12 @@ function useBillingContextInternal(): BillingContext {
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
const paymentMethodCapability = computed(() =>
toValue(activeContext.value.paymentMethodCapability)
)
const defaultPaymentMethodType = computed(() =>
toValue(activeContext.value.defaultPaymentMethodType)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -303,6 +309,8 @@ function useBillingContextInternal(): BillingContext {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
getMaxSeats,
initialize,

View File

@@ -91,6 +91,9 @@ export function useLegacyBilling(): BillingState & BillingActions {
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Payment method capability is workspace-only; legacy always reports null.
const paymentMethodCapability = computed(() => null)
const defaultPaymentMethodType = computed(() => null)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
@@ -214,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
// Actions
initialize,

View File

@@ -30,7 +30,8 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
SIGNUP_TURNSTILE = 'signup_turnstile'
SIGNUP_TURNSTILE = 'signup_turnstile',
SETTLE_ENDPOINT_ENABLED = 'settle_endpoint_enabled'
}
/**
@@ -181,6 +182,13 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get settleEndpointEnabled() {
return resolveFlag(
ServerFeatureFlag.SETTLE_ENDPOINT_ENABLED,
undefined,
false
)
}
})

View File

@@ -2506,7 +2506,11 @@
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out",
"cancelFailed": "Failed to cancel subscription",
"cancelTimeout": "Subscription cancellation timed out"
"cancelTimeout": "Subscription cancellation timed out",
"payOwedProcessing": "Processing payment…",
"payOwedSuccess": "Payment processed successfully",
"payOwedFailed": "Payment failed",
"payOwedTimeout": "Payment verification timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
@@ -4489,5 +4493,37 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"billing": {
"spendLimit": {
"addPaymentMethodTitle": "Add a payment method",
"paymentFailedTitle": "Your automatic payment failed",
"oneTimeOnlyInfo": "{method} can't be used for automatic top-ups — add a card, bank account, or Link",
"addPaymentMethodCta": "Add a payment method",
"updatePaymentMethodCta": "Update payment method",
"orBuyManually": "Or buy credits manually",
"capabilityError": "Unable to load payment method status. You can add a payment method below.",
"defaultMethod": "Your current payment method",
"methodLabels": {
"alipay": "Alipay",
"card": "Your card",
"us_bank_account": "Your bank account",
"link": "Link"
}
},
"owedBalance": {
"title": "Outstanding balance: {amount}",
"addPaymentMethod": "Add a payment method",
"addCardOrBank": "Add a card or bank account",
"oneTimeOnlyHint": "Your current method can't be used to settle a balance — add a card, bank account, or Link",
"payNow": "Pay now",
"processing": "Processing payment…",
"chargeAutomatic": "A charge will process automatically.",
"unknownCapability": "Payment method status unavailable. Refresh to try again."
},
"consent": {
"paymentMethodBody": "By adding a payment method, you authorize Comfy Org to automatically charge it — without further action from you at the time — for unpaid balances and usage overages, in the amount you owe at billing time. You can remove it at any time in {settingsLink} (an active subscription or unpaid balance may need to be resolved first).",
"settingsLink": "Account Settings → Credits"
}
}
}

View File

@@ -10,12 +10,16 @@ import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceAp
type Balance = Pick<
BalanceInfo,
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
>
| 'amountMicros'
| 'cloudCreditBalanceMicros'
| 'prepaidBalanceMicros'
| 'pendingChargesMicros'
> & { currency?: string }
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
tier: SubscriptionInfo['tier'] | 'TEAM'
}
type TeamStop = CurrentTeamCreditStop
type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
const state = vi.hoisted(() => ({
balance: null as Balance | null,
@@ -25,12 +29,20 @@ const state = vi.hoisted(() => ({
currentTeamCreditStop: null as TeamStop | null,
isLoading: false,
canTopUp: true,
paymentMethodCapability: null as PaymentMethodCapability | null,
settleEndpointEnabled: false,
isPayingOwed: false,
fetchBalance: vi.fn(),
fetchStatus: vi.fn(),
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
toastErrorHandler: vi.fn()
toastErrorHandler: vi.fn(),
toastAdd: vi.fn(),
initiateAddPaymentMethod: vi.fn(),
settleOwedBalance: vi.fn(),
startOperation: vi.fn(),
clearOperation: vi.fn()
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -57,11 +69,46 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
isLoading: computed(() => state.isLoading),
paymentMethodCapability: computed(() => state.paymentMethodCapability),
fetchBalance: state.fetchBalance,
fetchStatus: state.fetchStatus
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get settleEndpointEnabled() {
return state.settleEndpointEnabled
}
}
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
initiateAddPaymentMethod: (...args: unknown[]) =>
state.initiateAddPaymentMethod(...args),
settleOwedBalance: (...args: unknown[]) => state.settleOwedBalance(...args)
}
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
get isPayingOwed() {
return state.isPayingOwed
},
startOperation: (...args: unknown[]) => state.startOperation(...args),
clearOperation: (...args: unknown[]) => state.clearOperation(...args)
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: (...args: unknown[]) => state.toastAdd(...args)
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({ canTopUp: state.canTopUp }))
@@ -92,6 +139,11 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
error: 'Error',
warning: 'Warning',
unknownError: 'An unknown error occurred'
},
subscription: {
totalCredits: 'Total credits',
remaining: 'remaining',
@@ -116,7 +168,25 @@ const i18n = createI18n({
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
upgradeToAddCredits: 'Upgrade to add credits',
preview: {
paymentPopupBlocked:
'Popup blocked. Please allow popups and try again.'
}
},
billing: {
owedBalance: {
title: 'Outstanding balance: {amount}',
addPaymentMethod: 'Add a payment method',
addCardOrBank: 'Add a card or bank account',
oneTimeOnlyHint:
"Your current method can't be used to settle a balance — add a card, bank account, or Link",
chargeAutomatic: 'A charge will process automatically.',
payNow: 'Pay now',
processing: 'Processing payment…',
unknownCapability:
'Payment method status unavailable. Refresh to try again.'
}
}
}
}
@@ -131,11 +201,17 @@ function renderTile(props: Record<string, unknown> = {}) {
stubs: {
Button: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
'<button v-bind="$attrs" :data-variant="variant" :disabled="disabled || loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading', 'disabled'],
emits: ['click']
},
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
Skeleton: {
template: '<div role="status" aria-label="Loading"></div>'
},
SubscriptionTermsNote: {
template: '<p data-testid="terms-note" :data-context="context"></p>',
props: ['context']
}
}
}
})
@@ -165,6 +241,9 @@ describe('CreditsTile', () => {
state.currentTeamCreditStop = null
state.isLoading = false
state.canTopUp = true
state.paymentMethodCapability = null
state.settleEndpointEnabled = false
state.isPayingOwed = false
vi.clearAllMocks()
})
@@ -370,4 +449,138 @@ describe('CreditsTile', () => {
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
)
})
describe('owed balance notice', () => {
it('hides the notice when pendingChargesMicros is null', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: undefined }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('hides the notice when pendingChargesMicros is zero', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 0 }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('hides the notice when pendingChargesMicros is negative', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: -100 }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows the notice with a formatted amount when pendingChargesMicros is positive', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 5_000_000 }
state.paymentMethodCapability = 'reusable'
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('$5.00')
})
it('shows "Add a payment method" CTA and terms note for capability=none', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'none'
renderTile()
expect(screen.getByText('Add a payment method')).toBeTruthy()
const termsNote = screen.getByTestId('terms-note')
expect(termsNote.dataset.context).toBe('payment_method')
expect(screen.queryByText('Pay now')).toBeNull()
})
it('shows "Add a card or bank account" CTA, hint, and terms note for capability=one_time_only', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'one_time_only'
renderTile()
expect(screen.getByText('Add a card or bank account')).toBeTruthy()
expect(
screen.getByText(
"Your current method can't be used to settle a balance — add a card, bank account, or Link"
)
).toBeTruthy()
const termsNote = screen.getByTestId('terms-note')
expect(termsNote.dataset.context).toBe('payment_method')
expect(screen.queryByText('Pay now')).toBeNull()
})
it('shows an auto-charge message and no CTA for capability=reusable when settle flag is off', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = false
renderTile()
expect(
screen.getByText('A charge will process automatically.')
).toBeTruthy()
expect(screen.queryByText('Pay now')).toBeNull()
expect(screen.queryByText('Add a payment method')).toBeNull()
expect(screen.queryByTestId('terms-note')).toBeNull()
})
it('shows "Pay now" CTA for capability=reusable when settle flag is on', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = true
const { container } = renderTile()
expect(container.textContent).toContain('Pay now')
expect(container.textContent).not.toContain('Add a payment method')
})
it('shows "Processing payment…" and disables the Pay now button while isPayingOwed', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = true
state.isPayingOwed = true
const { container } = renderTile()
expect(container.textContent).toContain('Processing payment…')
const btn = screen.getByRole('button', { name: /Processing payment/i })
expect(btn.getAttribute('disabled')).not.toBeNull()
})
it('falls back to USD when balance.currency is an empty string', () => {
activeProSubscription()
state.balance = {
amountMicros: 500,
pendingChargesMicros: 5_000_000,
currency: ''
}
state.paymentMethodCapability = 'reusable'
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('$5.00')
})
it('hides CTAs when canTopUp is false even when balance is owed', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'none'
state.canTopUp = false
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('Outstanding balance')
expect(screen.queryByText('Add a payment method')).toBeNull()
expect(screen.queryByTestId('terms-note')).toBeNull()
})
it('shows a neutral message when paymentMethodCapability is null', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = null
state.canTopUp = true
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain(
'Payment method status unavailable. Refresh to try again.'
)
expect(screen.queryByText('Pay now')).toBeNull()
expect(screen.queryByText('Add a payment method')).toBeNull()
})
})
})

View File

@@ -3,6 +3,7 @@
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
>
<Button
ref="refreshButtonRef"
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
@@ -143,6 +144,94 @@
</div>
</template>
<!-- Owed balance notice: shown only when there is a positive pending charge -->
<div
v-if="owedBalanceAmount !== null"
role="alert"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--alert-triangle] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-2">
<span class="text-base-foreground">{{
$t('billing.owedBalance.title', { amount: owedBalanceAmount })
}}</span>
<!-- one_time_only hint -->
<span
v-if="paymentMethodCapability === 'one_time_only'"
class="text-muted"
>{{ $t('billing.owedBalance.oneTimeOnlyHint') }}</span
>
<!-- reusable + flag off: read-only message -->
<span
v-if="
paymentMethodCapability === 'reusable' && !settleEndpointEnabled
"
class="text-muted"
>{{ $t('billing.owedBalance.chargeAutomatic') }}</span
>
<!-- consent note before add-payment-method CTA -->
<SubscriptionTermsNote
v-if="
permissions.canTopUp &&
(paymentMethodCapability === 'none' ||
paymentMethodCapability === 'one_time_only')
"
context="payment_method"
/>
<!-- CTA: none / one_time_only → add payment method -->
<Button
v-if="
permissions.canTopUp &&
(paymentMethodCapability === 'none' ||
paymentMethodCapability === 'one_time_only')
"
variant="primary"
size="sm"
class="w-fit"
@click="handleOwedAddPaymentMethod"
>
{{
paymentMethodCapability === 'none'
? $t('billing.owedBalance.addPaymentMethod')
: $t('billing.owedBalance.addCardOrBank')
}}
</Button>
<!-- CTA: reusable + flag on → Pay now -->
<Button
v-else-if="
permissions.canTopUp &&
paymentMethodCapability === 'reusable' &&
settleEndpointEnabled
"
ref="payNowButtonRef"
variant="primary"
size="sm"
class="w-fit"
:disabled="isPayingOwed || isPayingNow"
@click="handlePayNow"
>
<span v-if="isPayingOwed || isPayingNow" role="status">{{
$t('billing.owedBalance.processing')
}}</span>
<template v-else>{{ $t('billing.owedBalance.payNow') }}</template>
</Button>
<!-- capability loading/unknown: show a neutral message -->
<span
v-if="permissions.canTopUp && paymentMethodCapability === null"
class="text-muted"
>{{ $t('billing.owedBalance.unknownCapability') }}</span
>
</div>
</div>
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
@@ -176,13 +265,14 @@
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
@@ -193,7 +283,11 @@ import {
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
@@ -209,6 +303,7 @@ const {
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
paymentMethodCapability,
fetchBalance,
fetchStatus
} = useBillingContext()
@@ -225,6 +320,101 @@ const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const { flags } = useFeatureFlags()
const billingOperationStore = useBillingOperationStore()
const toastStore = useToastStore()
const settleEndpointEnabled = computed(() => flags.settleEndpointEnabled)
const owedBalanceAmount = computed(() => {
const pendingMicros = balance.value?.pendingChargesMicros
if (pendingMicros == null || pendingMicros <= 0) return null
const rawCurrency = (balance.value?.currency ?? '').toUpperCase()
const currency = /^[A-Z]{3}$/.test(rawCurrency) ? rawCurrency : 'USD'
return (pendingMicros / 1_000_000).toLocaleString(locale.value, {
style: 'currency',
currency
})
})
const isPayingOwed = computed(() => billingOperationStore.isPayingOwed)
const isPayingNow = ref(false)
const payNowButtonRef = ref<InstanceType<typeof Button> | null>(null)
const refreshButtonRef = ref<InstanceType<typeof Button> | null>(null)
let payNowOpId: string | null = null
async function handleOwedAddPaymentMethod() {
try {
const response = await workspaceApi.initiateAddPaymentMethod()
const url = response.payment_method_url
if (!new URL(url).hostname.endsWith('.stripe.com')) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
})
return
}
const win = window.open(url, '_blank', 'noopener,noreferrer')
if (!win) {
toastStore.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('subscription.preview.paymentPopupBlocked')
})
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
}
}
async function handlePayNow() {
if (isPayingOwed.value || isPayingNow.value) return
isPayingNow.value = true
const idempotencyKey = crypto.randomUUID()
if (payNowOpId) {
billingOperationStore.clearOperation(payNowOpId)
payNowOpId = null
}
try {
const response = await workspaceApi.settleOwedBalance(idempotencyKey)
payNowOpId = response.billing_op_id
const operation = await billingOperationStore.startOperation(
payNowOpId,
'pay_owed'
)
if (operation.status === 'succeeded') {
await nextTick()
const el = refreshButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
} else {
await nextTick()
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
await nextTick()
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
} finally {
isPayingNow.value = false
}
}
const tierKey = computed(() => {
const tier = subscription.value?.tier

View File

@@ -258,6 +258,8 @@ export interface CurrentTeamCreditStop {
stop_usd: number
}
export type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
@@ -269,6 +271,8 @@ export interface BillingStatusResponse {
cancel_at?: string
renewal_date?: string
team_credit_stop?: CurrentTeamCreditStop
payment_method_capability?: PaymentMethodCapability
default_payment_method_type?: string
}
export interface BillingBalanceResponse {
@@ -285,13 +289,14 @@ interface CreateTopupRequest {
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed'
type TopupStatus = 'pending' | 'completed' | 'failed' | 'needs_payment_method'
export interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
payment_method_url?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
@@ -324,6 +329,15 @@ interface GetBillingEventsParams {
limit?: number
}
export interface AddPaymentMethodResponse {
payment_method_url: string
billing_op_id: string
}
export interface SettleOwedBalanceResponse {
billing_op_id: string
}
class WorkspaceApiError extends Error {
constructor(
message: string,
@@ -790,5 +804,43 @@ export const workspaceApi = {
} catch (err) {
handleAxiosError(err)
}
},
/**
* Initiate a Stripe SetupIntent to collect a payment method without charging.
* POST /api/billing/add-payment-method
*/
async initiateAddPaymentMethod(): Promise<AddPaymentMethodResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AddPaymentMethodResponse>(
api.apiURL('/billing/add-payment-method'),
null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Settle the outstanding owed balance immediately.
* POST /api/billing/settle-owed
*/
async settleOwedBalance(
idempotencyKey?: string
): Promise<SettleOwedBalanceResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<SettleOwedBalanceResponse>(
api.apiURL('/billing/settle-owed'),
idempotencyKey ? { idempotency_key: idempotencyKey } : null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
}
}

View File

@@ -0,0 +1,192 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex items-center justify-between p-8">
<h2 class="m-0 text-lg font-bold text-base-foreground">
<Skeleton v-if="ctaLoading" class="h-7 w-48" />
<template v-else>{{ title }}</template>
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<!-- Capability error fallback -->
<p
v-if="capabilityError"
aria-live="polite"
data-testid="capability-error-fallback"
class="m-0 px-8 text-sm text-muted-foreground"
>
{{ $t('billing.spendLimit.capabilityError') }}
</p>
<!-- one_time_only info box -->
<div
v-else-if="capability === 'one_time_only'"
class="mx-8 flex items-start gap-2 rounded-lg bg-secondary-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<span class="text-base-foreground">{{
$t('billing.spendLimit.oneTimeOnlyInfo', { method: methodLabel })
}}</span>
</div>
<!-- Actions -->
<div class="flex flex-col gap-4 p-8">
<SubscriptionTermsNote
v-if="!(capability === 'reusable' && scenario === 'payment_failed')"
context="payment_method"
/>
<Button
:disabled="ctaLoading"
:loading="ctaLoading"
variant="primary"
size="lg"
class="h-10 justify-center"
:aria-label="ctaLabel"
@click="handleMainCta"
>
<Skeleton v-if="ctaLoading" class="h-4 w-32" />
<template v-else>{{ ctaLabel }}</template>
</Button>
<button
class="cursor-pointer border-none bg-transparent text-sm text-muted-foreground transition-colors hover:text-base-foreground"
@click="handleBuyManually"
>
{{ $t('billing.spendLimit.orBuyManually') }}
</button>
</div>
<!-- TODO: handle paused and dunning account states -->
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { PaymentMethodCapability } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
type Scenario = 'limit_reached' | 'payment_failed'
const {
scenario,
capability,
methodType,
capabilityError = false
} = defineProps<{
scenario: Scenario
capability: PaymentMethodCapability
methodType?: string
capabilityError?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const toastStore = useToastStore()
const { manageSubscription } = useBillingContext()
const ctaLoading = ref(false)
const methodLabel = computed(() => {
if (!methodType) return t('billing.spendLimit.defaultMethod')
const knownMethods = ['alipay', 'card', 'us_bank_account', 'link']
if (!knownMethods.includes(methodType))
return t('billing.spendLimit.defaultMethod')
return t(`billing.spendLimit.methodLabels.${methodType}`)
})
const title = computed(() => {
if (
capabilityError ||
capability === 'none' ||
capability === 'one_time_only'
) {
return t('billing.spendLimit.addPaymentMethodTitle')
}
if (scenario === 'payment_failed') {
return t('billing.spendLimit.paymentFailedTitle')
}
return t('billing.spendLimit.addPaymentMethodTitle')
})
const ctaLabel = computed(() => {
if (
capabilityError ||
capability === 'none' ||
capability === 'one_time_only'
) {
return t('billing.spendLimit.addPaymentMethodCta')
}
if (scenario === 'payment_failed') {
return t('billing.spendLimit.updatePaymentMethodCta')
}
return t('billing.spendLimit.addPaymentMethodCta')
})
function handleClose() {
dialogStore.closeDialog({ key: 'spend-limit' })
}
async function handleMainCta() {
if (ctaLoading.value) return
ctaLoading.value = true
try {
if (capability === 'reusable' && scenario === 'payment_failed') {
await manageSubscription()
} else {
const response = await workspaceApi.initiateAddPaymentMethod()
const url = response.payment_method_url
if (!new URL(url).hostname.endsWith('.stripe.com')) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
})
return
}
const paymentWindow = window.open(url, '_blank', 'noopener,noreferrer')
if (!paymentWindow) {
toastStore.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('subscription.preview.paymentPopupBlocked')
})
}
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
} finally {
ctaLoading.value = false
}
}
async function handleBuyManually() {
handleClose()
await dialogService.showTopUpCreditsDialog()
}
</script>

View File

@@ -38,17 +38,22 @@ function previewFixture(
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}
})
const globalOptions = {
mocks: { $t: (key: string) => key },
stubs: {
'i18n-t': { template: '<span />' },
SubscriptionTermsNote: { template: '<div />' },
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>'
}

View File

@@ -1,26 +1,54 @@
<template>
<p class="m-0 text-center text-xs text-muted-foreground">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
<template v-if="context === 'payment_method'">
<i18n-t keypath="billing.consent.paymentMethodBody" tag="span">
<template #settingsLink>
<button
class="cursor-pointer border-none bg-transparent p-0 text-xs text-muted-foreground underline transition-colors hover:text-base-foreground"
@click="openSettings"
>
{{ $t('billing.consent.settingsLink') }}
</button>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</template>
</p>
</template>
<script setup lang="ts">
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
const { context = 'subscription' } = defineProps<{
context?: 'subscription' | 'payment_method'
}>()
const settingsDialog = useSettingsDialog()
function openSettings() {
settingsDialog.show('workspace')
}
</script>

View File

@@ -9,12 +9,16 @@ import type {
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}
})
const globalOptions = {
mocks: { $t: (key: string) => key },

View File

@@ -70,7 +70,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
effectiveBalanceMicros:
data.effective_balance_micros ?? data.amount_micros,
prepaidBalanceMicros: data.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0,
pendingChargesMicros: data.pending_charges_micros
}
})
@@ -80,6 +81,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
)
const tier = computed(() => statusData.value?.subscription_tier ?? null)
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
const paymentMethodCapability = computed(
() => statusData.value?.payment_method_capability ?? null
)
const defaultPaymentMethodType = computed(
() => statusData.value?.default_payment_method_type ?? null
)
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
@@ -300,6 +307,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
// Actions
initialize,

View File

@@ -504,6 +504,161 @@ describe('billingOperationStore', () => {
})
})
describe('pay_owed operations', () => {
it('shows immediate processing toast for pay_owed operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.payOwedProcessing',
group: 'billing-operation'
})
})
it('does not close any dialog or open settings on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockCloseDialog).not.toHaveBeenCalled()
expect(mockSettingsDialogShow).not.toHaveBeenCalled()
})
it('shows pay_owed success toast on success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.payOwedSuccess',
life: 5000
})
})
it('uses pay_owed failure message on failure', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.payOwedFailed',
detail: undefined
})
})
it('uses pay_owed timeout message on timeout', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.payOwedTimeout'
})
})
it('isPayingOwed is true while pay_owed operation is pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
expect(store.isPayingOwed).toBe(true)
})
it('isPayingOwed is false after pay_owed operation succeeds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
expect(store.isPayingOwed).toBe(false)
})
it('does not update workspace isSubscribed on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
})
it('refreshes billing status and balance on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockFetchBalance).toHaveBeenCalled()
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({

View File

@@ -16,7 +16,7 @@ const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationType = 'subscription' | 'topup' | 'cancel' | 'pay_owed'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
@@ -53,6 +53,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
)
)
const isPayingOwed = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'pay_owed'
)
)
function getOperation(opId: string) {
return operations.value.get(opId)
}
@@ -81,7 +87,9 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
: type === 'topup'
? 'billingOperation.topupProcessing'
: 'billingOperation.payOwedProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
@@ -169,6 +177,17 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
return
}
// pay_owed: only refresh balance; do not close any dialog or open settings.
if (operation.type === 'pay_owed') {
useToastStore().add({
severity: 'success',
summary: t('billingOperation.payOwedSuccess'),
life: 5000
})
resolveTerminal(opId)
return
}
// A subscription checkout shows its own success step in the pricing dialog,
// so leave it open. Top-ups have no such step: close and surface settings.
if (operation.type === 'topup') {
@@ -233,6 +252,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
function failureMessage(type: OperationType) {
if (type === 'subscription') return t('billingOperation.subscriptionFailed')
if (type === 'topup') return t('billingOperation.topupFailed')
if (type === 'pay_owed') return t('billingOperation.payOwedFailed')
return t('billingOperation.cancelFailed')
}
@@ -240,6 +260,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
if (type === 'subscription')
return t('billingOperation.subscriptionTimeout')
if (type === 'topup') return t('billingOperation.topupTimeout')
if (type === 'pay_owed') return t('billingOperation.payOwedTimeout')
return t('billingOperation.cancelTimeout')
}
@@ -295,6 +316,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
hasPendingOperations,
isSettingUp,
isAddingCredits,
isPayingOwed,
getOperation,
startOperation,
clearOperation

View File

@@ -19,7 +19,10 @@ import type {
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import type {
PaymentMethodCapability,
WorkspaceRole
} from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
const lazyApiNodesSignInContent = () =>
@@ -629,6 +632,25 @@ export const useDialogService = () => {
* understand" confirm dialog when the workspace has no other members;
* failures on that path surface as an error toast.
*/
async function showSpendLimitDialog(options: {
scenario: 'limit_reached' | 'payment_failed'
capability: PaymentMethodCapability
methodType?: string
capabilityError?: boolean
}) {
const { type } = useBillingContext()
if (type.value !== 'workspace') return
const { default: component } =
await import('@/platform/workspace/components/SpendLimitDialogContent.vue')
return dialogStore.showDialog({
key: 'spend-limit',
component,
props: options,
dialogComponentProps: workspaceDialogProps
})
}
async function showDowngradeToPersonalDialog(options: {
planName: string
planSlug: string
@@ -734,6 +756,7 @@ export const useDialogService = () => {
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog
showDowngradeToPersonalDialog,
showSpendLimitDialog
}
}

View File

@@ -33,6 +33,8 @@ export function useBillingContext(): BillingContext {
subscriptionStatus: computed(() => null),
tier: computed(() => null),
renewalDate: computed(() => null),
paymentMethodCapability: computed(() => null),
defaultPaymentMethodType: computed(() => null),
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
initialize: async () => {},
fetchStatus: async () => {},