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>
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
import { DateTime } from 'luxon'
|
|
import db from '@adonisjs/lucid/services/db'
|
|
import Organization from '#models/organization'
|
|
|
|
/**
|
|
* Politique de plans Rubis V1 :
|
|
*
|
|
* - Free : 2 factures actives en relance, 1 user
|
|
* - Pro : factures illimitées, 1 user
|
|
* - Business : factures illimitées, 5 users (V2 multi-users), réponses
|
|
* par l'adresse mail user (V2 enhancement)
|
|
*
|
|
* Période de grâce historique : les orgs créées avant le passage à 2
|
|
* factures bénéficient toujours d'un `gracePeriodEndsAt = createdAt +
|
|
* 3 mois` posé par la migration `1778157876956_alter_organizations_table`.
|
|
* Pendant cette fenêtre, AUCUNE limite n'est appliquée. Au-delà, si
|
|
* `activeInvoicesCount > limit` → import bloqué jusqu'à upgrade.
|
|
*
|
|
* Pour les nouvelles orgs, `gracePeriodEndsAt = null` par défaut : la
|
|
* limite Free s'applique immédiatement. L'essai 14 jours Pro (CB à
|
|
* l'inscription via Stripe Setup Intent) viendra remplacer cette grâce
|
|
* historique — cf. roadmap landing-optimisations.md §3-6.
|
|
*
|
|
* Choix Free 2 factures (vs 5 initialement) : cf. ADR-022. Le segment
|
|
* cœur (freelance/artisan/kiné) émet < 5 factures/mois et restait donc
|
|
* en Free perpétuel — 2 permet de tester sans rendre le produit utilisable
|
|
* en production solo.
|
|
*/
|
|
|
|
export type PlanKey = 'free' | 'pro' | 'business'
|
|
|
|
export type PlanCaps = {
|
|
/** Nombre max de factures dans un état "actif" (pending / awaiting / in_relance / litigation). null = illimité. */
|
|
activeInvoicesLimit: number | null
|
|
/** Nombre max d'utilisateurs par org. null = illimité. */
|
|
seatsLimit: number | null
|
|
/** Multi-users autorisé ? V1 : seulement Business (mais pas implémenté). */
|
|
multiUsers: boolean
|
|
/** Réponse via l'email du user au lieu d'un from-Rubis générique ? V2. */
|
|
replyFromUserEmail: boolean
|
|
/** SMS V2. */
|
|
smsEnabled: boolean
|
|
}
|
|
|
|
export const PLAN_CAPS: Record<PlanKey, PlanCaps> = {
|
|
free: {
|
|
activeInvoicesLimit: 2,
|
|
seatsLimit: 1,
|
|
multiUsers: false,
|
|
replyFromUserEmail: false,
|
|
smsEnabled: false,
|
|
},
|
|
pro: {
|
|
activeInvoicesLimit: null,
|
|
seatsLimit: 1,
|
|
multiUsers: false,
|
|
replyFromUserEmail: false,
|
|
smsEnabled: false,
|
|
},
|
|
business: {
|
|
activeInvoicesLimit: null,
|
|
seatsLimit: 5,
|
|
multiUsers: true,
|
|
replyFromUserEmail: true,
|
|
smsEnabled: false, // V2
|
|
},
|
|
}
|
|
|
|
const ACTIVE_STATUSES = ['pending', 'awaiting_user_confirmation', 'in_relance', 'litigation']
|
|
|
|
/**
|
|
* Compte les factures considérées "actives" pour la limite Free :
|
|
* statut ∈ {pending, awaiting_user_confirmation, in_relance, litigation}.
|
|
* paid / cancelled n'occupent pas de slot.
|
|
*/
|
|
export async function countActiveInvoices(organizationId: string): Promise<number> {
|
|
const row = await db
|
|
.from('invoices')
|
|
.where('organization_id', organizationId)
|
|
.whereIn('status', ACTIVE_STATUSES)
|
|
.count('* as n')
|
|
.first()
|
|
return Number(row?.n ?? 0)
|
|
}
|
|
|
|
export type EnforcementResult =
|
|
| { allowed: true }
|
|
| {
|
|
allowed: false
|
|
reason: 'free_limit_active_invoices'
|
|
limit: number
|
|
current: number
|
|
gracePeriodEndsAt: string | null
|
|
}
|
|
|
|
/**
|
|
* Vérifie si l'org peut créer N nouvelles factures actives.
|
|
*
|
|
* Règle :
|
|
* - Plans payants → toujours autorisé
|
|
* - Free pendant la période de grâce → autorisé sans limite
|
|
* - Free pendant l'essai 14 j Stripe (status='trialing') → autorisé sans
|
|
* limite (l'user a déjà donné sa CB, il a accès Pro complet)
|
|
* - Free après période de grâce ET hors essai → bloque si
|
|
* `current + delta > limit`
|
|
*
|
|
* `delta` = nombre de factures qu'on s'apprête à créer (typiquement 1
|
|
* pour saisie manuelle, N pour upload OCR multi-fichiers).
|
|
*/
|
|
export async function canCreateInvoices(
|
|
organizationId: string,
|
|
delta = 1
|
|
): Promise<EnforcementResult> {
|
|
const org = await Organization.find(organizationId)
|
|
if (!org) return { allowed: true } // org introuvable → pas notre rôle de bloquer ici
|
|
|
|
const plan = (org.plan ?? 'free') as PlanKey
|
|
const caps = PLAN_CAPS[plan]
|
|
if (caps.activeInvoicesLimit === null) return { allowed: true }
|
|
|
|
// Free + dans la période de grâce → unlimited
|
|
const now = DateTime.utc()
|
|
if (org.gracePeriodEndsAt && org.gracePeriodEndsAt > now) {
|
|
return { allowed: true }
|
|
}
|
|
|
|
// Free + essai 14 j Stripe actif → unlimited. `subscriptionStatus`
|
|
// est posé par le webhook checkout.session.completed lors du démarrage
|
|
// du trial. `trial_ends_at` agit en garde-fou : si le webhook trial->
|
|
// active a été manqué, on évite de laisser l'unlimited en place
|
|
// indéfiniment.
|
|
if (
|
|
org.subscriptionStatus === 'trialing' &&
|
|
org.trialEndsAt &&
|
|
org.trialEndsAt > now
|
|
) {
|
|
return { allowed: true }
|
|
}
|
|
|
|
const current = await countActiveInvoices(organizationId)
|
|
if (current + delta <= caps.activeInvoicesLimit) {
|
|
return { allowed: true }
|
|
}
|
|
|
|
return {
|
|
allowed: false,
|
|
reason: 'free_limit_active_invoices',
|
|
limit: caps.activeInvoicesLimit,
|
|
current,
|
|
gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* État subscription de l'org pour exposition côté SPA — utilisé par la
|
|
* page /parametres/abonnement.
|
|
*/
|
|
export type OrgSubscriptionState = {
|
|
plan: PlanKey
|
|
caps: PlanCaps
|
|
/** Compteur courant de factures actives. */
|
|
activeInvoicesCount: number
|
|
/** True tant que l'org bénéficie de la fenêtre 3 mois post-signup. */
|
|
inGracePeriod: boolean
|
|
gracePeriodEndsAt: string | null
|
|
/** Status Stripe (`active`, `trialing`, `past_due`, `canceled`...). null pour les Free. */
|
|
subscriptionStatus: string | null
|
|
/** True ssi essai 14 j Pro actuellement actif (status=trialing + trial_ends_at futur). */
|
|
inTrial: boolean
|
|
/** ISO de fin d'essai, null si jamais d'essai démarré. */
|
|
trialEndsAt: string | null
|
|
/** 'monthly' | 'yearly' | null pour les Free. */
|
|
billingCycle: 'monthly' | 'yearly' | null
|
|
/** ISO date de fin de période courante (= prochaine facture Stripe). */
|
|
currentPeriodEnd: string | null
|
|
/** True si l'org a un Stripe customer ID (= a déjà payé une fois). */
|
|
hasStripeCustomer: boolean
|
|
/**
|
|
* True si l'user a annulé sa souscription côté Stripe et qu'elle s'éteindra
|
|
* à `currentPeriodEnd`. Pendant cette fenêtre l'org reste sur son plan
|
|
* payant (status `active`), mais l'UI affiche "annulé, accès jusqu'au DD/MM"
|
|
* et propose un bouton "Réactiver".
|
|
*/
|
|
cancelAtPeriodEnd: boolean
|
|
}
|
|
|
|
export async function getOrgSubscriptionState(
|
|
organizationId: string
|
|
): Promise<OrgSubscriptionState> {
|
|
const org = await Organization.findOrFail(organizationId)
|
|
const plan = (org.plan ?? 'free') as PlanKey
|
|
const now = DateTime.utc()
|
|
const inGracePeriod =
|
|
plan === 'free' && !!org.gracePeriodEndsAt && org.gracePeriodEndsAt > now
|
|
const inTrial =
|
|
org.subscriptionStatus === 'trialing' &&
|
|
!!org.trialEndsAt &&
|
|
org.trialEndsAt > now
|
|
|
|
return {
|
|
plan,
|
|
caps: PLAN_CAPS[plan],
|
|
activeInvoicesCount: await countActiveInvoices(organizationId),
|
|
inGracePeriod,
|
|
gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null,
|
|
subscriptionStatus: org.subscriptionStatus ?? null,
|
|
inTrial,
|
|
trialEndsAt: org.trialEndsAt?.toISO() ?? null,
|
|
billingCycle:
|
|
org.billingCycle === 'monthly' || org.billingCycle === 'yearly'
|
|
? org.billingCycle
|
|
: null,
|
|
currentPeriodEnd: org.currentPeriodEnd?.toISO() ?? null,
|
|
hasStripeCustomer: !!org.stripeCustomerId,
|
|
cancelAtPeriodEnd: !!org.cancelAtPeriodEnd,
|
|
}
|
|
}
|