Skip to content
Merged
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
17 changes: 16 additions & 1 deletion app/[locale]/(public)/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getSessionUser } from '@/lib/supabase/tenant'
import { getSolanaSettlementOptions } from "@/app/actions/admin/settings";
import { findConflictingSubscription } from "@/lib/payments/subscription-guard";
import { SubscriptionConflictNotice } from "@/components/public/subscription-conflict-notice";
import { PROVIDER_CAPABILITIES, type PaymentProvider } from "@/lib/payments/types";

interface SearchParams {
courseId?: string;
Expand Down Expand Up @@ -137,13 +138,27 @@ export default async function CheckoutPage(props: { params: Promise<{ locale: st
planId: dbPlan.plan_id,
});
if (conflict) {
// Self-service switch (#463): only when the current subscription's
// provider supports an automated plan change AND the target plan
// uses the same provider — cross-provider switches still need admin
// help, so they keep the "contact your school" copy.
const currentProvider = (conflict.payment_provider || 'manual') as PaymentProvider;
const caps = PROVIDER_CAPABILITIES[currentProvider];
const canSwitchPlan =
!!caps &&
(caps.supportsPlanChange || !caps.supportsNativeSubscriptions) &&
(!dbPlan.payment_provider || dbPlan.payment_provider === currentProvider);
return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-xl px-4 py-12 sm:py-20">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
</div>
<SubscriptionConflictNotice currentPlanName={conflict.plan_name} />
<SubscriptionConflictNotice
currentPlanName={conflict.plan_name}
targetPlanId={dbPlan.plan_id}
canSwitchPlan={canSwitchPlan}
/>
</div>
</div>
);
Expand Down
15 changes: 14 additions & 1 deletion app/[locale]/checkout/manual/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PaymentRequestForm } from '@/components/student/payment-request-form'
import { getManualPaymentInstructions } from '@/app/actions/admin/settings'
import { findConflictingSubscription } from '@/lib/payments/subscription-guard'
import { SubscriptionConflictNotice } from '@/components/public/subscription-conflict-notice'
import { PROVIDER_CAPABILITIES, type PaymentProvider } from '@/lib/payments/types'
import { IconShieldCheck, IconLock } from '@tabler/icons-react'
import type { Metadata } from 'next'

Expand Down Expand Up @@ -76,10 +77,22 @@ export default async function ManualCheckoutPage(props: {
planId: plan.plan_id,
})
if (conflict) {
// Self-service switch (#463): this route only ever checks out a
// 'manual' plan (non-manual plans redirect above), so the only gate is
// whether the current subscription is also on 'manual' — cross-provider
// switches still need admin help.
const currentProvider = (conflict.payment_provider || 'manual') as PaymentProvider
const caps = PROVIDER_CAPABILITIES[currentProvider]
const canSwitchPlan =
!!caps && (caps.supportsPlanChange || !caps.supportsNativeSubscriptions) && currentProvider === 'manual'
return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-xl px-4 py-12 sm:py-20">
<SubscriptionConflictNotice currentPlanName={conflict.plan_name} />
<SubscriptionConflictNotice
currentPlanName={conflict.plan_name}
targetPlanId={plan.plan_id}
canSwitchPlan={canSwitchPlan}
/>
</div>
</div>
)
Expand Down
23 changes: 19 additions & 4 deletions components/public/subscription-conflict-notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@ import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { IconAlertTriangle } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import { SwitchPlanButton } from '@/components/public/switch-plan-button'

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

return (
<div
Expand All @@ -30,12 +41,16 @@ export async function SubscriptionConflictNotice({
: t('description')}
</p>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-muted-foreground">
{t('howToSwitch')}
{canSwitchHere ? t('howToSwitchSelfService') : t('howToSwitch')}
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/dashboard/student/billing">
<Button>{t('viewBilling')}</Button>
</Link>
{canSwitchHere ? (
<SwitchPlanButton planId={targetPlanId} />
) : (
<Link href="/dashboard/student/billing">
<Button>{t('viewBilling')}</Button>
</Link>
)}
<Link href="/dashboard/student/browse">
<Button variant="outline">{t('browseCourses')}</Button>
</Link>
Expand Down
45 changes: 45 additions & 0 deletions components/public/switch-plan-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'

import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { IconLoader2 } from '@tabler/icons-react'
import { toast } from 'sonner'
import { changePlan } from '@/app/[locale]/(public)/checkout/actions'
import { Button } from '@/components/ui/button'

/**
* Self-service "switch to this plan" CTA (#463) — replaces the old dead-end
* "contact your school" copy on the parallel-subscription conflict notice.
*/
export function SwitchPlanButton({ planId }: { planId: number }) {
const t = useTranslations('pricing')
const router = useRouter()
const [isPending, startTransition] = useTransition()

const handleSwitch = () => {
startTransition(async () => {
try {
await changePlan(String(planId))
toast.success(t('switchSuccess'))
router.push('/dashboard/student/billing')
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : t('switchError'))
}
})
}

return (
<Button type="button" onClick={handleSwitch} disabled={isPending}>
{isPending ? (
<span className="flex items-center gap-2">
<IconLoader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t('switching')}
</span>
) : (
t('switchToThis')
)}
</Button>
)
}
4 changes: 3 additions & 1 deletion lib/payments/subscription-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ConflictingSubscription {
plan_id: number
plan_name: string | null
end_date: string | null
payment_provider: string | null
}

/**
Expand All @@ -47,7 +48,7 @@ export async function findConflictingSubscription(
): Promise<ConflictingSubscription | null> {
const { data, error } = await supabase
.from('subscriptions')
.select('subscription_id, plan_id, end_date, plan:plans(plan_name)')
.select('subscription_id, plan_id, end_date, payment_provider, plan:plans(plan_name)')
.eq('user_id', userId)
.eq('tenant_id', tenantId)
.in('subscription_status', BLOCKING_SUBSCRIPTION_STATUSES)
Expand All @@ -67,5 +68,6 @@ export async function findConflictingSubscription(
plan_id: conflict.plan_id,
plan_name: plan?.plan_name ?? null,
end_date: conflict.end_date,
payment_provider: conflict.payment_provider,
}
}
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4267,6 +4267,7 @@
"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.",
"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.",
"howToSwitch": "To switch plans, contact your school and ask them to cancel your current plan first.",
"howToSwitchSelfService": "You can switch to this plan yourself — no need to contact your school.",
"viewBilling": "View my billing",
"browseCourses": "Browse courses"
},
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4260,6 +4260,7 @@
"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.",
"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.",
"howToSwitch": "Para cambiar de plan, contacta a tu escuela y pide que cancelen tu plan actual primero.",
"howToSwitchSelfService": "Puedes cambiar a este plan tú mismo — no necesitas contactar a tu escuela.",
"viewBilling": "Ver mi facturación",
"browseCourses": "Explorar cursos"
},
Expand Down
Loading