mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
4 Commits
codex/cove
...
feat/payme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744b105355 | ||
|
|
8f2a24a73d | ||
|
|
a222ad0ba5 | ||
|
|
2fae5a2089 |
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
src/platform/workspace/components/SpendLimitDialogContent.vue
Normal file
192
src/platform/workspace/components/SpendLimitDialogContent.vue
Normal 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>
|
||||
@@ -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>'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
Reference in New Issue
Block a user