Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
302 changes: 302 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,302 @@
# 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. A shared composable loads that value with stale-response protection for both the dashboard modal and plans page. The plans page 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 `src/composables/useBillingPaidAt.ts`: shared billing-history query, error state, and stale-response guard.
- Create `tests/payment-required-copy.unit.test.ts`: focused resolver coverage for never-paid, previously paid, unresolved, missing-relation, and native states.
- Modify `src/components/PaymentRequiredModal.vue`: consume the shared billing-history composable.
- 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, shouldShowExpiredTrialPlansState, shouldShowPlanFailureBanner } 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)
})

it.concurrent('shows the plans-page state only for an expired never-paid organization', () => {
expect(shouldShowExpiredTrialPlansState(true, false, null)).toBe(true)
expect(shouldShowExpiredTrialPlansState(true, false, undefined)).toBe(false)
})

it.concurrent('keeps the failure banner neutral until billing history resolves', () => {
expect(shouldShowPlanFailureBanner(true, false, undefined)).toBe(false)
expect(shouldShowPlanFailureBanner(true, false, '2026-01-15T12:00:00.000Z')).toBe(true)
})
})
```

- [ ] **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
}

export function shouldShowExpiredTrialPlansState(currentOrganizationFailed: boolean, isNative: boolean, paidAt: string | null | undefined): boolean {
return currentOrganizationFailed && shouldShowExpiredTrialCopy(isNative, paidAt)
}

export function shouldShowPlanFailureBanner(
currentOrganizationFailed: boolean,
isNative: boolean,
paidAt: string | null | undefined,
billingLookupFailed = false,
): boolean {
return currentOrganizationFailed
&& (isNative || billingLookupFailed || paidAt !== undefined)
&& !shouldShowExpiredTrialPlansState(currentOrganizationFailed, isNative, paidAt)
}
```

- [ ] **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 seven 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:**
- Create: `src/composables/useBillingPaidAt.ts`
- Modify: `src/components/PaymentRequiredModal.vue`
- 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: Share billing-history loading with organization-switch protection**

Create `useBillingPaidAt` with the authenticated `orgs.stripe_info(paid_at)` lookup, a monotonically increasing request token, and explicit lookup-error state. It returns `paidAt` and `billingLookupFailed` refs, resets both when the organization changes, ignores stale responses, and logs failures with the organization ID.

```ts
export function useBillingPaidAt(orgId: Readonly<Ref<string | null | undefined>>, disabled = false) {
const paidAt = ref<string | null | undefined>(undefined)
const billingLookupFailed = ref(false)
let billingLookupRun = 0

watch(orgId, async (nextOrgId) => {
const currentRun = ++billingLookupRun
paidAt.value = undefined
billingLookupFailed.value = false

if (disabled || !nextOrgId)
return

const { data, error } = await useSupabase()
.from('orgs')
.select('stripe_info(paid_at)')
.eq('id', nextOrgId)
.maybeSingle()

if (currentRun !== billingLookupRun)
return

if (error || !data) {
billingLookupFailed.value = true
console.error('Failed to load organization billing history', { orgId: nextOrgId, error })
return
}

paidAt.value = resolveBillingPaidAt(data.stripe_info)
}, { immediate: true })

return { paidAt, billingLookupFailed }
}
```

Replace the inline watcher in `PaymentRequiredModal.vue` with the composable, then use the same composable on the plans page. Keep the plans banner neutral while the lookup is pending, but preserve the existing failure presentation when the lookup itself fails:

```ts
import { useBillingPaidAt } from '~/composables/useBillingPaidAt'
import { shouldShowExpiredTrialPlansState, shouldShowPlanFailureBanner } from '~/services/paymentRequired'

const billingOrgId = computed(() => currentOrganization.value?.gid)
const { paidAt, billingLookupFailed } = useBillingPaidAt(billingOrgId, isMobile)
const showExpiredTrialState = computed(() => {
return shouldShowExpiredTrialPlansState(organizationStore.currentOrganizationFailed, isMobile, paidAt.value)
})
const showPlanFailureBanner = computed(() => {
return shouldShowPlanFailureBanner(organizationStore.currentOrganizationFailed, isMobile, paidAt.value, billingLookupFailed.value)
})
```

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>
```

Render the current red banner only after billing history proves a non-trial failure, or when the lookup fails and the existing fallback is required:

```vue
<div v-if="showPlanFailureBanner" 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**

Render the existing `CreditsCta` and expert-support blocks once. Use flex ordering and state-specific spacing to keep them above the grid for existing states and immediately below the grid for the expired-trial state. Do not duplicate their markup or 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/composables/useBillingPaidAt.ts src/components/PaymentRequiredModal.vue 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/components/PaymentRequiredModal.vue`
- Verify: `src/composables/useBillingPaidAt.ts`
- 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 seven 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/components/PaymentRequiredModal.vue src/composables/useBillingPaidAt.ts 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/components/PaymentRequiredModal.vue src/composables/useBillingPaidAt.ts 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.
Loading
Loading