rubis/apps/api/app/controllers/invoices_controller.ts
ordinarthur 1952265217
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m0s
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy API / build-and-deploy (push) Successful in 1m52s
feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
Pricing V1 :
  - Free  : 5 factures actives, 1 user, 3 mois de grâce illimité au signup
  - Pro   : 19 €/mois ou 190 €/an, factures illimitées, 1 user
  - Business : 49 €/mois ou 490 €/an, illimité + 5 sièges (V2 multi-users)
              + reply-from-user-email (V2)

Backend :
  - Migration : plan, grace_period_ends_at, stripe_customer_id,
    stripe_subscription_id, subscription_status, billing_cycle,
    current_period_end sur `organizations`. Backfill grace_period auto.
  - `app/services/billing.ts` : PLAN_CAPS, countActiveInvoices,
    canCreateInvoices (enforce post-grace), getOrgSubscriptionState.
  - `app/services/stripe.ts` : client lazy + lookup_keys stables.
  - `app/controllers/billing_controller.ts` :
      • GET  /billing/subscription      → state pour l'UI
      • POST /billing/checkout          → crée une Checkout Session
      • POST /billing/portal            → Customer Portal Session
      • POST /billing/webhook (public)  → handle 4 events Stripe
        (checkout.completed, subscription.updated/deleted, invoice.payment_failed)
  - `commands/stripe_setup.ts` : `node ace stripe:setup` crée Products +
    Prices (idempotent via lookup_key).
  - Enforcement 402 `plan_limit_reached` sur :
      • POST /invoices (saisie manuelle)
      • POST /invoices/import-batch/:id/drafts/:draftId/validate (OCR)

Frontend :
  - `lib/billing.ts` : useSubscription, useStartCheckout, useOpenPortal,
    useIsAtFreeLimit.
  - `routes/_app/parametres_.abonnement.tsx` : page comparaison plans
    avec toggle mensuel/annuel, current plan + portail Stripe, CTA upgrade
    qui redirige vers Checkout hostée.
  - `routes/_app/parametres.tsx` : nouvelle section "Abonnement" qui
    affiche le plan courant + lien vers la page abonnement.
  - `components/billing/PlanLimitBanner.tsx` : banner sur /factures qui
    s'adapte selon période (grâce / approche / atteinte).
  - Toast dédié 402 sur la validation OCR avec action "Passer Pro".

Doc :
  - flow.md : nouvelle section §11 "Pricing & enforcement" qui couvre
    plans, grâce, webhook flow, Customer Portal, env vars.

Setup dev :
  1. STRIPE_SECRET_KEY (sk_test_...) dans apps/api/.env
  2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook`
     → copier whsec_... → STRIPE_WEBHOOK_SECRET
  3. `node ace stripe:setup` une fois pour créer Products+Prices
  4. Tester via /parametres/abonnement → checkout en mode test Stripe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:03:28 +02:00

440 lines
14 KiB
TypeScript

import Invoice from '#models/invoice'
import Plan from '#models/plan'
import RelanceTask from '#models/relance_task'
import InvoiceTransformer from '#transformers/invoice_transformer'
import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import { canCreateInvoices } from '#services/billing'
import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock'
import drive from '@adonisjs/drive/services/main'
const PAGE_SIZE = 50
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
const STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializeInvoice(i: Invoice) {
return new InvoiceTransformer(i).toObject()
}
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant (V1 simplifié — les RelanceTask viendront plus tard).
*
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
* - étape actuelle (la prochaine future) : 'current'
* - étapes futures : 'future'
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = [],
// `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow.
now: DateTime = DateTime.utc()
): Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> {
const events: Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> = [
{
id: `${invoice.id}__issued`,
state: 'past',
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: 'Importée',
},
]
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
const dueMs = invoice.dueDate.toMillis()
const nowMs = now.toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
const task = taskByStepId.get(step.id)
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
let state: 'past' | 'current' | 'future'
if (task?.status === 'sent') state = 'past'
else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
else if (!task && invoice.status === 'pending' && !currentSet) {
state = 'current'
currentSet = true
} else if (!currentSet) {
state = 'current'
currentSet = true
} else state = 'future'
const subject = step.subject.replace('{{numero}}', invoice.numero)
// Wording uniforme et rassurant : aucune relance ne part sans que l'user
// confirme l'impayé. On évite "programmé" tout court qui sonne comme
// "ça va partir tout seul".
const what = task
? task.status === 'sent'
? `Envoyée après votre confirmation · "${subject}"`
: task.status === 'cancelled'
? `Annulée — facture encaissée · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
events.push({
id: `${invoice.id}__step_${step.order}`,
state,
when: `${formatShortDate(stepDate)} · ${labelStep}`,
what,
})
}
}
if (invoice.status === 'paid' && invoice.paidAt) {
events.push({
id: `${invoice.id}__paid`,
state: 'past',
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
what: 'Marquée encaissée — relances stoppées',
})
}
return events
}
function formatShortDate(d: DateTime): string {
return d.toFormat('dd/LL/yyyy')
}
export default class InvoicesController {
/**
* GET /invoices?status=&q=&clientId=&page=
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const filters = await request.validateUsing(listInvoicesValidator)
const query = Invoice.query()
.where('organization_id', organizationId)
.preload('client')
.preload('plan')
if (filters.status && filters.status !== 'all') {
query.where('status', filters.status)
}
if (filters.clientId) {
query.where('client_id', filters.clientId)
}
if (filters.q) {
const q = filters.q.toLowerCase()
query.where((b) => {
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
sub
.from('clients')
.whereColumn('clients.id', 'invoices.client_id')
.whereILike('clients.name', `%${q}%`)
})
})
}
const invoices = await query.exec()
// Tri : actionnable d'abord (status priority), puis échéance croissante.
invoices.sort((a, b) => {
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
const page = filters.page ?? 1
const total = invoices.length
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return response.json({
data: sliced.map(serializeInvoice),
meta: { total, page },
})
}
/**
* GET /invoices/counts — compteurs par statut pour les chips dashboard.
*/
async counts({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.count('* as count')
.groupBy('status')
const counts = {
all: 0,
pending: 0,
in_relance: 0,
awaiting_user_confirmation: 0,
paid: 0,
litigation: 0,
cancelled: 0,
}
for (const r of rows) {
const c = Number(r.count)
counts.all += c
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = c
}
return response.json({ data: counts })
}
/**
* GET /invoices/:id — détail enrichi (client + plan + timeline).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan', (q) => q.preload('steps'))
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
const data = serializeInvoice(invoice)
const relanceTasks = await RelanceTask.query()
.where('invoice_id', invoice.id)
.whereNot('status', 'cancelled')
return response.json({
data: {
...data,
client: invoice.client && {
id: invoice.client.id,
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
address: invoice.client.address,
siret: invoice.client.siret,
},
plan: invoice.plan && {
id: invoice.plan.id,
slug: invoice.plan.slug,
name: invoice.plan.name,
steps: (invoice.plan.steps ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((s) => ({
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)),
},
})
}
/**
* POST /invoices — saisie manuelle.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(createInvoiceValidator)
// Plan limit Free : bloque la création si l'org a déjà 5 actives
// après la période de grâce.
const enforcement = await canCreateInvoices(organizationId, 1)
if (!enforcement.allowed) {
throw new Exception(
`Limite atteinte : ${enforcement.limit} factures actives sur le plan Free. Passez Pro pour créer cette facture.`,
{ status: 402, code: 'plan_limit_reached' }
)
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Vérification plan (s'il est fourni, doit appartenir à l'org).
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
return Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
pdfStorageKey: null,
notes: null,
paidAt: null,
},
{ client: trx }
)
})
await invoice.load('client')
await invoice.load('plan')
// Programme uniquement le check-in (envoyé à dueDate). Les relances
// client ne partent qu'après confirmation "toujours en attente".
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* GET /invoices/:id/pdf — stream le PDF/image originel de la facture.
*
* Source : `pdfStorageKey` propagé depuis le draft d'import lors de la
* validation. 404 si la facture n'a pas de fichier (saisie manuelle).
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob
* puis affiche dans un <iframe>/<img> via objectURL.
*/
async pdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (!invoice.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour cette facture', {
status: 404,
code: 'pdf_not_available',
})
}
const ext = (invoice.pdfStorageKey.split('.').pop() ?? '').toLowerCase()
const contentType =
ext === 'pdf'
? 'application/pdf'
: ext === 'png'
? 'image/png'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'application/octet-stream'
const buffer = Buffer.from(await drive.use().getArrayBuffer(invoice.pdfStorageKey))
response.header('Content-Type', contentType)
response.header('Cache-Control', 'private, max-age=300')
response.header(
'Content-Disposition',
`inline; filename="${invoice.numero}.${ext || 'pdf'}"`
)
return response.send(buffer)
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
* et sur organization.rubisCount). Annule toutes les relances futures.
*/
async markPaid({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan')
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (invoice.status === 'paid') {
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
return response.json({ data: serializeInvoice(invoice) })
}
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = await clock.now(invoice.organizationId)
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
// Bump du compteur agrégé sur l'organisation
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed).
await recordActivity({
organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
// Annule toutes les relances + le check-in programmés pour cette
// facture (idempotent, BullMQ.remove peut échouer silencieusement
// si le job a déjà été consommé).
await cancelFutureRelances(invoice.id, trx)
await cancelCheckinForInvoice(invoice.id, trx)
})
return response.json({ data: serializeInvoice(invoice) })
}
}