Skip to content

Commit 346795f

Browse files
fix(payments): offer self-service plan switch on the conflict notice (#458)
The parallel-subscription conflict notice shown by /checkout and /checkout/manual (issue #459) still told students to "contact your school" to switch plans, even after #463 shipped a real self-service switch (changePlan action + pricing-page CTAs). The notice was never updated to use it. Now the notice renders a "Switch to this plan" button (reusing the existing changePlan action) whenever the current subscription's provider supports an automated switch and the target plan uses the same provider, falling back to the old contact-your-school copy only for genuine cross-provider cases changePlan itself can't handle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHYmMbZvQvZWJXYxyqKPUH
1 parent a6fc9f9 commit 346795f

7 files changed

Lines changed: 99 additions & 7 deletions

File tree

app/[locale]/(public)/checkout/page.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getSessionUser } from '@/lib/supabase/tenant'
1010
import { getSolanaSettlementOptions } from "@/app/actions/admin/settings";
1111
import { findConflictingSubscription } from "@/lib/payments/subscription-guard";
1212
import { SubscriptionConflictNotice } from "@/components/public/subscription-conflict-notice";
13+
import { PROVIDER_CAPABILITIES, type PaymentProvider } from "@/lib/payments/types";
1314

1415
interface SearchParams {
1516
courseId?: string;
@@ -137,13 +138,27 @@ export default async function CheckoutPage(props: { params: Promise<{ locale: st
137138
planId: dbPlan.plan_id,
138139
});
139140
if (conflict) {
141+
// Self-service switch (#463): only when the current subscription's
142+
// provider supports an automated plan change AND the target plan
143+
// uses the same provider — cross-provider switches still need admin
144+
// help, so they keep the "contact your school" copy.
145+
const currentProvider = (conflict.payment_provider || 'manual') as PaymentProvider;
146+
const caps = PROVIDER_CAPABILITIES[currentProvider];
147+
const canSwitchPlan =
148+
!!caps &&
149+
(caps.supportsPlanChange || !caps.supportsNativeSubscriptions) &&
150+
(!dbPlan.payment_provider || dbPlan.payment_provider === currentProvider);
140151
return (
141152
<div className="min-h-screen bg-background">
142153
<div className="mx-auto max-w-xl px-4 py-12 sm:py-20">
143154
<div className="mb-8 text-center">
144155
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
145156
</div>
146-
<SubscriptionConflictNotice currentPlanName={conflict.plan_name} />
157+
<SubscriptionConflictNotice
158+
currentPlanName={conflict.plan_name}
159+
targetPlanId={dbPlan.plan_id}
160+
canSwitchPlan={canSwitchPlan}
161+
/>
147162
</div>
148163
</div>
149164
);

app/[locale]/checkout/manual/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PaymentRequestForm } from '@/components/student/payment-request-form'
66
import { getManualPaymentInstructions } from '@/app/actions/admin/settings'
77
import { findConflictingSubscription } from '@/lib/payments/subscription-guard'
88
import { SubscriptionConflictNotice } from '@/components/public/subscription-conflict-notice'
9+
import { PROVIDER_CAPABILITIES, type PaymentProvider } from '@/lib/payments/types'
910
import { IconShieldCheck, IconLock } from '@tabler/icons-react'
1011
import type { Metadata } from 'next'
1112

@@ -76,10 +77,22 @@ export default async function ManualCheckoutPage(props: {
7677
planId: plan.plan_id,
7778
})
7879
if (conflict) {
80+
// Self-service switch (#463): this route only ever checks out a
81+
// 'manual' plan (non-manual plans redirect above), so the only gate is
82+
// whether the current subscription is also on 'manual' — cross-provider
83+
// switches still need admin help.
84+
const currentProvider = (conflict.payment_provider || 'manual') as PaymentProvider
85+
const caps = PROVIDER_CAPABILITIES[currentProvider]
86+
const canSwitchPlan =
87+
!!caps && (caps.supportsPlanChange || !caps.supportsNativeSubscriptions) && currentProvider === 'manual'
7988
return (
8089
<div className="min-h-screen bg-background">
8190
<div className="mx-auto max-w-xl px-4 py-12 sm:py-20">
82-
<SubscriptionConflictNotice currentPlanName={conflict.plan_name} />
91+
<SubscriptionConflictNotice
92+
currentPlanName={conflict.plan_name}
93+
targetPlanId={plan.plan_id}
94+
canSwitchPlan={canSwitchPlan}
95+
/>
8396
</div>
8497
</div>
8598
)

components/public/subscription-conflict-notice.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,29 @@ import { getTranslations } from 'next-intl/server'
22
import Link from 'next/link'
33
import { IconAlertTriangle } from '@tabler/icons-react'
44
import { Button } from '@/components/ui/button'
5+
import { SwitchPlanButton } from '@/components/public/switch-plan-button'
56

67
/**
78
* Blocking notice shown in place of the checkout form when the student already
89
* has a live subscription to a different plan in this school (issue #459).
910
* Server component — rendered by the checkout pages before any payment UI.
11+
*
12+
* When the current subscription's provider supports an automated switch
13+
* (#463), this offers a self-service "Switch to this plan" CTA instead of the
14+
* "contact your school" dead end — cross-provider switches (and providers
15+
* without a change-plan path) still fall back to that copy.
1016
*/
1117
export async function SubscriptionConflictNotice({
1218
currentPlanName,
19+
targetPlanId,
20+
canSwitchPlan = false,
1321
}: {
1422
currentPlanName: string | null
23+
targetPlanId?: number
24+
canSwitchPlan?: boolean
1525
}) {
1626
const t = await getTranslations('checkout.conflict')
27+
const canSwitchHere = canSwitchPlan && typeof targetPlanId === 'number'
1728

1829
return (
1930
<div
@@ -30,12 +41,16 @@ export async function SubscriptionConflictNotice({
3041
: t('description')}
3142
</p>
3243
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-muted-foreground">
33-
{t('howToSwitch')}
44+
{canSwitchHere ? t('howToSwitchSelfService') : t('howToSwitch')}
3445
</p>
3546
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
36-
<Link href="/dashboard/student/billing">
37-
<Button>{t('viewBilling')}</Button>
38-
</Link>
47+
{canSwitchHere ? (
48+
<SwitchPlanButton planId={targetPlanId} />
49+
) : (
50+
<Link href="/dashboard/student/billing">
51+
<Button>{t('viewBilling')}</Button>
52+
</Link>
53+
)}
3954
<Link href="/dashboard/student/browse">
4055
<Button variant="outline">{t('browseCourses')}</Button>
4156
</Link>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import { useTransition } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { useTranslations } from 'next-intl'
6+
import { IconLoader2 } from '@tabler/icons-react'
7+
import { toast } from 'sonner'
8+
import { changePlan } from '@/app/[locale]/(public)/checkout/actions'
9+
import { Button } from '@/components/ui/button'
10+
11+
/**
12+
* Self-service "switch to this plan" CTA (#463) — replaces the old dead-end
13+
* "contact your school" copy on the parallel-subscription conflict notice.
14+
*/
15+
export function SwitchPlanButton({ planId }: { planId: number }) {
16+
const t = useTranslations('pricing')
17+
const router = useRouter()
18+
const [isPending, startTransition] = useTransition()
19+
20+
const handleSwitch = () => {
21+
startTransition(async () => {
22+
try {
23+
await changePlan(String(planId))
24+
toast.success(t('switchSuccess'))
25+
router.push('/dashboard/student/billing')
26+
router.refresh()
27+
} catch (error) {
28+
toast.error(error instanceof Error ? error.message : t('switchError'))
29+
}
30+
})
31+
}
32+
33+
return (
34+
<Button type="button" onClick={handleSwitch} disabled={isPending}>
35+
{isPending ? (
36+
<span className="flex items-center gap-2">
37+
<IconLoader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
38+
{t('switching')}
39+
</span>
40+
) : (
41+
t('switchToThis')
42+
)}
43+
</Button>
44+
)
45+
}

lib/payments/subscription-guard.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ConflictingSubscription {
3030
plan_id: number
3131
plan_name: string | null
3232
end_date: string | null
33+
payment_provider: string | null
3334
}
3435

3536
/**
@@ -47,7 +48,7 @@ export async function findConflictingSubscription(
4748
): Promise<ConflictingSubscription | null> {
4849
const { data, error } = await supabase
4950
.from('subscriptions')
50-
.select('subscription_id, plan_id, end_date, plan:plans(plan_name)')
51+
.select('subscription_id, plan_id, end_date, payment_provider, plan:plans(plan_name)')
5152
.eq('user_id', userId)
5253
.eq('tenant_id', tenantId)
5354
.in('subscription_status', BLOCKING_SUBSCRIPTION_STATUSES)
@@ -67,5 +68,6 @@ export async function findConflictingSubscription(
6768
plan_id: conflict.plan_id,
6869
plan_name: plan?.plan_name ?? null,
6970
end_date: conflict.end_date,
71+
payment_provider: conflict.payment_provider,
7072
}
7173
}

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4267,6 +4267,7 @@
42674267
"descriptionWithPlan": "Your subscription to {planName} is still active. Subscribing to another plan would not replace it — you would be billed for both plans at the same time.",
42684268
"description": "You already have an active subscription in this school. Subscribing to another plan would not replace it — you would be billed for both plans at the same time.",
42694269
"howToSwitch": "To switch plans, contact your school and ask them to cancel your current plan first.",
4270+
"howToSwitchSelfService": "You can switch to this plan yourself — no need to contact your school.",
42704271
"viewBilling": "View my billing",
42714272
"browseCourses": "Browse courses"
42724273
},

messages/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4260,6 +4260,7 @@
42604260
"descriptionWithPlan": "Tu suscripción a {planName} sigue activa. Suscribirte a otro plan no la reemplazaría — se te cobrarían ambos planes al mismo tiempo.",
42614261
"description": "Ya tienes una suscripción activa en esta escuela. Suscribirte a otro plan no la reemplazaría — se te cobrarían ambos planes al mismo tiempo.",
42624262
"howToSwitch": "Para cambiar de plan, contacta a tu escuela y pide que cancelen tu plan actual primero.",
4263+
"howToSwitchSelfService": "Puedes cambiar a este plan tú mismo — no necesitas contactar a tu escuela.",
42634264
"viewBilling": "Ver mi facturación",
42644265
"browseCourses": "Explorar cursos"
42654266
},

0 commit comments

Comments
 (0)