feat(stripe): deep-linked cancel flow with auto-redirect

Before: 'Gérer l'abonnement' opened the generic Customer Portal. If the
user cancelled, the portal's 'return_url' was just a button label —
nothing auto-redirected back to Freedge, so the user was stranded on
billing.stripe.com after clicking 'Cancel'.

Now: dedicated 'Annuler' button on the Profile SubscriptionCard that
calls a new backend endpoint POST /stripe/portal/cancel. This creates
a portal session with flow_data.type = 'subscription_cancel' deep-linked
to the user's active subscription, plus after_completion.type = 'redirect'
so Stripe automatically redirects to /subscription/cancelled when the
cancellation is confirmed.

New page /subscription/cancelled:
- Animated heart badge (spring + pulsing halo)
- 'À bientôt, on l'espère' title
- Info box showing the period-end date (fetched via sync on mount)
  so the user knows they still have access until the end of the
  already-paid period
- Re-engagement message + 'Retour aux recettes' / 'Voir les plans' CTAs
- On mount: calls /stripe/sync so the DB is updated immediately
  (doesn't wait for the customer.subscription.updated webhook)

Profile SubscriptionCard paid-state footer now has two buttons side by
side: 'Gérer' (outline) and 'Annuler' (ghost with red hover).

Backend verified: Stripe SDK v12 supports flow_data.after_completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-08 14:10:57 +02:00
parent b0e9425ed5
commit b783627890
5 changed files with 251 additions and 18 deletions

View File

@ -270,6 +270,66 @@ const stripeRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
} }
); );
// -------------------------------------------------------------------------
// POST /stripe/portal/cancel — Portail deep-linké sur le flow d'annulation
// -------------------------------------------------------------------------
// Contrairement à /portal qui ouvre le portail en mode "libre", celui-ci
// ouvre directement l'écran de confirmation d'annulation pour la
// subscription active de l'utilisateur, et redirige automatiquement
// vers /subscription/cancelled après validation.
fastify.post(
'/portal/cancel',
{ preHandler: authenticate },
async (request, reply) => {
if (!fastify.stripe) {
return reply.code(503).send({ error: 'Stripe n\'est pas configuré' });
}
try {
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id },
});
if (!user || !user.stripeId) {
return reply.code(400).send({
error: 'Aucun compte de facturation Stripe associé',
});
}
if (!user.stripeSubscriptionId) {
return reply.code(400).send({
error: 'Aucun abonnement actif à annuler',
});
}
const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const portalSession = await fastify.stripe.billingPortal.sessions.create({
customer: user.stripeId,
// Return URL pour le bouton "retour" classique du portail
return_url: `${appUrl}/profile`,
// Deep-link dans le flow d'annulation avec auto-redirect
flow_data: {
type: 'subscription_cancel',
subscription_cancel: {
subscription: user.stripeSubscriptionId,
},
after_completion: {
type: 'redirect',
redirect: {
return_url: `${appUrl}/subscription/cancelled`,
},
},
},
});
return { url: portalSession.url };
} catch (err) {
fastify.log.error(err, 'cancel portal session creation failed');
return reply.code(500).send({ error: "Erreur lors de l'ouverture du portail" });
}
}
);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// POST /stripe/webhook — Receiver d'événements Stripe (signature vérifiée) // POST /stripe/webhook — Receiver d'événements Stripe (signature vérifiée)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -10,6 +10,7 @@ import Home from './pages/Home'
import ResetPassword from '@/pages/ResetPassword' import ResetPassword from '@/pages/ResetPassword'
import Pricing from '@/pages/Pricing' import Pricing from '@/pages/Pricing'
import CheckoutSuccess from '@/pages/CheckoutSuccess' import CheckoutSuccess from '@/pages/CheckoutSuccess'
import SubscriptionCancelled from '@/pages/SubscriptionCancelled'
import { MainLayout } from './layouts/MainLayout' import { MainLayout } from './layouts/MainLayout'
import useAuth from '@/hooks/useAuth' import useAuth from '@/hooks/useAuth'
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards' import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
@ -45,6 +46,7 @@ function App() {
{/* Abonnement */} {/* Abonnement */}
<Route path="/pricing" element={<Pricing />} /> <Route path="/pricing" element={<Pricing />} />
<Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} /> <Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} />
<Route path="/subscription/cancelled" element={<ProtectedRoute><SubscriptionCancelled /></ProtectedRoute>} />
{/* Racine */} {/* Racine */}
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} /> <Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />

View File

@ -41,6 +41,17 @@ const stripeService = {
window.location.href = url; window.location.href = url;
}, },
/**
* Ouvre directement le flow d'annulation du Customer Portal.
* Après validation, Stripe redirige automatiquement vers
* /subscription/cancelled (géré par flow_data.after_completion côté backend).
*/
openCancelFlow: async (): Promise<void> => {
const { url } = await apiService.post<{ url: string }>("/stripe/portal/cancel", {});
if (!url) throw new Error("Pas d'URL de portail reçue");
window.location.href = url;
},
/** /**
* Resynchronise l'abonnement depuis Stripe. Utile quand le webhook * Resynchronise l'abonnement depuis Stripe. Utile quand le webhook
* n'est pas arrivé (mode dev sans `stripe listen`, latence, etc.). * n'est pas arrivé (mode dev sans `stripe listen`, latence, etc.).

View File

@ -289,6 +289,17 @@ export default function Profile() {
} }
}; };
const handleCancelSubscription = async () => {
setPortalLoading(true);
try {
await stripeService.openCancelFlow();
// Redirection gérée par openCancelFlow
} catch (err) {
setError(err instanceof Error ? err.message : "Impossible d'ouvrir le portail d'annulation");
setPortalLoading(false);
}
};
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
try { try {
await userService.deleteAccount(deletePassword); await userService.deleteAccount(deletePassword);
@ -484,6 +495,7 @@ export default function Profile() {
<SubscriptionCard <SubscriptionCard
subscription={subscription} subscription={subscription}
onManage={handleOpenPortal} onManage={handleOpenPortal}
onCancel={handleCancelSubscription}
onSync={handleSyncSubscription} onSync={handleSyncSubscription}
portalLoading={portalLoading} portalLoading={portalLoading}
syncLoading={syncLoading} syncLoading={syncLoading}
@ -970,6 +982,7 @@ export default function Profile() {
interface SubscriptionCardProps { interface SubscriptionCardProps {
subscription: SubscriptionStatus | null; subscription: SubscriptionStatus | null;
onManage: () => void; onManage: () => void;
onCancel: () => void;
onSync: () => void; onSync: () => void;
portalLoading: boolean; portalLoading: boolean;
syncLoading: boolean; syncLoading: boolean;
@ -978,6 +991,7 @@ interface SubscriptionCardProps {
function SubscriptionCard({ function SubscriptionCard({
subscription, subscription,
onManage, onManage,
onCancel,
onSync, onSync,
portalLoading, portalLoading,
syncLoading, syncLoading,
@ -1096,24 +1110,34 @@ function SubscriptionCard({
: "Abonnement actif"} : "Abonnement actif"}
</p> </p>
</div> </div>
<Button <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto">
variant="outline" <Button
onClick={onManage} variant="outline"
disabled={portalLoading} onClick={onManage}
className="w-full md:w-auto h-11 px-6 rounded-full border-orange-200 hover:bg-orange-50 hover:border-orange-300 dark:border-orange-800/50 dark:hover:bg-orange-950/30" disabled={portalLoading}
> className="w-full sm:w-auto h-11 px-6 rounded-full border-orange-200 hover:bg-orange-50 hover:border-orange-300 dark:border-orange-800/50 dark:hover:bg-orange-950/30"
{portalLoading ? ( >
<> {portalLoading ? (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <>
Ouverture <div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</> Ouverture
) : ( </>
<> ) : (
<CreditCard className="mr-2 h-4 w-4" /> <>
Gérer l'abonnement <CreditCard className="mr-2 h-4 w-4" />
</> Gérer
)} </>
</Button> )}
</Button>
<Button
variant="ghost"
onClick={onCancel}
disabled={portalLoading}
className="w-full sm:w-auto h-11 px-4 rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/30"
>
Annuler
</Button>
</div>
</div> </div>
</motion.div> </motion.div>
); );

View File

@ -0,0 +1,136 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { motion } from "framer-motion"
import { Heart, ArrowRight, ChefHat, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import stripeService, { type SubscriptionStatus } from "@/api/stripe"
export default function SubscriptionCancelled() {
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null)
// Sync immédiat avec Stripe pour refléter l'annulation dans la DB
useEffect(() => {
stripeService.syncSubscription().then(setSubscription).catch(() => {
// Fallback sur un simple GET si le sync échoue
stripeService.getSubscription().then(setSubscription).catch(() => {/* ignore */})
})
}, [])
const periodEnd = subscription?.currentPeriodEnd
? new Date(subscription.currentPeriodEnd).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})
: null
return (
<div className="mx-auto max-w-2xl px-4 py-12 md:py-20 text-center">
{/* Icône heart animée */}
<motion.div
className="relative mx-auto mb-8 flex h-28 w-28 items-center justify-center"
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
>
{/* Halo */}
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-200/60 to-amber-200/40 blur-2xl"
animate={{ scale: [1, 1.15, 1], opacity: [0.6, 1, 0.6] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<div className="relative z-10 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-orange-400 to-amber-500 text-white shadow-2xl shadow-orange-500/30 ring-4 ring-white dark:ring-slate-900">
<Heart className="h-12 w-12 fill-current" strokeWidth={1.5} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-3">
À bientôt, on l'espère
</h1>
<p className="text-muted-foreground text-base md:text-lg max-w-md mx-auto">
Ton abonnement a bien é annulé. Merci d'avoir fait un bout de chemin
avec Freedge.
</p>
</motion.div>
{/* Info période payée */}
{periodEnd && (
<motion.div
className="mt-8 mx-auto max-w-md rounded-2xl border border-orange-200/60 bg-orange-50/60 dark:bg-orange-950/20 dark:border-orange-900/40 p-5 backdrop-blur-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<div className="flex items-start gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-orange-100 dark:bg-orange-950/60 flex items-center justify-center">
<Sparkles className="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
<div className="text-left">
<p className="text-sm font-semibold text-foreground">
Profite de ton plan jusqu'au {periodEnd}
</p>
<p className="text-xs text-muted-foreground mt-1">
Tu conserves l'accès à toutes les fonctionnalités premium jusqu'à
la fin de la période déjà payée. Ensuite, tu repasseras
automatiquement sur le plan gratuit.
</p>
</div>
</div>
</motion.div>
)}
{/* Message de re-engagement */}
<motion.p
className="mt-8 text-sm text-muted-foreground max-w-md mx-auto italic"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
>
Tu changes d'avis ? Tu peux te réabonner à tout moment depuis la page
Tarifs, sans perdre tes recettes ni tes préférences.
</motion.p>
<motion.div
className="mt-10 flex flex-col sm:flex-row gap-3 justify-center"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
<Link to="/recipes">
<Button
size="lg"
className="w-full sm:w-auto h-12 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md shadow-orange-500/25 hover:shadow-lg hover:shadow-orange-500/40 transition-all font-semibold group"
>
<ChefHat className="mr-2 h-5 w-5" />
Retour à mes recettes
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
<Link to="/pricing">
<Button
size="lg"
variant="outline"
className="w-full sm:w-auto h-12 px-6 rounded-full"
>
Voir les plans
</Button>
</Link>
</motion.div>
<motion.p
className="mt-12 text-xs text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
>
Une dernière chose si tu as un retour à nous partager, n'hésite pas :
ça nous aide à faire mieux.
</motion.p>
</div>
)
}