-
-
Notifications
You must be signed in to change notification settings - Fork 131
fix(frontend): clarify expired trial plans state #2927
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
WcaleNieWolny
wants to merge
15
commits into
main
Choose a base branch
from
wolny/expired-trial-plans-state
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 9 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
d8647e7
docs: design expired trial plans state
WcaleNieWolny 8b436a0
docs: narrow expired trial plans scope
WcaleNieWolny c610c8d
docs: plan expired trial plans implementation
WcaleNieWolny 9a21b34
test(frontend): cover expired trial billing state
WcaleNieWolny 1ea9ce6
fix(frontend): clarify expired trial plans state
WcaleNieWolny 9d9877f
Merge remote-tracking branch 'origin/main' into wolny/expired-trial-p…
WcaleNieWolny 0a50eec
fix(frontend): type admin filter query for router
WcaleNieWolny a482ff4
test(cli): keep app hint mock in sync
WcaleNieWolny c542524
fix(frontend): harden trial billing state
WcaleNieWolny c046ffd
refactor(frontend): share billing history state
WcaleNieWolny 479c1a8
Merge remote-tracking branch 'origin/main' into wolny/expired-trial-p…
WcaleNieWolny 5b34c65
test(cli): remove duplicate app hint mock
WcaleNieWolny 5b355f9
fix(frontend): keep trial plan banners visible
WcaleNieWolny 12ad9a8
test(frontend): assert trial banner order
WcaleNieWolny 8e5343c
Merge remote-tracking branch 'origin/main' into wolny/expired-trial-p…
WcaleNieWolny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
259 changes: 259 additions & 0 deletions
259
docs/superpowers/plans/2026-08-07-expired-trial-plans-state.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| # Expired Trial Plans State Implementation Plan | ||
|
|
||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
|
|
||
| **Goal:** Show a clear, neutral expired-trial state on the plans page for organizations that never paid, without changing any previously subscribed state. | ||
|
|
||
| **Architecture:** Reuse the dashboard billing-history distinction through a small pure service that treats `stripe_info.paid_at === null` as never paid. The plans page loads that value with stale-response protection, derives one `showExpiredTrialState` computed value, and uses it to switch header copy, suppress the misleading error banner, and neutralize plan cards only for expired trials. | ||
|
|
||
| **Tech Stack:** Vue 3 Composition API, Pinia, Supabase JS, vue-i18n, Vitest, Tailwind CSS | ||
|
|
||
| --- | ||
|
|
||
| ## File Structure | ||
|
|
||
| - Create `src/services/paymentRequired.ts`: shared pure billing-history predicates used by dashboard and plans-page presentation. | ||
| - Create `tests/payment-required-copy.unit.test.ts`: focused resolver coverage for never-paid, previously paid, unresolved, missing-relation, and native states. | ||
| - Modify `src/pages/settings/organization/Plans.vue`: load `paid_at`, derive the expired-trial state, update header/banner/card treatment, and place secondary CTAs after the plan grid. | ||
| - Modify `messages/en.json`: add state-aware expired-trial heading and plan-specific action copy. | ||
|
|
||
| ### Task 1: Shared Expired-Trial Resolver | ||
|
|
||
| **Files:** | ||
| - Create: `src/services/paymentRequired.ts` | ||
| - Create: `tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| - [ ] **Step 1: Write the failing resolver tests** | ||
|
|
||
| ```ts | ||
| import { describe, expect, it } from 'vitest' | ||
| import { resolveBillingPaidAt, shouldShowExpiredTrialCopy } from '../src/services/paymentRequired' | ||
|
|
||
| describe('payment required copy', () => { | ||
| it.concurrent('shows expired-trial copy for a never-paid web organization', () => { | ||
| expect(shouldShowExpiredTrialCopy(false, null)).toBe(true) | ||
| }) | ||
|
|
||
| it.concurrent('treats a missing billing relation as never paid', () => { | ||
| expect(resolveBillingPaidAt(null)).toBe(null) | ||
| }) | ||
|
|
||
| it.concurrent('keeps existing copy for a previously paid web organization', () => { | ||
| expect(shouldShowExpiredTrialCopy(false, '2026-01-15T12:00:00.000Z')).toBe(false) | ||
| }) | ||
|
|
||
| it.concurrent('keeps existing copy while billing history is unresolved', () => { | ||
| expect(shouldShowExpiredTrialCopy(false, undefined)).toBe(false) | ||
| }) | ||
|
|
||
| it.concurrent('never shows purchase-oriented trial copy in the native app', () => { | ||
| expect(shouldShowExpiredTrialCopy(true, null)).toBe(false) | ||
| }) | ||
| }) | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Run the focused test and verify that it fails** | ||
|
|
||
| Run: `bunx vitest run tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| Expected: FAIL because `src/services/paymentRequired.ts` does not exist. | ||
|
|
||
| - [ ] **Step 3: Implement the minimal shared resolver** | ||
|
|
||
| ```ts | ||
| export function resolveBillingPaidAt(stripeInfo: { paid_at: string | null } | null): string | null { | ||
| return stripeInfo?.paid_at ?? null | ||
| } | ||
|
|
||
| export function shouldShowExpiredTrialCopy(isNative: boolean, paidAt: string | null | undefined): boolean { | ||
| return !isNative && paidAt === null | ||
| } | ||
| ``` | ||
|
|
||
| - [ ] **Step 4: Run the focused test and verify that it passes** | ||
|
|
||
| Run: `bunx vitest run tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| Expected: PASS with five tests. | ||
|
|
||
| - [ ] **Step 5: Commit the resolver** | ||
|
|
||
| ```bash | ||
| git add src/services/paymentRequired.ts tests/payment-required-copy.unit.test.ts | ||
| git commit -m "test(frontend): cover expired trial billing state" | ||
| ``` | ||
|
|
||
| ### Task 2: State-Aware Plans Page | ||
|
|
||
| **Files:** | ||
| - Modify: `src/pages/settings/organization/Plans.vue` | ||
| - Modify: `messages/en.json` | ||
|
|
||
| - [ ] **Step 1: Add English translation keys** | ||
|
|
||
| Add these keys to `messages/en.json` in alphabetical order: | ||
|
|
||
| ```json | ||
| "choose-plan-name": "Choose {plan}", | ||
| "trial-ended-plans-description": "Choose a plan to continue using Capgo.", | ||
| "trial-ended-title": "Your free trial has ended" | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Load billing history with organization-switch protection** | ||
|
|
||
| Import the shared billing-state helpers, then add billing state beside the existing refs. Keep the banner neutral while the lookup is pending, but preserve the existing failure presentation when the lookup itself fails: | ||
|
|
||
| ```ts | ||
| import { resolveBillingPaidAt, shouldShowExpiredTrialPlansState, shouldShowPlanFailureBanner } from '~/services/paymentRequired' | ||
|
|
||
| const paidAt = ref<string | null | undefined>(undefined) | ||
| const billingLookupFailed = ref(false) | ||
| const showExpiredTrialState = computed(() => { | ||
| return shouldShowExpiredTrialPlansState(organizationStore.currentOrganizationFailed, isMobile, paidAt.value) | ||
| }) | ||
| const showPlanFailureBanner = computed(() => { | ||
| return shouldShowPlanFailureBanner(organizationStore.currentOrganizationFailed, isMobile, paidAt.value, billingLookupFailed.value) | ||
| }) | ||
|
|
||
| let billingLookupRun = 0 | ||
| watch(() => currentOrganization.value?.gid, async (orgId) => { | ||
| const currentRun = ++billingLookupRun | ||
| paidAt.value = undefined | ||
|
WcaleNieWolny marked this conversation as resolved.
Outdated
|
||
| billingLookupFailed.value = false | ||
|
|
||
| if (isMobile || !orgId) | ||
| return | ||
|
|
||
| const { data, error } = await useSupabase() | ||
| .from('orgs') | ||
| .select('stripe_info(paid_at)') | ||
| .eq('id', orgId) | ||
| .maybeSingle() | ||
|
|
||
| if (currentRun !== billingLookupRun) | ||
| return | ||
|
|
||
| if (error || !data) { | ||
| billingLookupFailed.value = true | ||
| console.error('Failed to load organization billing history', { orgId, error }) | ||
| return | ||
| } | ||
|
|
||
| paidAt.value = resolveBillingPaidAt(data.stripe_info) | ||
| }, { immediate: true }) | ||
| ``` | ||
|
|
||
| This query is read-only and uses the current authenticated client. Do not alter ended-subscription state or query Stripe directly. | ||
|
|
||
| - [ ] **Step 3: Render state-aware header copy and preserve existing paid-state behavior** | ||
|
|
||
| Change only the pricing heading and description: | ||
|
|
||
| ```vue | ||
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white"> | ||
| {{ t(showExpiredTrialState ? 'trial-ended-title' : 'plan-pricing-plans') }} | ||
| </h1> | ||
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> | ||
| {{ t(showExpiredTrialState ? 'trial-ended-plans-description' : 'plan-desc') }} | ||
| </p> | ||
| ``` | ||
|
|
||
| Suppress the current red banner only for the proven expired-trial state: | ||
|
|
||
| ```vue | ||
| <div v-if="organizationStore.currentOrganizationFailed && !showExpiredTrialState" class="px-4 py-2 mb-4 font-medium text-center text-white bg-red-500 rounded-lg shrink-0"> | ||
| {{ t('plan-failed') }} | ||
| </div> | ||
| ``` | ||
|
|
||
| Previously paid, canceled, unresolved, and lookup-error states must continue through the existing branch unchanged. | ||
|
|
||
| - [ ] **Step 4: Neutralize cards only for expired trials** | ||
|
|
||
| Return no recommendation for the expired-trial state: | ||
|
|
||
| ```ts | ||
| function isRecommended(p: Database['public']['Tables']['plans']['Row']) { | ||
| if (showExpiredTrialState.value) | ||
| return false | ||
| return currentPlanSuggest.value?.name === p.name && (currentPlanSuggest.value?.price_m ?? 0) > (currentPlan.value?.price_m ?? 0) | ||
| } | ||
| ``` | ||
|
|
||
| Make the expired-trial action identify the selected plan: | ||
|
|
||
| ```ts | ||
| if (showExpiredTrialState.value) | ||
| return t('choose-plan-name', { plan: p.name }) | ||
| if (isTrial.value || organizationStore.currentOrganizationFailed) | ||
| return t('plan-upgrade') | ||
| ``` | ||
|
|
||
| Gate only the current-plan outline in the plan-card class: | ||
|
|
||
| ```vue | ||
| p.name === currentPlan?.name && !isCreditsOnly && !showExpiredTrialState | ||
| ? 'border-2 border-blue-500' | ||
| : 'border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700' | ||
| ``` | ||
|
|
||
| - [ ] **Step 5: Put the primary plan choice before secondary CTAs** | ||
|
|
||
| Move the existing `CreditsCta` and expert-support blocks, unchanged internally, from above the plans grid to immediately below the grid. Do not add another expired-trial callout. | ||
|
|
||
| - [ ] **Step 6: Run focused tests and frontend typechecking** | ||
|
|
||
| Run: `bunx vitest run tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| Expected: PASS. | ||
|
|
||
| Run: `bun run typecheck:frontend` | ||
|
|
||
| Expected: exit 0 with no Vue or TypeScript errors. | ||
|
|
||
| - [ ] **Step 7: Commit the plans-page behavior** | ||
|
|
||
| ```bash | ||
| git add messages/en.json src/pages/settings/organization/Plans.vue | ||
| git commit -m "fix(frontend): clarify expired trial plans state" | ||
| ``` | ||
|
|
||
| ### Task 3: Verification and Scope Guard | ||
|
|
||
| **Files:** | ||
| - Verify: `src/pages/settings/organization/Plans.vue` | ||
| - Verify: `src/services/paymentRequired.ts` | ||
| - Verify: `tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| - [ ] **Step 1: Run formatting and lint before final validation** | ||
|
|
||
| Run: `bun run lint:fix` | ||
|
|
||
| Expected: exit 0; formatting changes, if any, are limited to touched frontend files. | ||
|
|
||
| - [ ] **Step 2: Run the focused unit test after formatting** | ||
|
|
||
| Run: `bunx vitest run tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| Expected: PASS with five tests. | ||
|
|
||
| - [ ] **Step 3: Run frontend typechecking** | ||
|
|
||
| Run: `bun run typecheck:frontend` | ||
|
|
||
| Expected: exit 0. | ||
|
|
||
| - [ ] **Step 4: Review the final diff for scope** | ||
|
|
||
| Run: `git diff HEAD~2 -- messages/en.json src/pages/settings/organization/Plans.vue src/services/paymentRequired.ts tests/payment-required-copy.unit.test.ts` | ||
|
|
||
| Expected: only the never-paid expired-trial state changes. No ended-subscription, canceled-subscription, backend, schema, or migration behavior changes. | ||
|
|
||
| - [ ] **Step 5: Commit formatter-only changes if needed** | ||
|
|
||
| ```bash | ||
| git add messages/en.json src/pages/settings/organization/Plans.vue src/services/paymentRequired.ts tests/payment-required-copy.unit.test.ts | ||
| git commit -m "style(frontend): format expired trial plans changes" | ||
| ``` | ||
|
|
||
| Skip this commit when the formatter produces no diff. | ||
104 changes: 104 additions & 0 deletions
104
docs/superpowers/specs/2026-08-07-expired-trial-plans-state-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # Expired Trial State on the Plans Page | ||
|
|
||
| ## Problem | ||
|
|
||
| The organization plans page currently treats every inaccessible, non-paying organization as a failed plan. This produces two misleading signals for an organization whose trial simply expired: | ||
|
|
||
| - A red banner says that the plan failed and asks the user to verify card details, even when the organization never entered a card or bought a plan. | ||
| - A plan card can receive the blue current-plan outline because `currentPlan` falls back to a plan such as Solo, even though the organization never subscribed to it. | ||
|
|
||
| The page also places several banner-like elements before the plan cards. Adding another expired-trial alert would increase noise instead of clarifying the state. | ||
|
|
||
| ## Goals | ||
|
|
||
| - Clearly identify an expired trial without presenting it as an error. | ||
| - Never imply that an unpaid organization owns a plan. | ||
| - Use the same never-paid versus previously-paid distinction as the dashboard payment-required experience. | ||
| - Reduce the number of messages competing with the plan-selection task. | ||
| - Preserve all existing behavior for previously subscribed organizations, including ended subscriptions. | ||
|
|
||
| ## Billing State Model | ||
|
|
||
| The plans page should resolve a display state from the organization entitlement data and billing history. The in-progress dashboard expired-trial work introduces `src/services/paymentRequired.ts`; the plans page should reuse or extend that shared logic rather than reproduce its query. `stripe_info.paid_at` distinguishes organizations that never paid from organizations that previously subscribed. | ||
|
|
||
| The plans page only needs to distinguish the expired-trial case from all existing billing cases: | ||
|
|
||
| 1. **Active trial**: the organization is not paying and has trial days remaining. | ||
| 2. **Expired trial**: the organization is inactive and `paid_at` is `null`, including a missing `stripe_info` relation. | ||
| 3. **All existing billing states**: when `paid_at` contains a timestamp, preserve the current plans-page behavior without further classification or presentation changes. | ||
| 4. **Active paid or credits-only**: retain the existing applicable plan or credits presentation. | ||
|
|
||
| An unresolved billing-history request is a loading state, not an expired trial. The page must not briefly render expired-trial copy while `paid_at` is still unknown. | ||
|
|
||
| The state resolver should be shared rather than duplicating conditional logic between the dashboard and plans page. | ||
|
|
||
| ## Plans Page Presentation | ||
|
|
||
| ### Expired trial | ||
|
|
||
| Replace the normal pricing introduction with state-aware header copy: | ||
|
|
||
| - Title: **Your free trial has ended** | ||
| - Description: **Choose a plan to continue using Capgo.** | ||
|
|
||
| Do not render an additional expired-trial banner. Use the normal page typography and neutral text colors rather than red or amber error styling. | ||
|
|
||
| All plan cards must be neutral: | ||
|
|
||
| - No current-plan outline. | ||
| - No selected state. | ||
| - No recommendation badge or recommendation styling. | ||
| - Each enabled action identifies the choice, for example **Choose Solo** or **Choose Maker**. | ||
|
|
||
| The plan grid is the primary next action. Move secondary choices below it: | ||
|
|
||
| 1. Credits/pay-as-you-go alternative. | ||
| 2. Expert support promotion. | ||
|
|
||
| The global organization status may say **Trial expired**, but the plans page must not repeat that status in a separate banner. | ||
|
|
||
| ### Previously subscribed organizations | ||
|
|
||
| Do not change their copy, banners, colors, plan-card behavior, or billing-state classification in this work. This explicitly includes ended and canceled subscriptions. | ||
|
|
||
| ## Plan Card Rules | ||
|
|
||
| An expired trial must not show a current plan. In expired-trial state, suppress current-plan, selected, and recommendation styling even if `currentPlan`, `bestPlan`, or a fallback plan resolves to Solo. Existing plan-card behavior outside the expired-trial state remains unchanged. | ||
|
|
||
| ## Data Flow and Failure Handling | ||
|
|
||
| 1. Load the current organization as today. | ||
| 2. Resolve its billing history through the shared payment-required service. | ||
| 3. Keep the header in its normal neutral pricing state while billing history is unresolved. | ||
| 4. Ignore stale billing-history responses when the current organization changes. | ||
| 5. Render the expired-trial header only for a resolved never-paid organization; otherwise preserve the existing presentation. | ||
|
|
||
| If the billing-history request fails, preserve the existing presentation and log the failure. Do not infer an expired trial from a failed lookup. | ||
|
|
||
| ## Accessibility and Localization | ||
|
|
||
| - State differences must be conveyed in text, not by color alone. | ||
| - New copy must use translation keys in `messages/en.json`; do not use inline fallback strings. | ||
| - The heading remains the page's primary heading so screen-reader users encounter the organization state before the plan choices. | ||
| - Buttons must have plan-specific accessible text. | ||
|
|
||
| ## Testing | ||
|
|
||
| Add unit coverage for the shared resolver and component coverage for the plans-page presentation: | ||
|
|
||
| - Never-paid organization with an expired trial gets expired-trial header copy. | ||
| - A missing `stripe_info` relation is treated as never paid. | ||
| - Previously paid and canceled organizations retain their current presentation. | ||
| - Other previously paid inactive organizations retain their current presentation. | ||
| - Unresolved or failed billing lookup never flashes expired-trial copy. | ||
| - Expired-trial cards have no selected, current, or recommended styling. | ||
| - Active paid plan-card behavior remains unchanged. | ||
| - Credits-only behavior remains intact. | ||
| - Switching organizations cannot apply stale billing history to the new organization. | ||
|
|
||
| ## Out of Scope | ||
|
|
||
| - Changing Stripe subscription lifecycle behavior. | ||
| - Changing plan prices or plan recommendation behavior outside the expired-trial state. | ||
| - Redesigning the global dashboard payment-required overlay beyond sharing its billing-state resolver. | ||
| - Changing ended, canceled, or previously paid subscription states in any way. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.