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>
238 lines
8.9 KiB
TypeScript
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 },
|
|
})
|
|
}
|
|
}
|