Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions docs/superpowers/plans/2026-08-07-expired-trial-plans-state.md
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'
Comment thread
WcaleNieWolny marked this conversation as resolved.
Outdated

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
Comment thread
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 docs/superpowers/specs/2026-08-07-expired-trial-plans-state-design.md
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.
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@
"checksum-sha256-desc": "v5 + encryption, v6, v7, v8",
"checksum-type-info": "Checksum Algorithm",
"choose-a-plan": "Choose a plan",
"choose-plan-name": "Choose {plan}",
"choose-which-channel-to-link-this-bundle-to": "Choose which channel to link this bundle to",
"cli-doc": "CLI doc",
"cli-version": "CLI version",
Expand Down Expand Up @@ -2349,6 +2350,7 @@
"trial-banner-message": "Enjoying your Capgo trial? Subscribe to a plan.",
"trial-end-date": "Trial End Date",
"trial-ended-description": "Choose a plan to continue using Capgo updates and other features. If you didn’t get a chance to explore Capgo during your trial, email {supportEmail} to request an extension.",
"trial-ended-plans-description": "Choose a plan to continue using Capgo.",
"trial-ended-title": "Your free trial has ended",
"trial-extended-badge": "Extended {count}",
"trial-extension-conversion-trend": "Trial Extension Conversion Trend",
Expand Down
3 changes: 2 additions & 1 deletion src/components/admin/AdminFilterBar.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import type { DateRangeMode } from '~/stores/adminDashboard'
import { useMutationObserver } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
Expand Down Expand Up @@ -102,7 +103,7 @@ function syncStoreToQuery() {
return
}

const nextQuery: Record<string, string | string[] | null | undefined> = { ...route.query }
const nextQuery: LocationQueryRaw = { ...route.query }
nextQuery.range = serialized.range
if (serialized.start && serialized.end) {
nextQuery.start = serialized.start
Expand Down
Loading
Loading