rubis/apps/api/app/controllers/banking_controller.ts
ordinarthur 51217175ad
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
feat(banking): intégration Powens AISP + auto-réconciliation factures
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).

Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
  webview URL, list/get/delete connections, accounts, transactions,
  vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
  encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
  (TTL 10 min), handleCallback (upsert connection + accounts +
  fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
  Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
  + label normalisé (numero ou nom client, NFD strip + alphanum).
  Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
  HIGH → invoice.status=paid + bonus rubis + cancel relances +
  enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
  (user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
  powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
  USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
  / SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
  puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
  + colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
  sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
  récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
  (notif user après match auto, lien direct facture + libellé
  bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
  jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
  reconnecter la banque) et banking:simulate-payment (injecte une
  bank_transaction synthétique qui matche une facture, pour test E2E
  sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
  credentials Powens. Endpoint public GET /banking/status renvoie
  { enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
  rapporte désormais la vraie colonne en faute (numero/slug/…) avec
  message lisible « Le numéro de facture "F2026-0013" existe déjà »,
  au lieu d'un message ambigu sur organization_id.

Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
  switch + plan Pro/Business. Si Free → upsell card avec CTA vers
  /parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
  une banque". Si banque connectée → carte avec accounts (IBAN
  masqué FR76 **** **** **** 1234), solde, last sync, bouton
  Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
  avec badge ✓ animé + halo glow rubis, récap des comptes
  synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
  note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
  { enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
  useUpdateBankingSettings.

Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
  BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
  POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
  POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
  POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
  via kubectl patch, à remplacer post-KYC).

Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
  clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
  (anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
  POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
  powens_account_id), (account, powens_id) → rejouer un event ou un
  callback ne pose pas de problème.

Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
  Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
  prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
  flip flag → smoke test), snippet kubectl patch pour rotation
  ciblée de secrets.

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

235 lines
8.9 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import Organization from '#models/organization'
import BankConnectionTransformer from '#transformers/bank_connection_transformer'
import {
createWebviewUrl,
handleCallback,
listConnectionsForOrg,
disconnect,
setReconciliationMode,
BankingStateError,
BankingNotConfiguredError,
} from '#services/banking/banking_service'
import { updateBankingSettingsValidator } from '#validators/banking'
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
}
/**
* Kill switch banking. ON si :
* - `BANKING_ENABLED` est 'true' (string env), OU non défini (default
* activé en dev)
* - ET les 3 vars Powens critiques sont posées (domain + creds)
*
* Pourquoi un double check : `BANKING_ENABLED=false` permet de cacher
* la feature en prod le temps que le KYC Powens soit validé, **sans
* supprimer les vars d'env** (utile pour le re-enable rapide).
* À l'inverse, si on a oublié les creds, on bloque même si le flag
* est ON — sinon on aurait un bouton qui clique dans le vide.
*/
function isBankingEnabled(): boolean {
// `BANKING_ENABLED` est déclaré `boolean.optional()` côté env.ts donc
// Adonis renvoie un booléen ou undefined (pas une string).
// En dev sans flag explicite, on considère ON (cohérent avec
// l'expérience locale actuelle qui n'utilise pas ce flag).
const flag = env.get('BANKING_ENABLED')
if (flag === false) return false
return Boolean(env.get('POWENS_DOMAIN') && env.get('POWENS_CLIENT_ID'))
}
/**
* BankingController — endpoints d'agrégation bancaire (Powens, lecture
* seule).
*
* Routes (toutes en /api/v1, sauf callback) :
* - POST /banking/powens/init → renvoie webviewUrl à ouvrir
* - GET /banking/connections → liste les banques connectées
* - DELETE /banking/connections/:id → déconnecte une banque
* - PATCH /banking/settings → toggle reconciliationMode
* - GET /banking/powens/callback → public, redirige le SPA après webview
*
* Middleware :
* - auth + assertPaidPlan (Pro ou Business) sur tout sauf callback
* - callback est public (Powens redirige le navigateur sans Bearer), la
* sécurité est portée par le state HMAC dans l'URL.
*/
export default class BankingController {
/**
* GET /banking/status — public.
*
* Kill switch lu côté SPA pour décider d'afficher la section banque.
* On considère banking activé si :
* - `BANKING_ENABLED` == 'true' (ou non défini = true par défaut en dev)
* - ET les credentials Powens sont configurés
*
* En prod on déploie avec `BANKING_ENABLED=false` tant que le KYC
* Powens prod n'est pas finalisé — la feature est dormante, aucun
* bouton n'apparaît, aucun call init n'est tenté.
*/
async status({ response }: HttpContext) {
return response.json({ data: { enabled: isBankingEnabled() } })
}
/**
* POST /banking/powens/init
*
* Crée (si besoin) le user Powens de l'org, génère un code temporaire,
* et construit l'URL webview. Le front fait `window.location = url`.
*
* 409 `bank_already_connected` si l'org a déjà une banque active —
* l'UI propose de déconnecter d'abord.
* 503 `banking_disabled` si le kill switch est OFF.
*/
async init({ auth, response }: HttpContext) {
if (!isBankingEnabled()) {
throw new Exception(
"Le module banking n'est pas activé sur cet environnement.",
{ status: 503, code: 'banking_disabled' }
)
}
const orgId = requireOrgId(auth)
try {
const { webviewUrl } = await createWebviewUrl(orgId)
return response.json({ data: { webviewUrl } })
} catch (err: any) {
if (err instanceof BankingNotConfiguredError) {
throw new Exception(err.message, { status: 503, code: 'banking_not_configured' })
}
if (err?.code === 'bank_already_connected') {
throw new Exception(err.message, { status: 409, code: 'bank_already_connected' })
}
throw err
}
}
/**
* GET /banking/powens/callback — public.
*
* Powens redirige le navigateur ici avec ?connection_id=&state=. On
* vérifie le state (HMAC signé qui encode l'org), on traite, et on
* 302 vers le SPA en passant un statut dans l'URL pour le toast.
*
* Cas d'erreur : on redirige avec ?banking=error&reason=... plutôt
* que de renvoyer un JSON 4xx — le user est dans un browser tab,
* pas dans une requête XHR du SPA.
*/
async callback({ request, response }: HttpContext) {
const webUrl = env.get('WEB_URL') ?? 'http://localhost:5173'
/**
* Helpers de redirect.
*
* Le projet a `redirect.forwardQueryString: true` activé globalement
* dans config/app.ts (utile pour les flows checkin/auth où on veut
* propager les params). Pour CE callback c'est l'inverse : on ne
* veut PAS que les `?connection_id=&state=` de Powens collent au
* redirect (sinon URL malformée). On utilise donc la forme chainable
* `.withQs(false).toPath(...)` pour désactiver le forward sur ce
* redirect uniquement.
*
* Au succès → page dédiée `/parametres/banque/success` (UX :
* confirmation visuelle + récap, plutôt que retour discret avec toast).
* À l'erreur → retour sur `/parametres?banking=error&reason=...` avec
* toast d'erreur (pas de page dédiée erreur, un toast suffit).
*/
const redirectToError = (reason: string) => {
const url = new URL('/parametres', webUrl)
url.searchParams.set('banking', 'error')
url.searchParams.set('reason', reason)
return response.redirect().withQs(false).toPath(url.toString())
}
const redirectToSuccess = (params: { bank: string; connectionId: string }) => {
const url = new URL('/parametres/banque/success', webUrl)
url.searchParams.set('bank', params.bank)
url.searchParams.set('connectionId', params.connectionId)
return response.redirect().withQs(false).toPath(url.toString())
}
const connectionIdRaw = request.input('connection_id')
const state = request.input('state')
if (!connectionIdRaw || !state) {
return redirectToError('missing_params')
}
const connectionId = Number(connectionIdRaw)
if (!Number.isFinite(connectionId)) {
return redirectToError('bad_connection_id')
}
try {
const result = await handleCallback({ connectionId, state: String(state) })
// TODO commit 5 : enqueue SyncPowensConnection job pour fetch les transactions.
logger.info(
{ orgId: result.orgId, connectionId: result.connectionId, bank: result.bankName },
'banking.callback.success'
)
return redirectToSuccess({
bank: result.bankName,
connectionId: result.connectionId,
})
} catch (err) {
logger.warn({ err }, 'banking.callback.failed')
const reason = err instanceof BankingStateError ? 'invalid_state' : 'callback_failed'
return redirectToError(reason)
}
}
/**
* GET /banking/connections — liste des banques connectées (avec accounts).
*/
async index({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const connections = await listConnectionsForOrg(orgId)
return response.json({
data: connections.map((c) => new BankConnectionTransformer(c).toObject()),
})
}
/**
* DELETE /banking/connections/:id — déconnecte une banque.
* Idempotent : si la connection est déjà 'revoked', on renvoie 204
* sans rejouer le DELETE Powens.
*/
async destroy({ auth, params, response }: HttpContext) {
const orgId = requireOrgId(auth)
await disconnect(orgId, String(params.id))
return response.noContent()
}
/**
* PATCH /banking/settings — toggle reconciliation mode (manual/auto).
* Retourne l'org mise à jour (juste le champ qui nous intéresse, le
* front n'a pas besoin de re-fetch /organizations/me).
*/
async updateSettings({ auth, request, response }: HttpContext) {
const orgId = requireOrgId(auth)
const payload = await request.validateUsing(updateBankingSettingsValidator)
const org = await setReconciliationMode(orgId, payload.reconciliationMode)
return response.json({
data: { reconciliationMode: org.reconciliationMode },
})
}
/**
* GET /banking/settings — lit le mode courant. Retourné séparément
* de /organizations/me pour ne pas exposer powens_user_id et autres
* champs internes côté front.
*/
async showSettings({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
return response.json({
data: { reconciliationMode: org.reconciliationMode },
})
}
}