rubis/apps/api/start/routes.ts
ordinarthur e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
  préfixe et compteur de numérotation, mentions légales (pénalités,
  escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
  /country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
  figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
  theme_slug + theme_accent_color, is_native, sequence_number (unique
  per org), pdf_generated_at.

API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
  totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)

Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).

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

512 lines
19 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| Toutes les routes sont sous /api/v1/. Les groupes auth et account sont
| importés depuis le contrôleur généré par Tuyau pour garantir le contrat
| client typé (cf. docs/tech/backend.md §8).
|
*/
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
/**
* Blog public — endpoints JSON consommés par apps/landing (Astro SSR).
* Pas auth, pas de paginiation V1 (volume cible <100 articles).
*/
const BlogController = () => import('#controllers/blog_controller')
const AdminPostsController = () => import('#controllers/admin_posts_controller')
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
const BrandController = () => import('#controllers/brand_controller')
const BankingController = () => import('#controllers/banking_controller')
const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller')
const InvoiceSettingsController = () => import('#controllers/invoice_settings_controller')
const InvoiceThemesController = () => import('#controllers/invoice_themes_controller')
router
.group(() => {
/**
* Health check — public, utilisé par les sondes K3s (startup/liveness/
* readiness) et le healthcheck Docker. Retourne 200 sans toucher la DB
* pour rester rapide ; la DB est implicitement vérifiée au boot par
* le init-container migrate.
*/
router.get('health', () => ({ status: 'ok', uptime: process.uptime() })).as('health')
/**
* Sentry test trigger — endpoint debug qui throw délibérément pour
* vérifier l'E2E observability (capture, release tag, sourcemaps,
* user context). Disponible UNIQUEMENT si NODE_ENV !== 'production'
* OU si DEBUG_SENTRY_TEST=true. À retirer une fois l'intégration
* validée sur la prod.
*/
if (process.env.NODE_ENV !== 'production' || process.env.DEBUG_SENTRY_TEST === 'true') {
router.get('_debug/sentry-test', () => {
throw new Error(`Sentry test from rubis-api — ${new Date().toISOString()}`)
})
}
/**
* Blog — endpoints JSON publics consommés par apps/landing en SSR.
* Cf. apps/landing/src/pages/blog/*.astro pour la consommation et
* apps/api/app/controllers/blog_controller.ts pour l'implémentation.
*/
router
.group(() => {
router.get('', [BlogController, 'index']).as('index')
router.get(':slug', [BlogController, 'show']).as('show')
})
.prefix('posts')
.as('posts')
/**
* Images servies depuis MinIO. Public, cache infini (le filename est
* un UUID immuable, chaque upload = nouvelle URL).
*
* Deux scopes :
* - `/uploads/blog/:filename` — hero images d'articles
* - `/uploads/brand-logos/:filename` — logos de marque blanche (Business)
*
* Le BlogUploadsController.show est en fait scope-agnostic (délègue à
* media_storage.readMedia), donc on le réutilise pour les deux. Un
* controller dédié serait du boilerplate inutile vu que la logique est
* 100% dans le service.
*/
router
.get('uploads/blog/:filename', [BlogUploadsController, 'show'])
.as('uploads.blog.show')
router
.get('uploads/brand-logos/:filename', [BrandController, 'showLogo'])
.as('uploads.brand_logos.show')
/**
* Banking — callback Powens. Public parce que le navigateur du user
* arrive ici en GET après que Powens ait fini la webview, sans
* Bearer token. La sécurité est portée par le `state` HMAC signé
* dans l'URL (verifié dans le service). Redirige toujours en 302
* vers le SPA avec ?banking=connected|error.
*/
router
.get('banking/powens/callback', [BankingController, 'callback'])
.as('banking.powens.callback')
/**
* Banking status — public. Renvoie `{ enabled }` pour que le SPA
* sache s'il doit afficher la section banque dans /parametres.
* Pas de Bearer requis : c'est un flag d'env, pas une info user.
*/
router
.get('banking/status', [BankingController, 'status'])
.as('banking.status')
/**
* Banking — webhook Powens. Public (auth via HMAC sur le body).
* Reçoit CONNECTION_SYNCED, NEW_TRANSACTIONS, CONNECTION_ERROR…
* Vérification de la signature avec POWENS_WEBHOOK_SECRET dans
* le controller.
*/
router
.post('webhooks/powens', [WebhooksPowensController, 'handle'])
.as('webhooks.powens')
})
.prefix('/api/v1')
router
.group(() => {
/**
* Auth — public. /refresh utilise le cookie httpOnly `rubis_refresh`
* posé par signup/login pour émettre une nouvelle AuthSession.
* /google/* → SSO via Ally, callback pose aussi le refresh cookie.
*/
router
.group(() => {
router.post('signup', [controllers.NewAccount, 'store']).as('signup')
router.post('login', [controllers.AccessTokens, 'store']).as('login')
router.post('refresh', [controllers.Refresh, 'handle']).as('refresh')
router
.get('google/redirect', [controllers.AuthGoogle, 'redirect'])
.as('google.redirect')
router
.get('google/callback', [controllers.AuthGoogle, 'callback'])
.as('google.callback')
router
.get('microsoft/redirect', [controllers.AuthMicrosoft, 'redirect'])
.as('microsoft.redirect')
router
.get('microsoft/callback', [controllers.AuthMicrosoft, 'callback'])
.as('microsoft.callback')
})
.prefix('auth')
.as('auth')
/**
* Check-in in-app — auth requise. La modale au login liste les factures
* en attente de confirmation et permet à l'user d'y répondre directement
* sans passer par l'email.
*
* IMPORTANT : ce groupe doit être déclaré AVANT le groupe public à token,
* sinon `/checkin/:token/pending` mange `/checkin/inapp/pending` (token
* littéral = "inapp") et redirige vers le SPA en 302.
*/
router
.group(() => {
router
.get('pending', [controllers.Checkin, 'inappPending'])
.as('pending')
router
.post(':invoiceId/paid', [controllers.Checkin, 'inappRespondPaid'])
.as('paid')
.where('invoiceId', router.matchers.uuid())
router
.post(':invoiceId/pending', [controllers.Checkin, 'inappRespondPending'])
.as('pending.post')
.where('invoiceId', router.matchers.uuid())
})
.prefix('checkin/inapp')
.as('checkin.inapp')
.use(middleware.auth())
/**
* Check-in — public (pas d'auth Bearer). Token signé en URL,
* lookup hash en DB. Redirige vers le SPA avec ?checkin=... pour
* que l'UI affiche un toast.
*/
router
.group(() => {
router
.get(':token/paid', [controllers.Checkin, 'respondPaid'])
.as('paid')
router
.get(':token/pending', [controllers.Checkin, 'respondPending'])
.as('pending')
})
.prefix('checkin')
.as('checkin')
/**
* Compte courant — auth requise.
*/
router
.group(() => {
router.get('profile', [controllers.Profile, 'show']).as('profile.show')
router.patch('profile', [controllers.Profile, 'update']).as('profile.update')
router.post('logout', [controllers.AccessTokens, 'destroy']).as('logout')
})
.prefix('account')
.as('account')
.use(middleware.auth())
/**
* Organisation rattachée à l'utilisateur courant — auth requise.
*/
router
.group(() => {
router.get('me', [controllers.Organizations, 'show']).as('show')
router.patch('me', [controllers.Organizations, 'update']).as('update')
/**
* Settings de facturation native (éditeur de factures). Aucun gating
* de plan : toute org peut paramétrer sa facturation. Le gating
* porte sur la création (`canCreateInvoices`).
*
* - GET /me/invoice-settings → settings + valeurs résolues
* - PATCH /me/invoice-settings → maj partielle (null = reset)
*/
router
.get('me/invoice-settings', [InvoiceSettingsController, 'show'])
.as('invoice-settings.show')
router
.patch('me/invoice-settings', [InvoiceSettingsController, 'update'])
.as('invoice-settings.update')
})
.prefix('organizations')
.as('organizations')
.use(middleware.auth())
/**
* Thèmes de facture disponibles — auth requise. Retourne la liste des
* 4 templates pré-faits avec leurs métadonnées (slug + name + description)
* pour peupler la galerie de sélection dans l'éditeur et /parametres/facturation.
* Le rendu lui-même vit côté SPA (packages/ui/invoice-templates).
*/
router
.group(() => {
router.get('', [InvoiceThemesController, 'index']).as('index')
})
.prefix('invoice-themes')
.as('invoice-themes')
.use(middleware.auth())
/**
* Marque blanche — auth + plan Business obligatoires. Le middleware
* `assertBusinessPlan` throw 403 `business_plan_required` que le SPA
* catch pour afficher l'upsell card propre.
*
* - GET /brand → settings + tokens résolus + defaults
* - PATCH /brand → maj partielle (null = reset au default)
* - POST /brand/logo → upload multipart, écrase l'ancien sur MinIO
* - DELETE /brand/logo → retire le logo (retour wordmark Rubis)
* - POST /brand/test → envoie un mail de test à l'user pour preview
*
* Note : la route publique de lecture du logo (sans auth) est dans le
* groupe public au-dessus (`uploads/brand-logos/:filename`), parce que
* les emails sortants vers les clients doivent pouvoir charger l'image
* sans token.
*/
router
.group(() => {
router.get('', [BrandController, 'show']).as('show')
router.patch('', [BrandController, 'update']).as('update')
router.post('logo', [BrandController, 'uploadLogo']).as('logo.upload')
router.delete('logo', [BrandController, 'deleteLogo']).as('logo.delete')
router.post('test', [BrandController, 'sendTest']).as('test')
})
.prefix('brand')
.as('brand')
.use([middleware.auth(), middleware.assertBusinessPlan()])
/**
* Banking — agrégation bancaire Powens (Pro + Business uniquement).
* Le middleware `assertPaidPlan` throw 403 `paid_plan_required` que
* le SPA catch pour afficher l'upsell sur Free.
*
* La route callback (`GET /banking/powens/callback`) est dans le
* groupe public au-dessus parce que Powens redirige le navigateur
* du user sans Bearer token (state HMAC signé en remplacement).
*/
router
.group(() => {
router.post('powens/init', [BankingController, 'init']).as('powens.init')
router.get('connections', [BankingController, 'index']).as('connections.index')
router
.delete('connections/:id', [BankingController, 'destroy'])
.as('connections.destroy')
.where('id', router.matchers.uuid())
router.get('settings', [BankingController, 'showSettings']).as('settings.show')
router.patch('settings', [BankingController, 'updateSettings']).as('settings.update')
})
.prefix('banking')
.as('banking')
.use([middleware.auth(), middleware.assertPaidPlan()])
/**
* Clients — auth requise, scope par organization de l'utilisateur courant.
*/
router
.group(() => {
router.get('', [controllers.Clients, 'index']).as('index')
router.post('', [controllers.Clients, 'store']).as('store')
router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid())
router
.get(':id/timeseries', [controllers.Clients, 'timeseries'])
.as('timeseries')
.where('id', router.matchers.uuid())
router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid())
})
.prefix('clients')
.as('clients')
.use(middleware.auth())
/**
* IA — auth requise. Génération de templates de relance avec Mistral
* pour le wizard de création de plan custom.
*/
router
.group(() => {
router.post('generate-relance', [controllers.Ai, 'generateRelance']).as('generate-relance')
})
.prefix('ai')
.as('ai')
.use(middleware.auth())
/**
* Plans — auth requise. Lookup par slug (stable et lisible :
* /parametres/plans/standard-30j). POST = création d'un plan custom,
* slug auto-généré depuis le name.
*/
router
.group(() => {
router.get('', [controllers.Plans, 'index']).as('index')
router.post('', [controllers.Plans, 'store']).as('store')
router.get(':slug', [controllers.Plans, 'show']).as('show')
router.patch(':slug', [controllers.Plans, 'update']).as('update')
})
.prefix('plans')
.as('plans')
.use(middleware.auth())
/**
* Billing / Stripe — auth requise pour subscription/checkout/portal.
* Le webhook est PUBLIC (signature Stripe vérifiée côté handler).
*/
router
.post('billing/webhook', [controllers.Billing, 'webhook'])
.as('billing.webhook')
router
.group(() => {
router
.get('subscription', [controllers.Billing, 'subscription'])
.as('subscription')
router
.post('checkout', [controllers.Billing, 'checkout'])
.as('checkout')
router.post('portal', [controllers.Billing, 'portal']).as('portal')
router
.post('reactivate', [controllers.Billing, 'reactivate'])
.as('reactivate')
})
.prefix('billing')
.as('billing')
.use(middleware.auth())
/**
* Demo — auth requise. Mode démo opt-in par org (cf. CLAUDE.md →
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.
*/
router
.group(() => {
router.post('start', [controllers.Demo, 'start']).as('start')
router.post('end', [controllers.Demo, 'end']).as('end')
router.post('tick', [controllers.Demo, 'tick']).as('tick')
router.get('state', [controllers.Demo, 'state']).as('state')
router.get('inbox', [controllers.Demo, 'inbox']).as('inbox')
})
.prefix('demo')
.as('demo')
.use(middleware.auth())
/**
* Dashboard — auth requise. Calculs agrégés on-the-fly (pas de cache V1).
*/
router
.group(() => {
router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis')
router.get('activity', [controllers.Dashboard, 'activity']).as('activity')
router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late')
router
.get('timeseries', [controllers.Dashboard, 'timeseries'])
.as('timeseries')
})
.prefix('dashboard')
.as('dashboard')
.use(middleware.auth())
/**
* Invoices — auth requise. Ordre IMPORTANT : les routes statiques
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
* sinon `:id` matche les segments littéraux.
*/
router
.group(() => {
router.get('', [controllers.Invoices, 'index']).as('index')
router.post('', [controllers.Invoices, 'store']).as('store')
router.get('counts', [controllers.Invoices, 'counts']).as('counts')
/**
* Éditeur de factures natif :
* - POST /invoices/native → création depuis l'éditeur (séquence strict)
* - POST /invoices/preview-pdf → preview PDF sans persister
*
* Déclarés AVANT /:id pour ne pas être mangés par le matcher uuid.
*/
router.post('native', [controllers.Invoices, 'storeNative']).as('storeNative')
router.post('preview-pdf', [controllers.Invoices, 'previewPdf']).as('previewPdf')
// OCR / Import batch (cf. ImportBatchesController)
router.post('upload', [controllers.ImportBatches, 'upload']).as('upload')
router
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
.as('import-batch.show')
.where('id', router.matchers.uuid())
router
.get('import-batch/:id/drafts/:draftId/pdf', [
controllers.ImportBatches,
'draftPdf',
])
.as('import-batch.draft.pdf')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/validate', [
controllers.ImportBatches,
'validateDraft',
])
.as('import-batch.draft.validate')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/skip', [
controllers.ImportBatches,
'skipDraft',
])
.as('import-batch.draft.skip')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.delete('import-batch/:id', [controllers.ImportBatches, 'destroy'])
.as('import-batch.destroy')
.where('id', router.matchers.uuid())
router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid())
router
.get(':id/pdf', [controllers.Invoices, 'pdf'])
.as('pdf')
.where('id', router.matchers.uuid())
router
.post(':id/mark-paid', [controllers.Invoices, 'markPaid'])
.as('mark-paid')
.where('id', router.matchers.uuid())
})
.prefix('invoices')
.as('invoices')
.use(middleware.auth())
/**
* Admin — édition du blog. Toutes auth + admin (cf. is_admin sur users).
* Cf. apps/web/src/routes/_app/admin.* pour le SPA consommateur.
*/
router
.group(() => {
router
.group(() => {
router.get('', [AdminPostsController, 'index']).as('index')
router.post('', [AdminPostsController, 'store']).as('store')
router.get('suggest-slug', [AdminPostsController, 'suggestSlug']).as('suggest-slug')
router
.get(':id', [AdminPostsController, 'show'])
.as('show')
.where('id', router.matchers.uuid())
router
.patch(':id', [AdminPostsController, 'update'])
.as('update')
.where('id', router.matchers.uuid())
router
.post(':id/publish', [AdminPostsController, 'publish'])
.as('publish')
.where('id', router.matchers.uuid())
router
.post(':id/unpublish', [AdminPostsController, 'unpublish'])
.as('unpublish')
.where('id', router.matchers.uuid())
router
.delete(':id', [AdminPostsController, 'destroy'])
.as('destroy')
.where('id', router.matchers.uuid())
})
.prefix('posts')
.as('posts')
router.post('uploads', [BlogUploadsController, 'store']).as('uploads.store')
})
.prefix('admin')
.as('admin')
.use(middleware.auth())
.use(middleware.admin())
})
.prefix('/api/v1')