rubis/apps/api/app/controllers/banking_controller.ts
ordinarthur 3207f873e9
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m29s
feat(banking): mode "Bientôt disponible" pendant la fenêtre KYC Powens
Ajoute un état intermédiaire entre "feature désactivée silencieusement"
et "feature pleinement active" : un teaser visible dans /parametres
pour les Pro/Business qui annonce que la connexion bancaire arrive,
avec une note rassurante sur la lecture seule. Permet d'annoncer la
feature aux users payants pendant le délai d'agrément AISP / KYC
Powens, sans risque de cliquer dans le vide.

- Nouveau flag d'env `BANKING_TEASER_ENABLED` (boolean, default false)
- `GET /banking/status` renvoie désormais `{ enabled, comingSoon }`
  où comingSoon = !enabled && BANKING_TEASER_ENABLED
- `BankingSection` : nouveau composant `ComingSoonCard` (halo glow,
  copy explicite sur l'agrément AISP en cours, rassurance lecture
  seule) affiché quand comingSoon=true et l'org est Pro/Business
- `parametres.tsx` : la section "Banque" apparaît si enabled OU
  comingSoon (au lieu de uniquement enabled)
- ConfigMap K3s : `BANKING_TEASER_ENABLED='true'` en prod pour
  annoncer la feature aux clients payants pendant le KYC

Trois états possibles désormais :
  enabled=true                       → feature active (post-KYC)
  enabled=false + comingSoon=true    → teaser "Bientôt disponible"
  enabled=false + comingSoon=false   → section invisible (kill switch dur)

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

238 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.
*
* Trois états :
* - { enabled: true } → feature pleinement active
* - { enabled: false, comingSoon: true } → KYC Powens en cours,
* teaser "Bientôt disponible" affiché aux Pro/Business
* - { enabled: false, comingSoon: false } → section complètement
* cachée (kill switch dur)
*/
async status({ response }: HttpContext) {
return response.json({
data: {
enabled: isBankingEnabled(),
comingSoon: !isBankingEnabled() && env.get('BANKING_TEASER_ENABLED') === true,
},
})
}
/**
* 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 },
})
}
}