rubis/apps/web/src/components/billing/PlanLimitBanner.tsx
ordinarthur b0e6f83655
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m19s
Build & Deploy API / build-and-deploy (push) Successful in 1m44s
Build & Deploy Web / build-and-deploy (push) Successful in 41s
feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
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>
2026-05-18 12:04:41 +02:00

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>
);
}