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:
parent
b0e9425ed5
commit
b783627890
@ -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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ -10,6 +10,7 @@ import Home from './pages/Home'
|
||||
import ResetPassword from '@/pages/ResetPassword'
|
||||
import Pricing from '@/pages/Pricing'
|
||||
import CheckoutSuccess from '@/pages/CheckoutSuccess'
|
||||
import SubscriptionCancelled from '@/pages/SubscriptionCancelled'
|
||||
import { MainLayout } from './layouts/MainLayout'
|
||||
import useAuth from '@/hooks/useAuth'
|
||||
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
|
||||
@ -45,6 +46,7 @@ function App() {
|
||||
{/* Abonnement */}
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} />
|
||||
<Route path="/subscription/cancelled" element={<ProtectedRoute><SubscriptionCancelled /></ProtectedRoute>} />
|
||||
|
||||
{/* Racine */}
|
||||
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />
|
||||
|
||||
@ -41,6 +41,17 @@ const stripeService = {
|
||||
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
|
||||
* n'est pas arrivé (mode dev sans `stripe listen`, latence, etc.).
|
||||
|
||||
@ -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 () => {
|
||||
try {
|
||||
await userService.deleteAccount(deletePassword);
|
||||
@ -484,6 +495,7 @@ export default function Profile() {
|
||||
<SubscriptionCard
|
||||
subscription={subscription}
|
||||
onManage={handleOpenPortal}
|
||||
onCancel={handleCancelSubscription}
|
||||
onSync={handleSyncSubscription}
|
||||
portalLoading={portalLoading}
|
||||
syncLoading={syncLoading}
|
||||
@ -970,6 +982,7 @@ export default function Profile() {
|
||||
interface SubscriptionCardProps {
|
||||
subscription: SubscriptionStatus | null;
|
||||
onManage: () => void;
|
||||
onCancel: () => void;
|
||||
onSync: () => void;
|
||||
portalLoading: boolean;
|
||||
syncLoading: boolean;
|
||||
@ -978,6 +991,7 @@ interface SubscriptionCardProps {
|
||||
function SubscriptionCard({
|
||||
subscription,
|
||||
onManage,
|
||||
onCancel,
|
||||
onSync,
|
||||
portalLoading,
|
||||
syncLoading,
|
||||
@ -1096,11 +1110,12 @@ function SubscriptionCard({
|
||||
: "Abonnement actif"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onManage}
|
||||
disabled={portalLoading}
|
||||
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"
|
||||
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 ? (
|
||||
<>
|
||||
@ -1110,10 +1125,19 @@ function SubscriptionCard({
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Gérer l'abonnement
|
||||
Gérer
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
136
frontend/src/pages/SubscriptionCancelled.tsx
Normal file
136
frontend/src/pages/SubscriptionCancelled.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user