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>
238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
import {
|
|
useIsAtFreeLimit,
|
|
useSubscription,
|
|
useTrialDaysRemaining,
|
|
type SubscriptionState,
|
|
} from "@/lib/billing";
|
|
import { api } from "@/lib/api";
|
|
|
|
/**
|
|
* Tests unitaires sur la logique billing côté SPA.
|
|
*
|
|
* - useSubscription : query hook qui appelle /api/v1/billing/subscription
|
|
* - useIsAtFreeLimit : helper qui dérive un booléen pour bloquer l'UI
|
|
*
|
|
* On mock `api.get` pour éviter de toucher la network. Le wrapper QueryClient
|
|
* a `retry: false` pour fail-fast en cas de mock manquant.
|
|
*/
|
|
|
|
function makeWrapper() {
|
|
const qc = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
function fakeState(overrides: Partial<SubscriptionState> = {}): SubscriptionState {
|
|
return {
|
|
plan: "free",
|
|
caps: {
|
|
activeInvoicesLimit: 2,
|
|
seatsLimit: 1,
|
|
multiUsers: false,
|
|
replyFromUserEmail: false,
|
|
smsEnabled: false,
|
|
},
|
|
activeInvoicesCount: 0,
|
|
inGracePeriod: false,
|
|
gracePeriodEndsAt: null,
|
|
subscriptionStatus: null,
|
|
inTrial: false,
|
|
trialEndsAt: null,
|
|
billingCycle: null,
|
|
currentPeriodEnd: null,
|
|
hasStripeCustomer: false,
|
|
cancelAtPeriodEnd: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("useSubscription", () => {
|
|
it("appelle l'endpoint /billing/subscription et renvoie le state", async () => {
|
|
const state = fakeState({ plan: "pro", inGracePeriod: false });
|
|
vi.spyOn(api, "get").mockResolvedValue(state);
|
|
|
|
const { result } = renderHook(() => useSubscription(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
expect(result.current.data?.plan).toBe("pro");
|
|
expect(api.get).toHaveBeenCalledWith("/api/v1/billing/subscription");
|
|
});
|
|
});
|
|
|
|
describe("useIsAtFreeLimit", () => {
|
|
it("retourne false en l'absence de données (loading)", () => {
|
|
vi.spyOn(api, "get").mockImplementation(() => new Promise(() => {})); // jamais résolu
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
expect(result.current).toBe(false);
|
|
});
|
|
|
|
it("retourne false sur un plan Pro même avec beaucoup de factures", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "pro",
|
|
caps: {
|
|
activeInvoicesLimit: null,
|
|
seatsLimit: 1,
|
|
multiUsers: false,
|
|
replyFromUserEmail: false,
|
|
smsEnabled: false,
|
|
},
|
|
activeInvoicesCount: 200,
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
// Petit délai pour laisser le state propagate après la mock resolve
|
|
await waitFor(() => expect(result.current).toBe(false));
|
|
});
|
|
|
|
it("retourne false en grace period même si activeCount > limite", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
activeInvoicesCount: 12,
|
|
inGracePeriod: true,
|
|
gracePeriodEndsAt: "2099-01-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(false));
|
|
});
|
|
|
|
it("retourne true sur Free post-grace + activeCount >= limit", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
activeInvoicesCount: 2,
|
|
inGracePeriod: false,
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(result.current).toBe(true));
|
|
});
|
|
|
|
it("retourne true même si activeCount > limit (over)", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
activeInvoicesCount: 8,
|
|
inGracePeriod: false,
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(result.current).toBe(true));
|
|
});
|
|
|
|
it("retourne false sur Free post-grace mais activeCount < limit (1 < 2)", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
activeInvoicesCount: 1,
|
|
inGracePeriod: false,
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(false));
|
|
});
|
|
|
|
it("retourne false pendant l'essai 14 j (inTrial=true bypass le quota)", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
activeInvoicesCount: 10,
|
|
inGracePeriod: false,
|
|
inTrial: true,
|
|
trialEndsAt: new Date(Date.now() + 7 * 24 * 3600_000).toISOString(),
|
|
subscriptionStatus: "trialing",
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useIsAtFreeLimit(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(false));
|
|
});
|
|
});
|
|
|
|
describe("useTrialDaysRemaining", () => {
|
|
it("retourne null hors essai", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({ plan: "free", inTrial: false }),
|
|
);
|
|
const { result } = renderHook(() => useTrialDaysRemaining(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(null));
|
|
});
|
|
|
|
it("retourne le nombre de jours arrondi sup quand essai actif", async () => {
|
|
const sevenDays = 7 * 24 * 3600 * 1000;
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
inTrial: true,
|
|
// 6.5 jours → arrondi sup = 7
|
|
trialEndsAt: new Date(Date.now() + sevenDays - 12 * 3600 * 1000).toISOString(),
|
|
subscriptionStatus: "trialing",
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useTrialDaysRemaining(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(7));
|
|
});
|
|
|
|
it("retourne 0 si trialEndsAt déjà passé (garde-fou)", async () => {
|
|
vi.spyOn(api, "get").mockResolvedValue(
|
|
fakeState({
|
|
plan: "free",
|
|
inTrial: true,
|
|
trialEndsAt: new Date(Date.now() - 1000).toISOString(),
|
|
subscriptionStatus: "trialing",
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useTrialDaysRemaining(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current).toBe(0));
|
|
});
|
|
});
|