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 }, }) } }