Skip to content

Commit 0c83bb4

Browse files
Jaewon Yoonampagent
andcommitted
feat(billing): add inactive team subscription state
Amp-Thread-ID: https://ampcode.com/threads/T-019fb99b-c396-74eb-adb1-540ec1c3aeb0 Co-authored-by: Amp <amp@ampcode.com>
1 parent 7a6ae2f commit 0c83bb4

5 files changed

Lines changed: 187 additions & 21 deletions

File tree

src/locales/en/main.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2723,6 +2723,10 @@
27232723
"planLoadErrorRetry": "Try again",
27242724
"teamPlanName": "Team",
27252725
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
2726+
"inactiveTeamTitle": "Inactive team subscription",
2727+
"inactiveTeamDescription": "Reactivate your team plan to add more members and run workflows",
2728+
"inactiveTeamPlanIncludes": "An active plan features everything in {plan}, plus:",
2729+
"reactivateToUseCredits": "Reactivate your plan to use these credits",
27262730
"teamPerks": {
27272731
"inviteMembers": "Invite members",
27282732
"concurrentRuns": "Members can run workflows concurrently",

src/platform/cloud/subscription/components/CreditsTile.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const i18n = createI18n({
107107
additionalCredits: 'Additional credits',
108108
additionalCreditsInUse: 'In use',
109109
usedAfterMonthly: 'Used after monthly runs out',
110+
reactivateToUseCredits: 'Reactivate your plan to use these credits',
110111
monthlyCreditsUsedUpTitle:
111112
'Monthly credits are used up. Refills {date}',
112113
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
@@ -283,6 +284,18 @@ describe('CreditsTile', () => {
283284
expect(screen.queryByText('Add credits')).toBeNull()
284285
})
285286

287+
it('shows disabled credit details for an inactive plan', () => {
288+
activeProSubscription()
289+
const { container } = renderTile({ zeroState: true, inactivePlan: true })
290+
291+
expect(container.textContent).toContain('0remaining')
292+
expect(container.textContent).toContain('Additional credits')
293+
expect(container.textContent).toContain(
294+
'Reactivate your plan to use these credits'
295+
)
296+
expect(screen.queryByText('Add credits')).toBeNull()
297+
})
298+
286299
it('shows only the balance with no breakdown when there is no active subscription', () => {
287300
state.isActiveSubscription = false
288301
state.balance = { amountMicros: 500 }

src/platform/cloud/subscription/components/CreditsTile.vue

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
<template>
22
<div
3-
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
3+
:class="
4+
cn(
5+
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5',
6+
inactivePlan && 'text-muted'
7+
)
8+
"
49
>
510
<Button
611
variant="muted-textonly"
@@ -19,7 +24,14 @@
1924
</div>
2025
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
2126
<div v-else class="flex items-baseline gap-2">
22-
<i class="icon-[lucide--component] size-4 self-center text-credit" />
27+
<i
28+
:class="
29+
cn(
30+
'icon-[lucide--component] size-4 self-center',
31+
inactivePlan ? 'text-muted' : 'text-credit'
32+
)
33+
"
34+
/>
2335
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
2436
<span class="text-sm text-muted @max-[300px]:hidden">{{
2537
$t('subscription.remaining')
@@ -144,6 +156,36 @@
144156
</div>
145157
</template>
146158

159+
<template v-else-if="inactivePlan">
160+
<div class="h-px w-full bg-interface-stroke" />
161+
<div class="flex flex-col gap-2">
162+
<div class="flex items-center justify-between gap-2 text-sm">
163+
<span class="flex items-center gap-1">
164+
{{ $t('subscription.additionalCredits') }}
165+
<Button
166+
v-tooltip="{
167+
value: $t('subscription.additionalCreditsTooltip'),
168+
showDelay: 300
169+
}"
170+
variant="muted-textonly"
171+
size="icon-sm"
172+
:aria-label="$t('subscription.additionalCreditsInfo')"
173+
class="text-muted"
174+
>
175+
<i class="icon-[lucide--info] size-4" />
176+
</Button>
177+
</span>
178+
<span class="flex items-center gap-1 font-bold">
179+
<i class="icon-[lucide--component] size-4" />
180+
{{ displayPrepaid }}
181+
</span>
182+
</div>
183+
<span class="text-sm">
184+
{{ $t('subscription.reactivateToUseCredits') }}
185+
</span>
186+
</div>
187+
</template>
188+
147189
<div v-if="showActionButton" class="flex flex-col gap-3">
148190
<Button
149191
v-if="isFreeTier"
@@ -197,9 +239,10 @@ import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
197239
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
198240
import { useDialogService } from '@/services/dialogService'
199241
200-
const { zeroState = false } = defineProps<{
242+
const { zeroState = false, inactivePlan = false } = defineProps<{
201243
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
202244
zeroState?: boolean
245+
inactivePlan?: boolean
203246
}>()
204247
205248
const { locale, t } = useI18n()

src/platform/workspace/components/SubscriptionPanelContentWorkspace.test.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,9 @@ const i18n = createI18n({
252252
})
253253

254254
const CreditsTileStub = {
255-
props: ['zeroState'],
255+
props: ['zeroState', 'inactivePlan'],
256256
template:
257-
'<div data-testid="credits-tile" :data-zero-state="String(zeroState)" />'
257+
'<div data-testid="credits-tile" :data-zero-state="String(zeroState)" :data-inactive-plan="String(inactivePlan)" />'
258258
}
259259

260260
const ButtonStub = {
@@ -571,6 +571,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
571571

572572
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
573573
expect(mockResubscribe).toHaveBeenCalledOnce()
574+
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
574575
})
575576

576577
it('shows ended copy for an inactive ended subscription without a date', () => {
@@ -593,20 +594,56 @@ describe('SubscriptionPanelContentWorkspace', () => {
593594
).toBeInTheDocument()
594595
})
595596

596-
it('shows ended copy and subscribe CTA after a canceled Team plan becomes inactive', () => {
597+
it('renders an ended Team plan for its owner and routes reactivation to checkout', async () => {
597598
mockSubscriptionStatus.value = 'canceled'
598599
mockIsActiveSubscription.value = false
599600
mockIsWorkspaceSubscribed.value = false
601+
const user = userEvent.setup()
600602
renderComponent()
601603

602-
expect(screen.getByText('Your subscription has ended')).toBeInTheDocument()
603604
expect(
604-
screen.getByText('Your subscription is no longer active.')
605+
screen.getByRole('heading', { name: 'Inactive team subscription' })
605606
).toBeInTheDocument()
606607
expect(
607-
screen.getByRole('button', { name: 'Subscribe Now' })
608+
screen.getByText(
609+
'Reactivate your team plan to add more members and run workflows'
610+
)
608611
).toBeInTheDocument()
609-
expect(screen.queryByText(/^Ends on/i)).not.toBeInTheDocument()
612+
expect(
613+
screen.getByRole('button', { name: 'Billing & invoices' })
614+
).toBeInTheDocument()
615+
expect(
616+
screen.getByRole('button', { name: 'Reactivate plan' })
617+
).toBeInTheDocument()
618+
expect(
619+
screen.getByRole('button', { name: 'More Options' })
620+
).toBeInTheDocument()
621+
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
622+
'data-zero-state',
623+
'true'
624+
)
625+
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
626+
'data-inactive-plan',
627+
'true'
628+
)
629+
expect(document.body.textContent).toContain(
630+
'An active plan features everything in Pro, plus:'
631+
)
632+
expect(screen.getByText('Invite members')).toBeInTheDocument()
633+
expect(
634+
screen.getByText('Members can run workflows concurrently')
635+
).toBeInTheDocument()
636+
expect(
637+
screen.getByText('Shared credit pool for all members')
638+
).toBeInTheDocument()
639+
expect(screen.getByText('Role-based permissions')).toBeInTheDocument()
640+
641+
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
642+
643+
expect(mockShowSubscriptionDialog).toHaveBeenCalledWith({
644+
reason: 'settings_billing_panel'
645+
})
646+
expect(mockResubscribe).not.toHaveBeenCalled()
610647
})
611648

612649
it('keeps Billing & invoices portal access after a Team plan ends', async () => {

src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,30 @@
8080
<!-- OWNER Unsubscribed TEAM workspace -->
8181
<template v-if="showTeamSubscribePrompt">
8282
<div class="flex flex-col gap-2">
83-
<h3 class="m-0 text-sm font-bold text-text-primary">
84-
{{ $t('subscription.workspaceNotSubscribed') }}
83+
<h3
84+
:class="
85+
cn(
86+
'm-0 font-bold text-text-primary',
87+
showInactiveTeamSubscription ? 'text-base' : 'text-sm'
88+
)
89+
"
90+
>
91+
{{
92+
$t(
93+
showInactiveTeamSubscription
94+
? 'subscription.inactiveTeamTitle'
95+
: 'subscription.workspaceNotSubscribed'
96+
)
97+
}}
8598
</h3>
8699
<div class="text-sm text-text-secondary">
87-
{{ $t('subscription.subscriptionRequiredMessage') }}
100+
{{
101+
$t(
102+
showInactiveTeamSubscription
103+
? 'subscription.inactiveTeamDescription'
104+
: 'subscription.subscriptionRequiredMessage'
105+
)
106+
}}
88107
</div>
89108
</div>
90109
<div class="flex flex-wrap gap-2 md:ml-auto">
@@ -102,8 +121,30 @@
102121
class="rounded-lg px-4 py-2 text-sm font-normal"
103122
@click="handleSubscribeWorkspace"
104123
>
105-
{{ $t('subscription.subscribeNow') }}
124+
{{
125+
$t(
126+
showInactiveTeamSubscription
127+
? 'subscription.reactivatePlan'
128+
: 'subscription.subscribeNow'
129+
)
130+
}}
106131
</Button>
132+
<DropdownMenu
133+
v-if="showInactiveTeamSubscription && menuEntries.length > 0"
134+
:entries="menuEntries"
135+
>
136+
<template #button>
137+
<Button
138+
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
139+
variant="secondary"
140+
size="icon-lg"
141+
class="rounded-lg bg-interface-menu-component-surface-selected text-text-primary"
142+
:aria-label="$t('g.moreOptions')"
143+
>
144+
<i class="pi pi-ellipsis-h" />
145+
</Button>
146+
</template>
147+
</DropdownMenu>
107148
</div>
108149
</template>
109150

@@ -254,16 +295,27 @@
254295

255296
<div class="flex flex-col gap-6 pt-6 lg:flex-row lg:items-stretch">
256297
<div class="w-full lg:max-w-md">
257-
<CreditsTile :zero-state="showZeroState" />
298+
<CreditsTile
299+
:zero-state="showZeroState"
300+
:inactive-plan="showInactiveTeamSubscription"
301+
/>
258302
</div>
259303

260304
<div
261-
v-if="isActiveSubscription || isPersonalFree"
305+
v-if="
306+
isActiveSubscription ||
307+
isPersonalFree ||
308+
showInactiveTeamSubscription
309+
"
262310
class="flex flex-col gap-2"
263311
>
264312
<i18n-t
265-
v-if="isTeamActive"
266-
keypath="subscription.teamPlanIncludes"
313+
v-if="isTeamActive || showInactiveTeamSubscription"
314+
:keypath="
315+
showInactiveTeamSubscription
316+
? 'subscription.inactiveTeamPlanIncludes'
317+
: 'subscription.teamPlanIncludes'
318+
"
267319
tag="div"
268320
class="text-sm text-muted"
269321
>
@@ -288,7 +340,14 @@
288340
>
289341
<i
290342
v-if="benefit.type === 'feature'"
291-
class="pi pi-check text-xs text-text-primary"
343+
:class="
344+
cn(
345+
'pi pi-check text-xs',
346+
showInactiveTeamSubscription
347+
? 'text-muted'
348+
: 'text-text-primary'
349+
)
350+
"
292351
/>
293352
<span
294353
v-else-if="benefit.type === 'metric' && benefit.value"
@@ -323,6 +382,7 @@
323382
</template>
324383

325384
<script setup lang="ts">
385+
import { cn } from '@comfyorg/tailwind-utils'
326386
import { storeToRefs } from 'pinia'
327387
import { computed } from 'vue'
328388
import { useI18n } from 'vue-i18n'
@@ -422,6 +482,13 @@ const showTeamSubscribePrompt = computed(
422482
() => showSubscribePrompt.value && !isInPersonalWorkspace.value
423483
)
424484
485+
const showInactiveTeamSubscription = computed(
486+
() =>
487+
showTeamSubscribePrompt.value &&
488+
isSubscriptionEnded.value &&
489+
isTeamPlan.value
490+
)
491+
425492
const isPersonalFree = computed(
426493
() =>
427494
isInPersonalWorkspace.value &&
@@ -491,7 +558,9 @@ const scheduledPlanName = computed(() => {
491558
})
492559
493560
const showSubscriptionStateCard = computed(
494-
() => isSubscriptionCancelled.value || isSubscriptionEnded.value
561+
() =>
562+
!showInactiveTeamSubscription.value &&
563+
(isSubscriptionCancelled.value || isSubscriptionEnded.value)
495564
)
496565
497566
const subscriptionStateCardTitle = computed(() =>
@@ -558,7 +627,7 @@ const TEAM_PERK_KEYS = [
558627
] as const
559628
560629
const tierBenefits = computed((): TierBenefit[] => {
561-
if (isTeamActive.value) {
630+
if (isTeamActive.value || showInactiveTeamSubscription.value) {
562631
return TEAM_PERK_KEYS.map((key) => ({
563632
key,
564633
type: 'feature',

0 commit comments

Comments
 (0)