Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le funnel signup propose maintenant un essai 14 j Pro avec carte demandée mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11 (webhook customer.subscription.trial_will_end de Stripe). Couverture tests : 60 tests unitaires sur la couche billing - billing.spec.ts (25) — quota Free, bypass trial, inTrial state - stripe_billing.spec.ts (24) — handlers webhook, idempotence, dispatcher - trial_recap_job.spec.ts (11) — stats aggregation, formatRubisToHoursFr + 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining, useIsAtFreeLimit bypass trial). Backend : - Migration 1779000000000_add_trial_ends_at_to_organizations - PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur - getOrgSubscriptionState expose inTrial + trialEndsAt - Refactor handlers webhook en service stripe_billing.ts (pures, testables) — extraction depuis le controller. dispatchWebhookEvent routeur typé également extrait pour les tests. - createTrialCheckoutSession avec subscription_data.trial_period_days=14, garde-fou TrialAlreadyConsumedError contre re-trial. - handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe basé sur subscriptionId, idempotent contre re-delivery Stripe). - Endpoint POST /api/v1/billing/start-trial. - Email template trial_recap (React Email, branding Rubis figé) avec stats: factures importées, relances envoyées, € récupérés, rubis + heures libérées. Infra de test : - tests/helpers/stripe_mock.ts : __setStripeForTests injection + factories fakeSubscription / fakeCheckoutSession / fakeInvoice. - __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis. Frontend : - /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) : bouton primaire essai 14j + fallback "Free 2 factures". - PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui prime sur les autres bandeaux. Discret rubis-glow, non blocant. - useStartTrial hook + useTrialDaysRemaining (arrondi sup). - SubscriptionState typé avec inTrial + trialEndsAt. Landing : - Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 » (Hero + FinalCTA), maintenant promesse véridique. Notes ouvertes (à décider ultérieurement) : - Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte : guard reste à activer (risque cassage du signup actuel sinon). Pour l'instant l'écran est accessible mais opt-in. - Cron de redondance trial-recap : pas encore implémenté (le jobId déterministe BullMQ couvre déjà la double-livraison Stripe). À ajouter si on observe des trial sans recap en prod. - Tests E2E avec Stripe test mode à faire avant le go-live (cartes 3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
import { Link } from "@tanstack/react-router";
|
|
import { ArrowRight, Clock, Sparkles, Zap } from "lucide-react";
|
|
|
|
import { useSubscription, useTrialDaysRemaining } from "@/lib/billing";
|
|
import { cn } from "@/lib/utils";
|
|
import { formatDate } from "@/lib/format";
|
|
|
|
/**
|
|
* Banner d'enforcement plan Free.
|
|
*
|
|
* - "Essai" : essai 14 j actif → countdown rubis (toujours affiché)
|
|
* - Hidden : plan Pro/Business OU période de grâce active
|
|
* - "Approche" : ratio ≥ 80 % du quota → ton conseil
|
|
* - "Atteinte" : ratio ≥ 100 % du quota → ton blocant + CTA upgrade
|
|
*
|
|
* Posé en haut de /factures et /factures/import. Pas dans le dashboard
|
|
* pour ne pas polluer la lecture des KPIs.
|
|
*/
|
|
export function PlanLimitBanner({ className }: { className?: string }) {
|
|
const { data: sub } = useSubscription();
|
|
const trialDays = useTrialDaysRemaining();
|
|
if (!sub) return null;
|
|
|
|
// Essai 14 j Pro actif → countdown discret rubis-glow, toujours visible
|
|
// pendant la fenêtre. Pas blocant — l'user a accès Pro complet.
|
|
if (sub.inTrial && trialDays !== null) {
|
|
const dayLabel = trialDays > 1 ? "jours" : "jour";
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-card border border-rubis/25 bg-rubis-glow/40 px-4 py-3",
|
|
"flex items-center gap-3 text-[12.5px] text-ink-2",
|
|
className,
|
|
)}
|
|
>
|
|
<Clock size={14} className="text-rubis shrink-0" aria-hidden="true" />
|
|
<p className="leading-snug flex-1 min-w-0">
|
|
<strong className="text-ink font-semibold">Essai Pro</strong> — plus que{" "}
|
|
<strong className="font-medium">
|
|
{trialDays} {dayLabel}
|
|
</strong>
|
|
{sub.trialEndsAt ? (
|
|
<>
|
|
{" · prélèvement le "}
|
|
<strong className="font-medium">{formatDate(sub.trialEndsAt)}</strong>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
<Link
|
|
to="/parametres/abonnement"
|
|
className="shrink-0 text-[12.5px] font-medium text-rubis hover:underline underline-offset-4"
|
|
>
|
|
Gérer
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sub.plan !== "free") return null;
|
|
const limit = sub.caps.activeInvoicesLimit;
|
|
if (limit === null) return null;
|
|
|
|
// En grace period → on affiche un mini-rappel doux, pas un blocking banner.
|
|
if (sub.inGracePeriod && sub.gracePeriodEndsAt) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-card border border-rubis-glow bg-rubis-glow/30 px-4 py-3",
|
|
"flex items-center gap-3 text-[12.5px] text-ink-2",
|
|
className,
|
|
)}
|
|
>
|
|
<Sparkles size={14} className="text-rubis shrink-0" aria-hidden="true" />
|
|
<p className="leading-snug">
|
|
<strong className="text-ink font-semibold">Période de grâce</strong>{" "}
|
|
— illimité jusqu'au{" "}
|
|
<strong className="font-medium">
|
|
{formatDate(sub.gracePeriodEndsAt)}
|
|
</strong>
|
|
. Au-delà, le plan Free est plafonné à {limit} factures actives.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ratio = sub.activeInvoicesCount / limit;
|
|
if (ratio < 0.8) return null;
|
|
|
|
const reached = sub.activeInvoicesCount >= limit;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-card border px-4 py-3.5 flex items-start gap-3",
|
|
reached
|
|
? "border-rubis-deep bg-rubis-glow/40"
|
|
: "border-rubis bg-rubis-glow/20",
|
|
className,
|
|
)}
|
|
>
|
|
<Zap
|
|
size={16}
|
|
className={cn(
|
|
"shrink-0 mt-0.5",
|
|
reached ? "text-rubis-deep" : "text-rubis",
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-display text-[14px] font-semibold text-ink leading-tight">
|
|
{reached
|
|
? "Limite Free atteinte"
|
|
: `Bientôt à la limite (${sub.activeInvoicesCount}/${limit})`}
|
|
</p>
|
|
<p className="mt-1 text-[12.5px] text-ink-2 leading-snug">
|
|
{reached
|
|
? `Vous avez utilisé vos ${limit} factures actives gratuites. Passez Pro pour continuer à importer et relancer sans contrainte.`
|
|
: "Vous approchez de la limite Free. Passer Pro maintenant évite l'interruption."}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
to="/parametres/abonnement"
|
|
className={cn(
|
|
"shrink-0 inline-flex items-center gap-1.5 rounded-default px-3 py-2 cursor-pointer",
|
|
"text-[12.5px] font-semibold transition-colors",
|
|
reached
|
|
? "bg-rubis text-white hover:bg-rubis-deep"
|
|
: "bg-white border border-rubis text-rubis hover:bg-rubis hover:text-white",
|
|
)}
|
|
>
|
|
Passer Pro <ArrowRight size={12} aria-hidden="true" />
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|