feat(banking): intégration Powens AISP + auto-réconciliation factures
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s

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>
This commit is contained in:
ordinarthur 2026-05-12 14:03:32 +02:00
parent c590b489ef
commit 51217175ad
35 changed files with 4006 additions and 7 deletions

View File

@ -89,9 +89,30 @@ kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \
--from-literal=REDIS_PASSWORD="" \
--from-literal=GOOGLE_CLIENT_ID=... \
--from-literal=GOOGLE_CLIENT_SECRET=... \
--from-literal=MICROSOFT_CLIENT_ID=... \
--from-literal=MICROSOFT_CLIENT_SECRET=... \
--from-literal=STRIPE_SECRET_KEY=... \
--from-literal=STRIPE_WEBHOOK_SECRET=... \
--from-literal=SENTRY_DSN_API=... \
--from-literal=POWENS_CLIENT_ID=... \
--from-literal=POWENS_CLIENT_SECRET=... \
--from-literal=POWENS_WEBHOOK_SECRET=... \
--dry-run=client -o yaml | kubectl apply -f -
```
> ⚠️ Si tu rotates UN secret, repose la commande complète avec **toutes**
> les vars (sinon `apply` supprime celles omises). Garde la liste à jour
> dans un coffre-fort perso (1Password, Bitwarden…).
>
> **Alternative pour ajouter UNE/quelques clés sans toucher au reste** :
> ```bash
> kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \
> -n rubis patch secret rubis-app-secrets --type=strategic --patch '{
> "stringData": { "NOUVELLE_VAR": "valeur" }
> }'
> ```
> `stringData` accepte du clair — Kubernetes encode en base64 automatiquement.
### Google SSO — setup Google Cloud Console
Si la clé OAuth est perdue ou qu'on doit la régénérer :
1. https://console.cloud.google.com/apis/credentials → projet courant
@ -135,6 +156,53 @@ Le client secret expire (Azure force 6 ou 12 mois max) — penser à le
renouveler avant échéance ; sinon les nouvelles connexions échoueront
en silence après expiration.
### Banking (Powens) — activation prod
La feature banking est **déployée mais désactivée par défaut** en prod via
le flag `BANKING_ENABLED: 'false'` dans le ConfigMap `rubis-api-config`.
La section banque dans /parametres reste invisible, et `/api/v1/banking/*`
renvoie 503 `banking_disabled`. Procédure d'activation une fois le KYC
Powens prod validé :
1. **Powens** — créer un domaine prod chez Powens (KYC requis : Kbis,
contrat AISP/DSP2). Récupérer :
- Slug du domaine (ex : `rubis` → URL `rubis.biapi.pro`).
- `client_id` + `client_secret` prod (différents du sandbox).
- Webhook secret (à générer dans la console Powens prod).
2. **Whitelist côté console Powens prod** :
- Allowed redirect URIs :
`https://app.rubis.pro/api/v1/banking/powens/callback`
- Webhook URL :
`https://app.rubis.pro/api/v1/webhooks/powens`
3. **Mettre à jour les secrets K3s** (re-pose le snippet
`kubectl create secret generic` ci-dessus en remplissant les 3 vars
`POWENS_CLIENT_ID` / `POWENS_CLIENT_SECRET` / `POWENS_WEBHOOK_SECRET`
avec les valeurs prod).
4. **Mettre à jour le ConfigMap** dans `k3s/app/api.yml` si le slug
diffère de `rubis` (changer `POWENS_DOMAIN` et `POWENS_API_BASE_URL`).
Puis `BANKING_ENABLED: 'true'`. Commit + push → CI redéploie.
5. **Smoke test prod** : se connecter avec un compte Pro/Business, aller
sur `/parametres` → la section "Connecter votre banque" doit
apparaître. Cliquer "Connecter une banque" → la webview Powens prod
doit s'ouvrir. Tester avec un vrai compte bancaire perso d'abord
pour valider toute la chaîne (sync + reconcile + emails).
6. **Backup avant flip** : la 1re connexion crée un user Powens prod et
stocke `powens_user_id` + token chiffré (clé `APP_KEY`) sur l'org.
Si on doit un jour faire rotate `APP_KEY`, prévoir une migration des
tokens (déchiffrer avec ancienne clé → re-chiffrer avec nouvelle).
> Le webhook Powens (`POST /api/v1/webhooks/powens`) attend la
> signature HMAC SHA-256 dans le header `BI-Signature` (ou
> `X-Webhook-Signature`). Vérification automatique dans le controller
> avec `POWENS_WEBHOOK_SECRET`. Si Powens reçoit autre chose qu'un 200
> en réponse il retry agressivement → garder un œil sur les logs au
> démarrage.
### Mise à jour
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
modifiés. Build+rollout indépendants.
@ -236,7 +304,10 @@ Quand confiance acquise et plus aucune référence vivante :
- Namespace + secret registry K3s (`gitea-registry`)
- Postgres : base `rubis_prod` + user `rubis` (10.10.10.3)
- MinIO : bucket `rubis-prod-invoices`
- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral)
- ConfigMap `rubis-api-config` (env non-sensibles)
- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral,
Google/Microsoft SSO, Stripe ; Powens à poser au moment de l'activation
KYC)
- ConfigMap `rubis-api-config` (env non-sensibles incl. banking flags
désactivés par défaut)
Les prochains `/deploy` font uniquement build + rollout via push git.

View File

@ -108,3 +108,29 @@ MICROSOFT_TENANT=common
MICROSOFT_CALLBACK_URL=http://localhost:3333/api/v1/auth/microsoft/callback
LIMITER_STORE=redis
#--------------------------------------------------------------------
# Banking — agrégation bancaire (AISP, lecture seule)
#--------------------------------------------------------------------
# Setup complet : /docs/tech/banking-setup.md
#
# 1. Créer un compte sur https://console.powens.com/ + demander un
# domaine sandbox (gratuit). Récupérer client_id / client_secret.
# 2. Whitelister les redirect_uri dans la console Powens :
# - https://<tunnel>.trycloudflare.com/api/v1/banking/powens/callback (dev)
# - https://app.rubis.pro/api/v1/banking/powens/callback (prod)
# 3. En dev : lancer `cloudflared tunnel --url http://localhost:3333`
# et coller l'URL dans POWENS_REDIRECT_URI.
#
# POWENS_DOMAIN = slug du domaine (ex : 'rubis-sandbox').
# POWENS_API_BASE_URL = override optionnel ; sinon calculée comme
# https://<POWENS_DOMAIN>.biapi.pro/2.0/.
#--------------------------------------------------------------------
BANKING_ENABLED=true
BANKING_PROVIDER=powens
POWENS_DOMAIN=
POWENS_API_BASE_URL=
POWENS_CLIENT_ID=
POWENS_CLIENT_SECRET=
POWENS_REDIRECT_URI=https://CHANGEME.trycloudflare.com/api/v1/banking/powens/callback
POWENS_WEBHOOK_SECRET=

View File

@ -0,0 +1,234 @@
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 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 },
})
}
}

View File

@ -0,0 +1,223 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import env from '#start/env'
import Organization from '#models/organization'
import BankConnection from '#models/bank_connection'
import { getPowensClient } from '#services/banking/powens_client'
import { syncConnectionTransactions } from '#services/banking/sync_connection'
import { reconcileTransactionsForOrg } from '#services/banking/reconcile_transactions'
/**
* POST /api/v1/webhooks/powens public (auth via signature HMAC).
*
* Powens push les events sur cet endpoint dès qu'il se passe quelque
* chose sur une connection user. On vérifie la signature (header
* `BI-Signature` ou `X-Webhook-Signature`) avec `POWENS_WEBHOOK_SECRET`,
* puis on dispatch.
*
* Types d'event qu'on gère (les autres 200 silent, on ne crashe pas
* le webhook pour un event inconnu) :
*
* - CONNECTION_SYNCED re-sync transactions + reconcile (incrémental)
* - NEW_TRANSACTIONS idem
* - USER_SYNC_ENDED idem (event "global" de fin de sync user)
* - CONNECTION_ERROR update bank_connection.state + last_error
* - SCA_REQUIRED marque state='sca_required' (re-auth user nécessaire)
*
* Réponse : on renvoie 200 vite (Powens retry si timeout). Le sync +
* reconcile partent en fire-and-forget après la réponse.
*
* Idempotence : tous nos upserts banking sont idempotents par construction
* (UNIQUE indexes + match_status check), donc rejouer un event ne pose
* pas de problème.
*/
export default class WebhooksPowensController {
async handle({ request, response }: HttpContext) {
// 1. Vérifier signature HMAC
const secret = env.get('POWENS_WEBHOOK_SECRET')
if (!secret) {
logger.error('POWENS_WEBHOOK_SECRET manquant — webhook rejeté')
return response.status(503).json({ error: 'webhook_secret_missing' })
}
const secretValue = typeof secret === 'string' ? secret : secret.release()
// Powens utilise `BI-Signature` historiquement, certains tenants
// récents utilisent `X-Webhook-Signature`. On accepte les deux.
const signature =
request.header('bi-signature') ?? request.header('x-webhook-signature')
if (!signature) {
logger.warn('Webhook Powens reçu sans signature')
return response.status(400).json({ error: 'missing_signature' })
}
const rawBody = request.raw()
if (!rawBody) {
logger.warn('Webhook Powens reçu sans body')
return response.status(400).json({ error: 'no_raw_body' })
}
const client = getPowensClient()
if (!client.verifyWebhookSignature(rawBody, signature, secretValue)) {
logger.warn(
{ signaturePreview: signature.slice(0, 16) },
'Webhook Powens : signature invalide'
)
return response.status(401).json({ error: 'invalid_signature' })
}
// 2. Parser le payload (déjà parsé par bodyparser, on lit request.body())
const body = request.body() as PowensWebhookEvent
const type = String(body?.type ?? 'unknown').toUpperCase()
const idUser = Number(body?.id_user ?? body?.user?.id ?? NaN)
const connectionId = Number(
body?.connection?.id ?? body?.id_connection ?? NaN
)
logger.info(
{ type, idUser, connectionId, bodyKeys: Object.keys(body ?? {}) },
'powens.webhook.received'
)
if (!Number.isFinite(idUser)) {
// Event qu'on ne sait pas router (event "système" Powens, pas user-scoped).
// On ack vite — pas notre problème.
return response.status(200).json({ ok: true, ignored: 'no_user' })
}
// 3. Trouver l'org Rubis qui correspond à ce user Powens
const org = await Organization.query().where('powensUserId', idUser).first()
if (!org) {
logger.warn({ idUser }, 'powens.webhook.unknown_user')
// Cas légitime : un user Powens orphelin (org supprimée côté Rubis).
// On ack pour que Powens arrête de retry.
return response.status(200).json({ ok: true, ignored: 'unknown_user' })
}
// 4. Dispatch selon le type. On réponds 200 SYNCHRONIQUEMENT avant
// de lancer le sync + reconcile, pour ne pas faire timeout côté Powens
// (qui retry agressivement si la réponse tarde).
response.status(200).json({ ok: true })
switch (type) {
case 'CONNECTION_SYNCED':
case 'NEW_TRANSACTIONS':
case 'USER_SYNC_ENDED':
void runIncrementalSync(org.id, connectionId).catch((err) =>
logger.error(
{ err, orgId: org.id, type, connectionId },
'powens.webhook.sync_failed'
)
)
break
case 'CONNECTION_ERROR':
case 'SCA_REQUIRED':
void markConnectionError(org.id, connectionId, body).catch((err) =>
logger.error({ err, orgId: org.id, type }, 'powens.webhook.mark_error_failed')
)
break
default:
logger.info({ type }, 'powens.webhook.unhandled_type')
}
return
}
}
// --------------------------------------------------------------------
// Types Powens (sous-set utilisé)
// --------------------------------------------------------------------
interface PowensWebhookEvent {
type?: string
id_user?: number
user?: { id?: number }
id_connection?: number
connection?: {
id?: number
state?: string | null
error?: string | null
error_message?: string | null
}
}
// --------------------------------------------------------------------
// Handlers
// --------------------------------------------------------------------
/**
* Re-sync incrémental d'une connection (ou de toutes celles de l'org si
* connectionId est NaN). Fenêtre étroite (7 jours) car le webhook arrive
* juste après que Powens ait noté des changements on n'a pas besoin
* de re-fetcher 90 jours à chaque event.
*/
async function runIncrementalSync(
orgId: string,
connectionIdPowens: number
): Promise<void> {
let connectionIds: string[] = []
if (Number.isFinite(connectionIdPowens)) {
const conn = await BankConnection.query()
.where('organizationId', orgId)
.where('powensConnectionId', connectionIdPowens)
.first()
if (conn) connectionIds = [conn.id]
}
if (connectionIds.length === 0) {
// Fallback : toutes les connections actives de l'org
const all = await BankConnection.query()
.where('organizationId', orgId)
.whereNot('state', 'revoked')
.select('id')
connectionIds = all.map((c) => c.id)
}
for (const id of connectionIds) {
try {
await syncConnectionTransactions({ connectionId: id, daysBack: 7 })
} catch (err) {
logger.error({ err, connectionId: id }, 'powens.webhook.sync_connection_failed')
}
}
// Reconcile une seule fois après tous les syncs (économise du I/O DB).
try {
await reconcileTransactionsForOrg(orgId)
} catch (err) {
logger.error({ err, orgId }, 'powens.webhook.reconcile_failed')
}
}
/**
* Met à jour le state + last_error d'une connection en erreur. UI affichera
* une alerte "reconnexion requise" pour SCA_REQUIRED.
*/
async function markConnectionError(
orgId: string,
connectionIdPowens: number,
body: PowensWebhookEvent
): Promise<void> {
if (!Number.isFinite(connectionIdPowens)) return
const conn = await BankConnection.query()
.where('organizationId', orgId)
.where('powensConnectionId', connectionIdPowens)
.first()
if (!conn) return
const stateRaw = body.connection?.state ?? body.type ?? null
if (stateRaw === 'wrongpass') {
conn.state = 'wrongpass'
} else if (
stateRaw === 'additionalInformationNeeded' ||
String(body.type).toUpperCase() === 'SCA_REQUIRED'
) {
conn.state = 'sca_required'
} else {
conn.state = 'error'
}
conn.lastError =
body.connection?.error_message ?? body.connection?.error ?? String(body.type ?? null)
conn.lastSyncAt = DateTime.now()
await conn.save()
}

View File

@ -21,16 +21,36 @@ export default class HttpExceptionHandler extends ExceptionHandler {
if (!isObject(error)) return super.handle(error, ctx)
// Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol).
//
// Format du detail PG : `Key (col1, col2)=(val1, val2) already exists.`
//
// On veut un message lisible côté SPA. Pour les contraintes composites
// multi-tenant (qui contiennent toujours `organization_id` comme 1re
// colonne sur ce projet), `organization_id` n'est pas la colonne en
// faute — c'est l'autre colonne (numero, slug, email…). On reporte
// donc la 1re colonne NON-`organization_id` comme `field`, avec la
// valeur correspondante pour un message explicite.
if (error.code === '23505') {
const detail = typeof error.detail === 'string' ? error.detail : ''
const fieldMatch = detail.match(/Key \(([^)]+)\)=/)
const field = fieldMatch?.[1]?.split(',')[0]?.trim()
const m = detail.match(/Key \(([^)]+)\)=\(([^)]+)\)/)
const columns = m?.[1]?.split(',').map((s) => s.trim()) ?? []
const values = m?.[2]?.split(',').map((s) => s.trim()) ?? []
// Index de la 1re colonne != organization_id, sinon fallback sur 0.
const idx = Math.max(
0,
columns.findIndex((c) => c !== 'organization_id')
)
const field = columns[idx]
const value = values[idx]
ctx.response.status(422)
return ctx.response.json({
errors: [
{
code: 'duplicate',
message: 'Cette valeur existe déjà.',
message:
field && value
? `${humanizeField(field)} « ${value} » existe déjà.`
: 'Cette valeur existe déjà.',
field: field ?? undefined,
},
],
@ -121,3 +141,21 @@ export default class HttpExceptionHandler extends ExceptionHandler {
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object'
}
/**
* Mappe un nom de colonne DB en libellé lisible pour les messages d'erreur
* côté SPA. Volontairement court et conservateur on ne traduit que les
* champs qui peuvent réellement remonter sur une UNIQUE violation.
*/
function humanizeField(col: string): string {
const map: Record<string, string> = {
numero: 'Le numéro de facture',
slug: 'Le slug',
email: "L'email",
siret: 'Le SIRET',
powens_user_id: "L'identifiant utilisateur Powens",
stripe_customer_id: "L'identifiant client Stripe",
stripe_subscription_id: "L'identifiant abonnement Stripe",
}
return map[col] ?? `Le champ ${col}`
}

View File

@ -0,0 +1,229 @@
/**
* Email envoyé À L'UTILISATEUR (pas au client final) quand sa banque
* vient d'être connectée via Powens. Confirmation + récap des comptes
* synchronisés + rassurance "lecture seule".
*
* Toujours en branding Rubis (pas customizable même en Business) : c'est
* une notif Rubis user, pas user client. Pareil que CheckinEmail.
*/
import * as React from 'react'
import { Button, Section, Text } from '@react-email/components'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type BankConnectedEmailProps = {
tokens: BrandTokens
user: { fullName: string | null }
bank: { name: string }
accounts: Array<{ name: string; ibanMasked: string | null }>
/** URL vers /parametres (CTA). */
settingsUrl: string
/** URL landing publique pour le footer. */
landingUrl?: string | null
}
export function BankConnectedEmail({
tokens,
user,
bank,
accounts,
settingsUrl,
landingUrl,
}: BankConnectedEmailProps) {
const checkBadgeWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `0 0 ${sp.lg} 0`,
}
const checkBadgeStyle: React.CSSProperties = {
display: 'inline-block',
width: '44px',
height: '44px',
lineHeight: '44px',
textAlign: 'center',
borderRadius: '999px',
backgroundColor: tokens.primaryGlow,
color: tokens.primaryDeep,
fontSize: '22px',
fontWeight: 800,
}
const headingStyle: React.CSSProperties = {
fontSize: '22px',
fontWeight: 800,
color: tokens.text,
margin: `0 0 ${sp.sm} 0`,
textAlign: 'center',
letterSpacing: '-0.01em',
}
const subheadingStyle: React.CSSProperties = {
fontSize: '14px',
color: tokens.textMuted,
margin: `0 0 ${sp.xl} 0`,
textAlign: 'center',
lineHeight: '1.5',
}
const greetingStyle: React.CSSProperties = {
fontSize: '15px',
color: tokens.text,
lineHeight: '1.6',
margin: `0 0 ${sp.md} 0`,
}
const cardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 ${sp.xl} 0`,
}
const cardTitleStyle: React.CSSProperties = {
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: tokens.primary,
margin: `0 0 ${sp.sm} 0`,
}
const bankNameStyle: React.CSSProperties = {
fontSize: '17px',
fontWeight: 700,
color: tokens.text,
margin: `0 0 ${sp.md} 0`,
}
const accountRowStyle: React.CSSProperties = {
margin: `${sp.sm} 0`,
paddingTop: sp.sm,
borderTop: `1px solid ${tokens.border}`,
}
const accountNameStyle: React.CSSProperties = {
fontSize: '13px',
color: tokens.text,
fontWeight: 600,
margin: 0,
}
const ibanStyle: React.CSSProperties = {
fontSize: '12px',
color: tokens.textMuted,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
letterSpacing: '0.02em',
margin: `${sp.xs} 0 0 0`,
}
const infoCardStyle: React.CSSProperties = {
backgroundColor: tokens.primaryGlow,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `0 0 ${sp.xl} 0`,
}
const infoTextStyle: React.CSSProperties = {
fontSize: '13px',
color: tokens.primaryDeep,
lineHeight: '1.5',
margin: 0,
}
const ctaWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `${sp.lg} 0 ${sp.sm} 0`,
}
const buttonStyle: React.CSSProperties = {
backgroundColor: tokens.primary,
color: tokens.buttonText,
fontSize: '14px',
fontWeight: 600,
padding: `${sp.md} ${sp.xl}`,
borderRadius: tokens.radiusButton,
textDecoration: 'none',
display: 'inline-block',
}
const securityNoteStyle: React.CSSProperties = {
fontSize: '12px',
color: tokens.textVeryMuted,
textAlign: 'center',
margin: `${sp.lg} 0 0 0`,
lineHeight: '1.5',
}
const firstName = user.fullName?.split(' ')[0] ?? ''
return (
<EmailLayout
tokens={tokens}
preview={`${bank.name} est connectée à Rubis — vos paiements seront détectés automatiquement.`}
brandSubtitle="Banque connectée"
landingUrl={landingUrl}
>
<Section style={checkBadgeWrapStyle}>
<span style={checkBadgeStyle} aria-hidden="true">
</span>
</Section>
<Text style={headingStyle}>
Votre banque est <span style={{ color: tokens.primary }}>connectée</span>
</Text>
<Text style={subheadingStyle}>
Rubis va maintenant détecter automatiquement vos virements entrants
et arrêter les relances dès qu&apos;une facture est payée.
</Text>
<Text style={greetingStyle}>
Bonjour {firstName || 'et bienvenue'},
</Text>
<Text style={greetingStyle}>
Bonne nouvelle&nbsp;: <strong>{bank.name}</strong> est désormais
reliée à votre espace Rubis.
{accounts.length === 1
? ' Le compte ci-dessous va être surveillé :'
: ` Les ${accounts.length} comptes ci-dessous vont être surveillés :`}
</Text>
<Section style={cardStyle}>
<Text style={cardTitleStyle}>Comptes synchronisés</Text>
<Text style={bankNameStyle}>{bank.name}</Text>
{accounts.map((a, i) => (
<Section key={i} style={i === 0 ? undefined : accountRowStyle}>
<Text style={accountNameStyle}>{a.name}</Text>
{a.ibanMasked && <Text style={ibanStyle}>{a.ibanMasked}</Text>}
</Section>
))}
</Section>
<Section style={infoCardStyle}>
<Text style={infoTextStyle}>
<strong>Comment ça marche&nbsp;?</strong> Rubis lit en arrière-plan
les virements crédités sur vos comptes. Quand un montant correspond
à une facture en attente, on vous le suggère (ou on marque payé
automatiquement, selon votre préférence dans Paramètres).
</Text>
</Section>
<Section style={ctaWrapStyle}>
<Button href={settingsUrl} style={buttonStyle}>
Voir mes paramètres
</Button>
</Section>
<Text style={securityNoteStyle}>
🔒 Lecture seule. Aucun déplacement de fonds possible.
<br />
Vous pouvez déconnecter cette banque à tout moment depuis vos
paramètres.
</Text>
</EmailLayout>
)
}

View File

@ -0,0 +1,239 @@
/**
* Email envoyé À L'UTILISATEUR (pas au client final) quand la réconciliation
* bancaire automatique a marqué une de ses factures comme payée. Notif Rubis
* user, donc toujours en branding Rubis (jamais marque blanche).
*
* Différence avec PaymentThanksEmail : celui- va au CLIENT pour le remercier
* de son paiement. Le présent email va au USER pour l'informer qu'on a
* détecté un paiement et auto-fermé la relance.
*/
import * as React from 'react'
import { Button, Section, Text } from '@react-email/components'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type InvoiceAutoPaidNotificationEmailProps = {
tokens: BrandTokens
user: { fullName: string | null }
invoice: {
numero: string
amountFormatted: string
/** URL vers la page de détail de la facture dans le SPA. */
detailUrl: string
}
client: { name: string }
/** Libellé bancaire détecté (raw bancaire, donné pour transparence). */
bankLabel: string
/** Nom de la banque (pour rappeler quelle source). */
bankName: string
landingUrl?: string | null
}
export function InvoiceAutoPaidNotificationEmail({
tokens,
user,
invoice,
client,
bankLabel,
bankName,
landingUrl,
}: InvoiceAutoPaidNotificationEmailProps) {
const badgeWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `0 0 ${sp.lg} 0`,
}
const badgeStyle: React.CSSProperties = {
display: 'inline-block',
width: '44px',
height: '44px',
lineHeight: '44px',
textAlign: 'center',
borderRadius: '999px',
backgroundColor: tokens.primaryGlow,
color: tokens.primaryDeep,
fontSize: '22px',
fontWeight: 800,
}
const headingStyle: React.CSSProperties = {
fontSize: '22px',
fontWeight: 800,
color: tokens.text,
margin: `0 0 ${sp.sm} 0`,
textAlign: 'center',
letterSpacing: '-0.01em',
}
const subheadingStyle: React.CSSProperties = {
fontSize: '14px',
color: tokens.textMuted,
margin: `0 0 ${sp.xl} 0`,
textAlign: 'center',
lineHeight: '1.5',
}
const greetingStyle: React.CSSProperties = {
fontSize: '15px',
color: tokens.text,
lineHeight: '1.6',
margin: `0 0 ${sp.md} 0`,
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: tokens.textVeryMuted,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: tokens.text,
fontWeight: 600,
}
const amountStyle: React.CSSProperties = {
...summaryValueStyle,
fontSize: '20px',
fontWeight: 800,
color: tokens.primary,
fontVariantNumeric: 'tabular-nums',
}
const infoCardStyle: React.CSSProperties = {
backgroundColor: tokens.primaryGlow,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `0 0 ${sp.xl} 0`,
}
const infoTextStyle: React.CSSProperties = {
fontSize: '13px',
color: tokens.primaryDeep,
lineHeight: '1.5',
margin: 0,
}
const ctaWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `${sp.lg} 0`,
}
const buttonStyle: React.CSSProperties = {
backgroundColor: tokens.primary,
color: tokens.buttonText,
fontSize: '14px',
fontWeight: 600,
padding: `${sp.md} ${sp.xl}`,
borderRadius: tokens.radiusButton,
textDecoration: 'none',
display: 'inline-block',
}
const footnoteStyle: React.CSSProperties = {
fontSize: '12px',
color: tokens.textVeryMuted,
textAlign: 'center',
margin: `${sp.md} 0 0 0`,
lineHeight: '1.5',
}
const firstName = user.fullName?.split(' ')[0] ?? ''
return (
<EmailLayout
tokens={tokens}
preview={`${client.name} a payé la facture ${invoice.numero} (${invoice.amountFormatted})`}
brandSubtitle={`Facture ${invoice.numero} · payée`}
landingUrl={landingUrl}
>
<Section style={badgeWrapStyle}>
<span style={badgeStyle} aria-hidden="true">
</span>
</Section>
<Text style={headingStyle}>
Une facture vient d&apos;être <span style={{ color: tokens.primary }}>payée</span>
</Text>
<Text style={subheadingStyle}>
Rubis a détecté un virement entrant qui correspond à une de vos factures.
Tout a é géré&nbsp;: relances stoppées, remerciement envoyé.
</Text>
<Text style={greetingStyle}>
Bonjour {firstName || ''},
</Text>
<Text style={greetingStyle}>
<strong>{client.name}</strong> vient de régler votre facture
<strong> {invoice.numero}</strong>. Vous gagnez un rubis 💎 c&apos;est
10&nbsp;minutes que vous n&apos;aurez pas à passer à relancer ce client.
</Text>
<Section style={summaryCardStyle}>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Client</span>
<span style={summaryValueStyle}>{client.name}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Facture</span>
<span style={summaryValueStyle}>{invoice.numero}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Montant</span>
<span style={amountStyle}>{invoice.amountFormatted}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Détecté via</span>
<span style={summaryValueStyle}>{bankName}</span>
</Text>
</Section>
<Section style={infoCardStyle}>
<Text style={infoTextStyle}>
<strong>Libellé bancaire détecté&nbsp;:</strong>
<br />
<span
style={{
fontFamily: 'Menlo, Monaco, Consolas, monospace',
fontSize: '12px',
color: tokens.primaryDeep,
}}
>
{bankLabel}
</span>
</Text>
</Section>
<Section style={ctaWrapStyle}>
<Button href={invoice.detailUrl} style={buttonStyle}>
Voir la facture
</Button>
</Section>
<Text style={footnoteStyle}>
Vous êtes en mode <strong>réconciliation automatique</strong>. Pour
repasser à une validation manuelle (vous validez chaque match avant
que la facture passe payée), rendez-vous dans Paramètres Banque.
</Text>
</EmailLayout>
)
}

View File

@ -0,0 +1,35 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { Exception } from '@adonisjs/core/exceptions'
import Organization from '#models/organization'
/**
* Middleware de gating Pro/Business bloque les endpoints réservés aux
* plans payants (banking en V1). Symétrique de `assertBusinessPlan`,
* mais accepte aussi le plan Pro.
*
* Throw 403 avec `code: 'paid_plan_required'` si l'org est sur Free. Le
* SPA matche ce code pour afficher l'upsell card propre.
*/
export default class AssertPaidPlanMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const user = ctx.auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', {
status: 404,
code: 'not_found',
})
}
const org = await Organization.findOrFail(user.organizationId)
if (org.plan !== 'pro' && org.plan !== 'business') {
throw new Exception('Plan Pro ou Business requis pour cette fonctionnalité', {
status: 403,
code: 'paid_plan_required',
})
}
return next()
}
}

View File

@ -0,0 +1,13 @@
import { BankAccountSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import BankConnection from '#models/bank_connection'
import BankTransaction from '#models/bank_transaction'
export default class BankAccount extends BankAccountSchema {
@belongsTo(() => BankConnection)
declare connection: BelongsTo<typeof BankConnection>
@hasMany(() => BankTransaction)
declare transactions: HasMany<typeof BankTransaction>
}

View File

@ -0,0 +1,13 @@
import { BankConnectionSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import BankAccount from '#models/bank_account'
export default class BankConnection extends BankConnectionSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => BankAccount)
declare accounts: HasMany<typeof BankAccount>
}

View File

@ -0,0 +1,13 @@
import { BankTransactionSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import BankAccount from '#models/bank_account'
import Invoice from '#models/invoice'
export default class BankTransaction extends BankTransactionSchema {
@belongsTo(() => BankAccount)
declare account: BelongsTo<typeof BankAccount>
@belongsTo(() => Invoice, { foreignKey: 'matchedInvoiceId' })
declare matchedInvoice: BelongsTo<typeof Invoice>
}

View File

@ -2,6 +2,7 @@ import { OrganizationSchema } from '#database/schema'
import { column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
import BankConnection from '#models/bank_connection'
import type { BrandSettings } from '#services/brand'
export default class Organization extends OrganizationSchema {
@ -17,4 +18,13 @@ export default class Organization extends OrganizationSchema {
@hasMany(() => User)
declare users: HasMany<typeof User>
/**
* V1 = une seule connection active par org, mais on garde hasMany pour
* l'historique (connections révoquées passent en state='revoked'). Les
* services filtrent sur state='active' quand ils ont besoin de "la"
* connection courante.
*/
@hasMany(() => BankConnection)
declare bankConnections: HasMany<typeof BankConnection>
}

View File

@ -0,0 +1,413 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { DateTime } from 'luxon'
import encryption from '@adonisjs/core/services/encryption'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import Organization from '#models/organization'
import User from '#models/user'
import BankConnection from '#models/bank_connection'
import BankAccount from '#models/bank_account'
import { getPowensClient, type PowensConnection } from '#services/banking/powens_client'
import { syncConnectionTransactions } from '#services/banking/sync_connection'
import { reconcileTransactionsForOrg } from '#services/banking/reconcile_transactions'
import { sendBankConnectedEmail } from '#services/mail_dispatcher'
/**
* BankingService orchestration au-dessus du PowensClient.
*
* - gère le user Powens par org (init si pas encore créé, stockage du
* token chiffré via Adonis Encryption).
* - signe et vérifie le state HMAC du flow webview (anti-CSRF + porte
* l'org_id à travers le redirect).
* - upsert connections + accounts en DB au retour de la webview.
* - point d'entrée pour disconnect, listForOrg, et toggle du mode de
* réconciliation.
*
* Volontairement sans state interne fonctions pures qui prennent
* l'org en argument. Le seul état persistant est en DB.
*/
const STATE_TTL_SECONDS = 600 // 10 min — large pour couvrir le tunnel webview
export class BankingStateError extends Error {
constructor(reason: string) {
super(`State invalide : ${reason}`)
this.name = 'BankingStateError'
}
}
export class BankingNotConfiguredError extends Error {
constructor() {
super('Powens non configuré sur cet environnement (variables d\'env manquantes).')
this.name = 'BankingNotConfiguredError'
}
}
// --------------------------------------------------------------------
// State HMAC (anti-CSRF du flow webview)
// --------------------------------------------------------------------
interface StatePayload {
/** Organization id à laquelle binder la connection au retour. */
o: string
/** Powens user id (sanity check : doit matcher l'org au retour). */
u: number
/** Expiration epoch seconds. */
e: number
/** Random nonce, contre les replays. */
n: string
}
function getStateSecret(): Buffer {
const appKey = env.get('APP_KEY')
// env.get sur un secret renvoie un objet avec .release(). En test on a
// parfois une string brute.
const raw = typeof appKey === 'string' ? appKey : appKey.release()
return Buffer.from(raw, 'utf8')
}
function signState(payload: StatePayload): string {
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
const sig = createHmac('sha256', getStateSecret()).update(body).digest('base64url')
return `${body}.${sig}`
}
function verifyState(state: string): StatePayload {
const parts = state.split('.')
if (parts.length !== 2) throw new BankingStateError('format')
const [body, sig] = parts
const expected = createHmac('sha256', getStateSecret()).update(body).digest('base64url')
const a = Buffer.from(sig)
const b = Buffer.from(expected)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new BankingStateError('signature')
}
let payload: StatePayload
try {
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
} catch {
throw new BankingStateError('payload')
}
if (typeof payload.e !== 'number' || payload.e < Math.floor(Date.now() / 1000)) {
throw new BankingStateError('expired')
}
return payload
}
// --------------------------------------------------------------------
// User Powens (1 par organization)
// --------------------------------------------------------------------
/**
* Garantit que l'org a un user Powens. Si oui, retourne le token déchiffré.
* Si non, initialise un nouveau user via Powens et persiste le token chiffré.
*/
async function ensurePowensUser(org: Organization): Promise<{ userId: number; token: string }> {
if (org.powensUserId && org.powensTokenEncrypted) {
const decrypted = encryption.decrypt<string>(org.powensTokenEncrypted)
if (!decrypted) {
throw new Error('Token Powens chiffré illisible (clé APP_KEY changée ?)')
}
return { userId: Number(org.powensUserId), token: decrypted }
}
const client = getPowensClient()
const init = await client.initUser()
org.powensUserId = init.id_user
org.powensTokenEncrypted = encryption.encrypt(init.auth_token)
await org.save()
return { userId: init.id_user, token: init.auth_token }
}
// --------------------------------------------------------------------
// Flow webview — init
// --------------------------------------------------------------------
/**
* Construit l'URL de la webview Powens à retourner au front. Le front
* fait `window.location = url`. Le state encode l'org pour qu'on puisse
* retrouver le bon contexte au callback (qui n'a pas de Bearer token car
* c'est Powens qui redirige le navigateur).
*
* Refuse si une connection active existe déjà (V1 = 1 banque max). Le
* caller (controller) traduit en 409 UI propose "remplacer la banque".
*/
export async function createWebviewUrl(orgId: string): Promise<{ webviewUrl: string }> {
const domain = env.get('POWENS_DOMAIN')
const redirectUri = env.get('POWENS_REDIRECT_URI')
if (!domain || !redirectUri) throw new BankingNotConfiguredError()
const org = await Organization.findOrFail(orgId)
// V1 : 1 banque active max par org. L'UI propose une déconnexion explicite
// avant de reconnecter.
const existing = await BankConnection.query()
.where('organizationId', orgId)
.whereNotIn('state', ['revoked'])
.first()
if (existing) {
const err = new Error('Une banque est déjà connectée. Déconnectez-la avant d\'en ajouter une.')
;(err as any).code = 'bank_already_connected'
throw err
}
const { userId, token } = await ensurePowensUser(org)
const client = getPowensClient()
const codeRes = await client.createTemporaryCode(token)
const state = signState({
o: orgId,
u: userId,
e: Math.floor(Date.now() / 1000) + STATE_TTL_SECONDS,
n: randomBytes(12).toString('base64url'),
})
const webviewUrl = client.buildWebviewUrl({
code: codeRes.code,
state,
redirectUri,
domainSlug: domain,
})
logger.info(
{ orgId, userId, redirectUri, webviewUrl },
'banking.webview.init'
)
return { webviewUrl }
}
// --------------------------------------------------------------------
// Flow webview — callback
// --------------------------------------------------------------------
export interface CallbackResult {
orgId: string
connectionId: string
bankName: string
}
/**
* Traite le retour de la webview Powens. Vérifie le state, fetch la
* connection + accounts depuis Powens, upsert en DB. NE PAS faire le
* sync des transactions ici c'est lourd, on l'enqueue en job.
*
* Le caller (controller) :
* 1. récupère `connection_id` + `state` depuis les query params
* 2. appelle handleCallback
* 3. enqueue SyncPowensConnection
* 4. redirige 302 vers le SPA
*/
export async function handleCallback(params: {
connectionId: number
state: string
}): Promise<CallbackResult> {
const payload = verifyState(params.state)
const org = await Organization.findOrFail(payload.o)
if (org.powensUserId === null || Number(org.powensUserId) !== payload.u) {
throw new BankingStateError('user_mismatch')
}
if (!org.powensTokenEncrypted) {
throw new BankingStateError('no_token')
}
const token = encryption.decrypt<string>(org.powensTokenEncrypted)
if (!token) throw new BankingStateError('token_decrypt')
const client = getPowensClient()
const conn = await client.getConnection(token, params.connectionId)
const bankName = conn.connector?.name ?? 'Banque'
// Upsert connection
const existing = await BankConnection.query()
.where('organizationId', payload.o)
.where('powensConnectionId', conn.id)
.first()
const record =
existing ??
new BankConnection().fill({
organizationId: payload.o,
powensConnectionId: BigInt(conn.id) as any,
})
record.bankName = bankName
record.bankLogoUrl = null
record.state = computeConnectionState(conn)
record.lastSyncAt = conn.last_update ? DateTime.fromISO(conn.last_update) : null
record.lastError = conn.error_message ?? conn.error ?? null
await record.save()
// Upsert accounts (idempotent : on traite chacun)
const accounts = await client.getConnectionAccounts(token, conn.id)
const savedAccounts: Array<{ name: string; iban: string | null }> = []
for (const acc of accounts.accounts) {
if (acc.disabled) continue
const accExisting = await BankAccount.query()
.where('bankConnectionId', record.id)
.where('powensAccountId', acc.id)
.first()
const accRecord =
accExisting ??
new BankAccount().fill({
bankConnectionId: record.id,
powensAccountId: BigInt(acc.id) as any,
})
accRecord.name = acc.name ?? acc.original_name ?? 'Compte'
accRecord.iban = acc.iban ?? null
accRecord.currency = acc.currency?.id ?? 'EUR'
accRecord.balanceCents = acc.balance !== null && acc.balance !== undefined
? Math.round(acc.balance * 100)
: null
accRecord.type = acc.type ?? null
await accRecord.save()
savedAccounts.push({ name: accRecord.name, iban: accRecord.iban })
}
/**
* Post-callback async : 3 étapes séquentielles en fire-and-forget pour
* ne pas ralentir le redirect 302 vers la page success (la webview
* Powens est déjà fermée côté browser, on ne fait plus attendre l'user).
* 1. Mail "banque connectée" à l'user
* 2. Sync des 90 derniers jours de transactions depuis Powens
* 3. Reconciliation : matche transactions factures impayées,
* auto-marquage si mode=auto + confiance HIGH, sinon 'suggested'
*
* Une erreur à une étape ne bloque pas les suivantes (chaque étape
* a son try/catch). La connexion est déjà persistée en DB.
*/
void runPostCallback(payload.o, record.id, bankName, savedAccounts)
return {
orgId: payload.o,
connectionId: record.id,
bankName,
}
}
async function runPostCallback(
orgId: string,
connectionId: string,
bankName: string,
accounts: Array<{ name: string; iban: string | null }>
): Promise<void> {
// 1. Mail "banque connectée"
try {
const user = await User.query()
.where('organizationId', orgId)
.orderBy('createdAt', 'asc')
.first()
if (user) {
await sendBankConnectedEmail({
user,
bank: { name: bankName },
accounts: accounts.map((a) => ({
name: a.name,
ibanMasked: maskIbanForMail(a.iban),
})),
})
} else {
logger.warn({ orgId }, 'banking.callback.no_user_for_email')
}
} catch (err) {
logger.error({ err, orgId }, 'banking.callback.email_failed')
}
// 2. Sync transactions (90 jours)
try {
await syncConnectionTransactions({ connectionId, daysBack: 90 })
} catch (err) {
logger.error({ err, connectionId }, 'banking.callback.sync_failed')
return // si pas de sync, rien à réconcilier
}
// 3. Réconciliation
try {
await reconcileTransactionsForOrg(orgId)
} catch (err) {
logger.error({ err, orgId }, 'banking.callback.reconcile_failed')
}
}
/**
* "FR7612345678901234567890123" "FR76 **** **** **** 0123".
* Identique au transformer côté HTTP, dupliqué ici parce qu'on l'utilise
* dans le mail (avant que les accounts soient sérialisés par le HTTP layer).
*/
function maskIbanForMail(iban: string | null): string | null {
if (!iban) return null
const compact = iban.replace(/\s+/g, '')
if (compact.length < 8) return compact
return `${compact.slice(0, 4)} **** **** **** ${compact.slice(-4)}`
}
/**
* Map les états Powens vers notre vocabulaire interne. Powens renvoie
* `state: null` pour "actif" et une string en cas d'erreur. Cf. doc
* Powens connection.state.
*/
function computeConnectionState(conn: PowensConnection): string {
if (!conn.state) return 'active'
if (conn.state === 'wrongpass') return 'wrongpass'
if (conn.state === 'additionalInformationNeeded') return 'sca_required'
return 'error'
}
// --------------------------------------------------------------------
// Liste / disconnect
// --------------------------------------------------------------------
export async function listConnectionsForOrg(orgId: string) {
const connections = await BankConnection.query()
.where('organizationId', orgId)
.whereNot('state', 'revoked')
.preload('accounts')
.orderBy('createdAt', 'desc')
return connections
}
/**
* Déconnecte une banque : DELETE côté Powens + marque la ligne en DB
* comme 'revoked' (on ne hard-delete pas pour garder l'historique de
* transactions matchées sur des factures passées).
*/
export async function disconnect(orgId: string, connectionId: string): Promise<void> {
const conn = await BankConnection.query()
.where('organizationId', orgId)
.where('id', connectionId)
.firstOrFail()
const org = await Organization.findOrFail(orgId)
if (org.powensTokenEncrypted) {
const token = encryption.decrypt<string>(org.powensTokenEncrypted)
if (token) {
try {
await getPowensClient().deleteConnection(token, Number(conn.powensConnectionId))
} catch (err) {
// On continue même si Powens râle (404 = déjà supprimée côté Powens,
// 401 = token invalide). On log silencieusement et on marque côté DB.
}
}
}
conn.state = 'revoked'
await conn.save()
}
export async function setReconciliationMode(
orgId: string,
mode: 'manual' | 'auto'
): Promise<Organization> {
const org = await Organization.findOrFail(orgId)
org.reconciliationMode = mode
await org.save()
return org
}

View File

@ -0,0 +1,365 @@
import env from '#start/env'
import { createHmac, timingSafeEqual } from 'node:crypto'
/**
* Client HTTP bas niveau pour l'API Powens (ex-Budget Insight).
*
* Convention : ce service ne touche PAS à la DB ni au chiffrement. Il fait
* uniquement de la sérialisation HTTP. Le BankingService (au-dessus)
* orchestre persistance, chiffrement du token et logique métier.
*
* Singleton lazy-init pour ne pas crasher en dev quand les credentials
* Powens manquent : on n'instancie qu'au premier appel.
*
* Doc Powens : https://docs.powens.com/api-reference
* - /auth/init (admin) crée un user permanent + retourne son auth_token
* - /auth/token/code (user) génère un code one-shot 30s pour la webview
* - /users/me/... (user) endpoints user-scoped (connections, accounts, )
*
* Authentification :
* - admin (init user) : body { client_id, client_secret }
* - user (tout le reste) : header `Authorization: Bearer <auth_token>`
*/
// --------------------------------------------------------------------
// Types — sous-set des réponses Powens qu'on consomme. Volontairement
// minimaliste : on ne type que ce qu'on utilise, pas tout le schéma
// (Powens a ~200 champs sur les transactions). Le raw payload est
// stocké en jsonb pour pouvoir extraire un champ supplémentaire plus
// tard sans re-sync.
// --------------------------------------------------------------------
export interface PowensInitUserResponse {
auth_token: string
type: 'permanent'
id_user: number
}
export interface PowensTemporaryCodeResponse {
code: string
type: 'singleAccess'
access: 'public'
expires_in: number
}
export interface PowensConnection {
id: number
id_user: number
id_connector: number
state: string | null // null = actif, sinon "wrongpass", "additionalInformationNeeded", …
error: string | null
error_message: string | null
last_update: string | null
connector?: {
id: number
name: string
color?: string | null
uuid?: string
/** URL du logo (Powens CDN). */
siret?: string | null
}
}
export interface PowensAccount {
id: number
id_connection: number
id_user: number
number: string | null
original_name: string | null
name: string
iban: string | null
balance: number | null // unités natives (EUR), pas centimes
currency: { id: string } | null
type: string | null // 'checking' | 'savings' | 'card' | ...
disabled?: boolean
}
export interface PowensTransaction {
id: number
id_account: number
date: string // YYYY-MM-DD (date de valeur)
rdate?: string | null // date réelle (transactional date)
vdate?: string | null
bdate?: string | null // booking date
value: number // signé, unités natives (EUR)
original_value?: number | null
original_wording: string
simplified_wording?: string | null
wording: string | null
type?: string | null
/** Powens enrichit la transaction (category, counterparty, …). On garde le raw côté DB. */
[key: string]: unknown
}
export class PowensApiError extends Error {
constructor(
public readonly status: number,
public readonly path: string,
public readonly body: string
) {
super(`Powens ${path} → HTTP ${status}: ${body.slice(0, 500)}`)
this.name = 'PowensApiError'
}
}
export class PowensUnauthorizedError extends PowensApiError {
constructor(path: string, body: string) {
super(401, path, body)
this.name = 'PowensUnauthorizedError'
}
}
// --------------------------------------------------------------------
// Client
// --------------------------------------------------------------------
class PowensClient {
/**
* Base URL complète (avec /2.0/ et trailing slash). Calculée depuis le
* slug du domaine si POWENS_API_BASE_URL n'est pas fourni.
*/
constructor(
private readonly baseUrl: string,
private readonly clientId: string,
private readonly clientSecret: string
) {}
// ----- Auth admin -------------------------------------------------
/**
* Crée un nouveau user permanent côté Powens. Appelée au premier
* "Connecter ma banque" d'une organization, le couple (id_user,
* auth_token) est ensuite stocké sur l'org (token chiffré).
*
* Idempotent côté code : on stocke côté Rubis pour ne pas re-créer
* à chaque fois. Côté Powens, chaque appel crée un nouveau user
* donc à n'appeler qu'une fois par org.
*/
async initUser(): Promise<PowensInitUserResponse> {
return this.request<PowensInitUserResponse>('POST', 'auth/init', {
body: {
client_id: this.clientId,
client_secret: this.clientSecret,
},
})
}
// ----- Webview ----------------------------------------------------
/**
* Génère un code one-shot (30s) pour ouvrir la webview Powens dans
* le navigateur. Le user doit avoir un auth_token (initUser au préalable).
*/
async createTemporaryCode(userToken: string): Promise<PowensTemporaryCodeResponse> {
return this.request<PowensTemporaryCodeResponse>('GET', 'auth/token/code', {
userToken,
})
}
/**
* Construit l'URL de la webview Powens (page hébergée chez Powens
* le user choisit sa banque + se logue). Retournée par l'API au front,
* qui fait un `window.location = url`.
*
* Le state est un HMAC signé (anti-CSRF), généré et vérifié par le
* BankingService (pas par ce client).
*/
buildWebviewUrl(params: {
code: string
state: string
redirectUri: string
/** Slug du domaine (ex: 'rubis-sandbox') — pas le FQDN. */
domainSlug: string
/** Locale, default 'fr'. Passée en query param `lang`. */
locale?: string
}): string {
const { code, state, redirectUri, domainSlug, locale = 'fr' } = params
// Powens Connect 2.0 : path racine `/connect`, locale en query param.
// L'ancienne forme `webview.powens.com/<locale>/connect` retombe sur
// un fallback qui essaie un `webauth/resume` avec un state interne
// jamais créé → "No state found for this id".
const u = new URL('https://webview.powens.com/connect')
u.searchParams.set('domain', `${domainSlug}.biapi.pro`)
u.searchParams.set('client_id', this.clientId)
u.searchParams.set('redirect_uri', redirectUri)
u.searchParams.set('code', code)
u.searchParams.set('state', state)
if (locale) u.searchParams.set('lang', locale)
return u.toString()
}
// ----- Connections ------------------------------------------------
async listConnections(userToken: string): Promise<{ connections: PowensConnection[] }> {
return this.request<{ connections: PowensConnection[] }>(
'GET',
'users/me/connections?expand=connector',
{ userToken }
)
}
async getConnection(userToken: string, connectionId: number): Promise<PowensConnection> {
return this.request<PowensConnection>(
'GET',
`users/me/connections/${connectionId}?expand=connector`,
{ userToken }
)
}
async deleteConnection(userToken: string, connectionId: number): Promise<void> {
await this.request<void>('DELETE', `users/me/connections/${connectionId}`, {
userToken,
expectNoBody: true,
})
}
// ----- Accounts ---------------------------------------------------
/**
* Liste les comptes d'une connection. Powens renvoie aussi les comptes
* désactivés (`disabled: true`) le caller filtre.
*/
async getConnectionAccounts(
userToken: string,
connectionId: number
): Promise<{ accounts: PowensAccount[] }> {
return this.request<{ accounts: PowensAccount[] }>(
'GET',
`users/me/connections/${connectionId}/accounts`,
{ userToken }
)
}
// ----- Transactions -----------------------------------------------
/**
* Liste paginée des transactions d'un compte. Powens utilise une
* pagination par `limit` + cursor (`offset`). On expose un wrapper
* simple ; pour les gros volumes, le caller fait sa propre boucle.
*
* min_date / max_date : 'YYYY-MM-DD'. Par défaut Powens renvoie les
* 90 derniers jours.
*/
async getAccountTransactions(
userToken: string,
accountId: number,
opts: {
minDate?: string
maxDate?: string
limit?: number
offset?: number
} = {}
): Promise<{ transactions: PowensTransaction[]; total: number }> {
const qs = new URLSearchParams()
if (opts.minDate) qs.set('min_date', opts.minDate)
if (opts.maxDate) qs.set('max_date', opts.maxDate)
qs.set('limit', String(opts.limit ?? 500))
if (opts.offset !== undefined) qs.set('offset', String(opts.offset))
return this.request<{ transactions: PowensTransaction[]; total: number }>(
'GET',
`users/me/accounts/${accountId}/transactions?${qs.toString()}`,
{ userToken }
)
}
// ----- Webhook signature ------------------------------------------
/**
* Vérifie la signature HMAC SHA-256 d'un webhook Powens. La signature
* arrive dans le header `BI-Signature` (HMAC du body brut avec le
* secret webhook configuré côté console Powens).
*
* Comparison timing-safe pour éviter les attaques par timing.
*/
verifyWebhookSignature(rawBody: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// ----- Bas niveau -------------------------------------------------
private async request<T>(
method: 'GET' | 'POST' | 'DELETE',
path: string,
opts: {
userToken?: string
body?: unknown
expectNoBody?: boolean
} = {}
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
if (opts.userToken) headers['Authorization'] = `Bearer ${opts.userToken}`
const url = `${this.baseUrl}${path}`
const res = await fetch(url, {
method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
})
const text = await res.text()
if (!res.ok) {
if (res.status === 401) throw new PowensUnauthorizedError(path, text)
throw new PowensApiError(res.status, path, text)
}
if (opts.expectNoBody || text.length === 0) {
return undefined as T
}
try {
return JSON.parse(text) as T
} catch {
throw new PowensApiError(res.status, path, `Réponse non-JSON: ${text.slice(0, 200)}`)
}
}
}
// --------------------------------------------------------------------
// Singleton accessor
// --------------------------------------------------------------------
let _client: PowensClient | null = null
/**
* Retourne le client Powens singleton. Throw si les credentials ne sont
* pas configurés le caller doit gérer ce cas (typiquement renvoyer
* une 503 "banking not configured").
*/
export function getPowensClient(): PowensClient {
if (_client) return _client
const domain = env.get('POWENS_DOMAIN')
const clientId = env.get('POWENS_CLIENT_ID')
const clientSecret = env.get('POWENS_CLIENT_SECRET')
if (!domain || !clientId || !clientSecret) {
throw new Error(
'Powens non configuré : POWENS_DOMAIN / POWENS_CLIENT_ID / POWENS_CLIENT_SECRET manquants. Voir /docs/tech/banking-setup.md.'
)
}
// Override via POWENS_API_BASE_URL si fourni, sinon calculé depuis le slug.
const explicit = env.get('POWENS_API_BASE_URL')
let baseUrl = explicit ?? `https://${domain}.biapi.pro/2.0/`
if (!baseUrl.endsWith('/')) baseUrl += '/'
// env.get sur un secret renvoie un objet avec .release(), pas une string.
const secret = typeof clientSecret === 'string' ? clientSecret : clientSecret.release()
_client = new PowensClient(baseUrl, clientId, secret)
return _client
}
/** Reset le singleton — utile en test (jamais appelé en prod). */
export function resetPowensClient(): void {
_client = null
}
export type { PowensClient }

View File

@ -0,0 +1,312 @@
import logger from '@adonisjs/core/services/logger'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import Organization from '#models/organization'
import User from '#models/user'
import BankConnection from '#models/bank_connection'
import BankTransaction from '#models/bank_transaction'
import Invoice from '#models/invoice'
import Client from '#models/client'
import * as clock from '#services/clock'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { cancelCheckinForInvoice } from '#services/checkin_scheduler'
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
import { sendInvoiceAutoPaidNotification } from '#services/mail_dispatcher'
/**
* Réconciliation transactions factures.
*
* Stratégie V1 :
* - On regarde uniquement les transactions CRÉDITRICES (amount_cents > 0)
* de status 'unmatched' dont les comptes appartiennent à l'org.
* - Pour chaque transaction, on cherche les factures candidates de l'org
* en attente de règlement (status pending, in_relance,
* awaiting_user_confirmation) ayant exactement le même `amount_ttc_cents`.
* - Score de confiance :
* HIGH si label contient le numéro de facture (insensible casse/espaces)
* ou le nom du client normalisé. Cas non-ambigu (1 seul candidat
* avec ce montant + texte qui matche).
* LOW si juste le montant matche, ou s'il y a plusieurs candidats
* avec le même montant (ambigu).
*
* Selon `org.reconciliation_mode` :
* - 'auto' + HIGH on marque la facture payée, on annule les relances,
* on remercie le client (thanks email), on notifie le user (notif email),
* on incrémente rubisCount, on bonus rubisEarned. Transaction passe
* 'confirmed' avec matched_invoice_id.
* - 'auto' + LOW, ou 'manual' (tous niveaux) transaction passe
* 'suggested' (l'UI proposera validation user, V2).
* - aucun match du tout transaction reste 'unmatched'.
*
* Idempotent : si on rejoue, les transactions déjà 'confirmed' ne sont pas
* retraitées (filtre status). Si on rejoue une 'suggested' qui a entre-temps
* trouvé un meilleur match, on peut la promouvoir (V2).
*/
export interface ReconcileResult {
scanned: number
autoConfirmed: number
suggested: number
}
export async function reconcileTransactionsForOrg(orgId: string): Promise<ReconcileResult> {
const org = await Organization.findOrFail(orgId)
const mode: 'manual' | 'auto' =
org.reconciliationMode === 'auto' ? 'auto' : 'manual'
// Récupère tous les comptes liés à l'org via leurs connections.
const connectionIds = (
await BankConnection.query()
.where('organizationId', orgId)
.whereNot('state', 'revoked')
.select('id')
).map((c) => c.id)
if (connectionIds.length === 0) {
return { scanned: 0, autoConfirmed: 0, suggested: 0 }
}
// Récupère les transactions à traiter (crédits non matchés des comptes
// de l'org).
const candidates = await BankTransaction.query()
.where('matchStatus', 'unmatched')
.where('amountCents', '>', 0)
.whereHas('account', (q) => q.whereIn('bankConnectionId', connectionIds))
.orderBy('valueDate', 'asc')
.preload('account', (q) => q.preload('connection'))
if (candidates.length === 0) {
return { scanned: 0, autoConfirmed: 0, suggested: 0 }
}
// Précharge les factures en attente — on les re-query à chaque tx car
// le statut peut changer en cours de boucle (auto-mark-paid sur une
// tx précédente).
let autoConfirmed = 0
let suggested = 0
for (const tx of candidates) {
const match = await findInvoiceMatch(orgId, tx.amountCents, tx.label)
if (!match) continue
const isHighConfidence = match.confidence === 'high'
if (mode === 'auto' && isHighConfidence) {
const ok = await processAutoMatch({
transaction: tx,
invoice: match.invoice,
bankLabel: tx.label,
bankName: tx.account.connection.bankName,
})
if (ok) autoConfirmed++
} else {
tx.matchStatus = 'suggested'
tx.matchedInvoiceId = match.invoice.id
await tx.save()
suggested++
}
}
logger.info(
{ orgId, mode, scanned: candidates.length, autoConfirmed, suggested },
'banking.reconcile.completed'
)
return { scanned: candidates.length, autoConfirmed, suggested }
}
// --------------------------------------------------------------------
// Matching
// --------------------------------------------------------------------
interface InvoiceMatch {
invoice: Invoice
client: Client
confidence: 'high' | 'low'
}
const PAYABLE_STATUSES = ['pending', 'in_relance', 'awaiting_user_confirmation'] as const
/**
* Cherche un match pour (amount, label) parmi les factures à régler de l'org.
* Renvoie le premier match en confiance HIGH (texte explicite dans le label)
* sinon le match unique en confiance LOW (montant seul, 1 candidat) sinon null.
*/
async function findInvoiceMatch(
orgId: string,
amountCents: number,
label: string
): Promise<InvoiceMatch | null> {
const sameAmount = await Invoice.query()
.where('organizationId', orgId)
.where('amountTtcCents', amountCents)
.whereIn('status', PAYABLE_STATUSES as unknown as string[])
.preload('client')
if (sameAmount.length === 0) return null
const normalizedLabel = normalize(label)
// 1) HIGH-confidence : on cherche numero ou client name dans le label.
for (const inv of sameAmount) {
const numeroNormalized = normalize(inv.numero)
if (numeroNormalized && normalizedLabel.includes(numeroNormalized)) {
return { invoice: inv, client: inv.client, confidence: 'high' }
}
const clientNameNormalized = normalize(inv.client.name)
if (clientNameNormalized && normalizedLabel.includes(clientNameNormalized)) {
return { invoice: inv, client: inv.client, confidence: 'high' }
}
}
// 2) LOW-confidence : un seul candidat même montant sans texte qui matche,
// c'est ambigu mais probable. Plusieurs candidats → trop ambigu, skip.
if (sameAmount.length === 1) {
return { invoice: sameAmount[0], client: sameAmount[0].client, confidence: 'low' }
}
return null
}
/**
* Normalise une string pour matching texte :
* - lowercase
* - retire accents (NFD + diacritic strip)
* - retire caractères non alphanumériques
*
* "F-2026-0042" "f20260042"
* "Cabinet Müller & Cie" "cabinetmullercie"
* "VIR SEPA F-2026-0042 CABINET MULLER" "virsepaf20260042cabinetmuller"
*
* Permet de matcher "F-2026-0042" dans "VIR F2026-0042" même si le libellé
* bancaire a perdu le tiret.
*/
function normalize(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]/g, '')
}
// --------------------------------------------------------------------
// Processing auto
// --------------------------------------------------------------------
/**
* Process complet d'un match auto-confirmé. Toute la logique de
* "marquer payé" est dupliquée depuis InvoicesController.markPaid car
* on ne veut pas couper le controller en service (refactor V2).
*
* Retourne false si la facture a déjà é marquée payée entre temps
* (race condition rare avec un mark-paid manuel) l'idempotence est
* gérée au niveau DB par le check status='paid'.
*/
async function processAutoMatch(params: {
transaction: BankTransaction
invoice: Invoice
bankLabel: string
bankName: string
}): Promise<boolean> {
const { transaction, invoice, bankLabel, bankName } = params
// Garde-fou : re-check status avant tout, on évite de payer 2x.
await invoice.refresh()
if (invoice.status === 'paid') {
// Marque quand même la transaction pour qu'on ne la re-traite pas.
transaction.matchStatus = 'confirmed'
transaction.matchedInvoiceId = invoice.id
await transaction.save()
return false
}
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = await clock.now(invoice.organizationId)
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
await trx
.from('organizations')
.where('id', invoice.organizationId)
.increment('rubis_count', 1)
await recordActivity({
organizationId: invoice.organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> détectée payée via ${bankName}`,
meta: {
invoiceId: invoice.id,
clientId: invoice.clientId,
bankTransactionId: transaction.id,
source: 'banking_auto',
},
trx,
})
await cancelFutureRelances(invoice.id, trx)
await cancelCheckinForInvoice(invoice.id, trx)
transaction.useTransaction(trx)
transaction.matchStatus = 'confirmed'
transaction.matchedInvoiceId = invoice.id
await transaction.save()
})
// Hors transaction : enqueue thanks email au client + notif user.
// Fire-and-forget : si un mail loupe, on log mais on ne casse pas le batch.
await enqueuePaymentThanks(invoice.id).catch((err) =>
logger.error(
{ err, invoiceId: invoice.id },
'banking.reconcile.thanks_email_failed'
)
)
await notifyUserInvoiceAutoPaid({ invoice, bankLabel, bankName }).catch((err) =>
logger.error(
{ err, invoiceId: invoice.id },
'banking.reconcile.user_notif_failed'
)
)
return true
}
/**
* Envoie le mail "facture X payée par Y" au 1er user de l'org (V1
* mono-user). Isolé pour catch ciblé.
*/
async function notifyUserInvoiceAutoPaid(params: {
invoice: Invoice
bankLabel: string
bankName: string
}): Promise<void> {
const user = await User.query()
.where('organizationId', params.invoice.organizationId)
.orderBy('createdAt', 'asc')
.first()
if (!user) {
logger.warn(
{ invoiceId: params.invoice.id, orgId: params.invoice.organizationId },
'banking.reconcile.no_user_for_notif'
)
return
}
// Recharge client pour avoir le nom (au cas où pas préchargé).
const client = await params.invoice.related('client').query().firstOrFail()
await sendInvoiceAutoPaidNotification({
user,
invoice: params.invoice,
client,
bankLabel: params.bankLabel,
bankName: params.bankName,
})
}
// --------------------------------------------------------------------
// Suppress unused luxon import warning when DateTime n'est pas utilisé
// au top-level (on l'importe pour cohérence avec les autres services).
// --------------------------------------------------------------------
export type _Marker = typeof DateTime

View File

@ -0,0 +1,113 @@
import logger from '@adonisjs/core/services/logger'
import encryption from '@adonisjs/core/services/encryption'
import { DateTime } from 'luxon'
import Organization from '#models/organization'
import BankConnection from '#models/bank_connection'
import BankAccount from '#models/bank_account'
import BankTransaction from '#models/bank_transaction'
import { getPowensClient, type PowensTransaction } from '#services/banking/powens_client'
/**
* Synchronise les transactions d'une bank_connection depuis Powens vers
* notre DB. Idempotent (UNIQUE sur (account, powens_id) INSERT on
* conflict NOTHING équivalent côté Lucid : on check l'existence avant).
*
* Fenêtre par défaut : 90 jours en arrière. Au premier sync c'est large
* (couvre la plupart des factures en cours). Aux syncs suivants (webhook
* NEW_TRANSACTIONS), Powens nous donne tout ce qui est nouveau de toute
* façon la fenêtre sert surtout au backfill initial.
*
* On filtre les comptes pour ne sync que les comptes de type 'checking'
* par défaut : c'est sur ces comptes que tombent les virements clients
* (un livret ou un compte titre n'a pas de virement entrant à matcher
* avec une facture). Si on a besoin d'étendre, le filtre est ici.
*/
export async function syncConnectionTransactions(params: {
connectionId: string
daysBack?: number
}): Promise<{ inserted: number; skipped: number; accountsSynced: number }> {
const { connectionId, daysBack = 90 } = params
const connection = await BankConnection.findOrFail(connectionId)
const org = await Organization.findOrFail(connection.organizationId)
if (!org.powensTokenEncrypted) {
throw new Error('Org sans token Powens — connection orpheline ?')
}
const token = encryption.decrypt<string>(org.powensTokenEncrypted)
if (!token) throw new Error('Token Powens illisible')
const accounts = await BankAccount.query().where('bankConnectionId', connectionId)
const minDate = DateTime.now().minus({ days: daysBack }).toISODate()
const client = getPowensClient()
let inserted = 0
let skipped = 0
let accountsSynced = 0
for (const account of accounts) {
// Pour V1 on ne sync que les comptes courants. Élargir le filtre si
// on a besoin de matcher des paiements sur cartes ou autres types.
if (account.type && account.type !== 'checking') continue
accountsSynced++
try {
const result = await client.getAccountTransactions(token, Number(account.powensAccountId), {
minDate: minDate ?? undefined,
limit: 500,
})
for (const tx of result.transactions) {
const persisted = await upsertTransaction(account.id, tx)
if (persisted) inserted++
else skipped++
}
} catch (err) {
logger.warn(
{ err, connectionId, accountId: account.id, powensAccountId: account.powensAccountId },
'banking.sync.account_failed'
)
}
}
connection.lastSyncAt = DateTime.now()
await connection.save()
logger.info(
{ connectionId, accountsSynced, inserted, skipped },
'banking.sync.completed'
)
return { inserted, skipped, accountsSynced }
}
/**
* Upsert d'une transaction Powens. Retourne true si insérée (nouvelle),
* false si déjà connue (skipped). Powens utilise des montants en unités
* natives (EUR avec décimales), on convertit en cents au stockage.
*/
async function upsertTransaction(
bankAccountId: string,
tx: PowensTransaction
): Promise<boolean> {
const existing = await BankTransaction.query()
.where('bankAccountId', bankAccountId)
.where('powensId', tx.id)
.first()
if (existing) return false
const valueDate = DateTime.fromISO(tx.date)
const bookedAt = tx.bdate ? DateTime.fromISO(tx.bdate) : null
await BankTransaction.create({
bankAccountId,
powensId: BigInt(tx.id) as any,
amountCents: Math.round(tx.value * 100),
label: tx.original_wording ?? tx.wording ?? '',
wording: tx.simplified_wording ?? tx.wording ?? null,
valueDate,
bookedAt,
raw: tx,
matchStatus: 'unmatched',
})
return true
}

View File

@ -16,6 +16,8 @@ import type Organization from '#models/organization'
import { CheckinEmail } from '#mails/checkin_email'
import { RelanceEmail } from '#mails/relance_email'
import { PaymentThanksEmail } from '#mails/payment_thanks_email'
import { BankConnectedEmail } from '#mails/bank_connected_email'
import { InvoiceAutoPaidNotificationEmail } from '#mails/invoice_auto_paid_notification_email'
import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand'
/**
@ -426,3 +428,190 @@ export async function sendPaymentThanksEmail({
throw err
}
}
type BankConnectedPayload = {
user: User
bank: { name: string }
accounts: Array<{ name: string; ibanMasked: string | null }>
}
/**
* Envoie un email de confirmation À L'UTILISATEUR (pas au client final)
* quand sa banque vient d'être connectée via Powens. Notif interne
* Rubis user, donc toujours en branding Rubis (pas de marque blanche
* même pour Business).
*
* Idempotence : appelée en fire-and-forget depuis BankingService.handleCallback.
* Si l'envoi échoue, on log et on swallow la connexion bancaire est déjà
* en DB, on ne veut pas casser le flow utilisateur pour un mail loupé.
*/
export async function sendBankConnectedEmail({
user,
bank,
accounts,
}: BankConnectedPayload) {
const subject = `${bank.name} est connectée à Rubis`
const accountsList = accounts
.map((a) => `${a.name}${a.ibanMasked ? ` (${a.ibanMasked})` : ''}`)
.join('\n')
const body = `Bonjour ${user.fullName ?? ''},
Bonne nouvelle : ${bank.name} est désormais reliée à votre espace Rubis.
Comptes synchronisés :
${accountsList}
Rubis va maintenant détecter automatiquement vos virements entrants
et arrêter les relances dès qu'une facture est payée.
Voir mes paramètres : ${env.get('WEB_URL', 'http://localhost:5173')}/parametres
Lecture seule. Aucun déplacement de fonds possible. Vous pouvez
déconnecter cette banque à tout moment depuis vos paramètres.
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const settingsUrl = `${env.get('WEB_URL', 'http://localhost:5173')}/parametres`
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
BankConnectedEmail({
tokens: DEFAULT_BRAND, // notif Rubis → user, jamais en marque blanche
user: { fullName: user.fullName ?? null },
bank,
accounts,
settingsUrl,
landingUrl,
})
)
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
userId: user.id,
to: user.email,
bank: bank.name,
accountsCount: accounts.length,
driver,
},
'sendBankConnectedEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.html(htmlBody)
.text(body)
})
logger.info(
{ userId: user.id, bank: bank.name, driver },
'sendBankConnectedEmail: send OK'
)
} catch (err) {
logger.error(
{ err, userId: user.id, bank: bank.name, driver },
'sendBankConnectedEmail: échec envoi'
)
throw err
}
}
type InvoiceAutoPaidNotifPayload = {
user: User
invoice: Invoice
client: Client
bankLabel: string
bankName: string
}
/**
* Notif À L'UTILISATEUR (pas au client) quand la réconciliation auto
* a marqué une facture payée à partir d'un virement bancaire détecté.
* Toujours en branding Rubis. Inclut le lien direct vers la facture.
*
* À distinguer de `sendPaymentThanksEmail` qui, lui, va au CLIENT pour
* le remercier.
*/
export async function sendInvoiceAutoPaidNotification({
user,
invoice,
client,
bankLabel,
bankName,
}: InvoiceAutoPaidNotifPayload) {
const amountFormatted = formatAmountFr(invoice.amountTtcCents)
const subject = `${client.name} a payé la facture ${invoice.numero}`
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const detailUrl = `${webUrl}/factures/${invoice.id}`
const body = `Bonjour ${user.fullName ?? ''},
${client.name} vient de régler la facture ${invoice.numero} d'un montant de ${amountFormatted}.
Rubis a détecté le virement entrant sur votre ${bankName} et a tout géré pour vous :
Facture marquée payée
Relances futures annulées
Email de remerciement envoyé à ${client.name}
Libellé bancaire détecté :
${bankLabel}
Voir la facture : ${detailUrl}
Mode de réconciliation : automatique. Pour repasser en validation manuelle,
rendez-vous dans Paramètres Banque.
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
InvoiceAutoPaidNotificationEmail({
tokens: DEFAULT_BRAND,
user: { fullName: user.fullName ?? null },
invoice: { numero: invoice.numero, amountFormatted, detailUrl },
client: { name: client.name },
bankLabel,
bankName,
landingUrl,
})
)
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{ userId: user.id, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendInvoiceAutoPaidNotification: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.html(htmlBody)
.text(body)
})
logger.info(
{ userId: user.id, invoiceId: invoice.id, driver },
'sendInvoiceAutoPaidNotification: send OK'
)
} catch (err) {
logger.error(
{ err, userId: user.id, invoiceId: invoice.id, driver },
'sendInvoiceAutoPaidNotification: échec envoi'
)
throw err
}
}

View File

@ -0,0 +1,50 @@
import type BankConnection from '#models/bank_connection'
import type BankAccount from '#models/bank_account'
import { BaseTransformer } from '@adonisjs/core/transformers'
/**
* Sérialise un BankConnection (avec ses accounts préloadés) pour le SPA.
*
* IBAN masqué : "FR76 **** **** **** 1234" on n'expose jamais les 8
* blocs internes au front, ils ne servent qu'au backend pour la
* réconciliation.
*/
export default class BankConnectionTransformer extends BaseTransformer<BankConnection> {
toObject() {
const c = this.resource
return {
id: c.id,
bankName: c.bankName,
bankLogoUrl: c.bankLogoUrl,
state: c.state,
lastSyncAt: c.lastSyncAt?.toISO() ?? null,
lastError: c.lastError,
createdAt: c.createdAt.toISO()!,
accounts: (c.accounts ?? []).map(serializeAccount),
}
}
}
function serializeAccount(a: BankAccount) {
return {
id: a.id,
name: a.name,
type: a.type,
currency: a.currency,
balanceCents: a.balanceCents,
ibanMasked: maskIban(a.iban),
}
}
/**
* "FR7612345678901234567890123" "FR76 **** **** **** 0123"
* Garde les 4 premiers (pays + clé) et les 4 derniers. Le reste masqué.
*/
function maskIban(iban: string | null): string | null {
if (!iban) return null
const compact = iban.replace(/\s+/g, '')
if (compact.length < 8) return compact
const head = compact.slice(0, 4)
const tail = compact.slice(-4)
return `${head} **** **** **** ${tail}`
}

View File

@ -0,0 +1,11 @@
import vine from '@vinejs/vine'
/**
* PATCH /api/v1/banking/settings pour l'instant un seul champ
* (mode de réconciliation manuel / auto). Validator dédié pour
* pouvoir ajouter d'autres settings banking sans toucher au validator
* d'org.
*/
export const updateBankingSettingsValidator = vine.create({
reconciliationMode: vine.enum(['manual', 'auto'] as const),
})

View File

@ -0,0 +1,53 @@
import { BaseCommand, args } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import Organization from '#models/organization'
import { reconcileTransactionsForOrg } from '#services/banking/reconcile_transactions'
/**
* Lance la réconciliation transactions factures pour une organization
* donnée (ou la première trouvée si pas d'arg). Pratique en dev pour
* re-tester la logique de match sans devoir reconnecter la banque.
*
* Usage :
* node ace banking:reconcile # 1re org trouvée
* node ace banking:reconcile <orgId> # org spécifique
*/
export default class BankingReconcile extends BaseCommand {
static commandName = 'banking:reconcile'
static description = 'Relance la réconciliation banking pour une org'
static options: CommandOptions = {
startApp: true,
}
@args.string({
description: 'Organization id (UUID). Si absent, prend la 1re org avec une banque connectée.',
required: false,
})
declare orgId?: string
async run() {
let orgId = this.orgId
if (!orgId) {
const org = await Organization.query()
.whereNotNull('powensUserId')
.orderBy('createdAt', 'asc')
.first()
if (!org) {
this.logger.error('Aucune org avec un user Powens trouvée. Connecte une banque d\'abord.')
return
}
orgId = org.id
this.logger.info(`Org auto-sélectionnée : ${org.name || org.id} (${org.id})`)
}
const org = await Organization.findOrFail(orgId)
this.logger.info(`Mode de réconciliation : ${org.reconciliationMode}`)
const result = await reconcileTransactionsForOrg(orgId)
this.logger.success(
`Scanné : ${result.scanned} · Auto-confirmé : ${result.autoConfirmed} · Suggéré : ${result.suggested}`
)
}
}

View File

@ -0,0 +1,112 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { DateTime } from 'luxon'
import Invoice from '#models/invoice'
import BankAccount from '#models/bank_account'
import BankConnection from '#models/bank_connection'
import BankTransaction from '#models/bank_transaction'
import { reconcileTransactionsForOrg } from '#services/banking/reconcile_transactions'
/**
* Simule un virement bancaire entrant qui matche une facture donnée.
*
* Pourquoi : le sandbox Powens ne permet pas de "faire" de vrais virements
* (les comptes sont des fixtures avec des transactions pré-générées qui
* ne matchent pas les factures qu'on crée dans Rubis). Cette commande
* injecte directement une bank_transaction synthétique avec montant + label
* forgés pour matcher la facture, puis relance le reconcile.
*
* Réservé dev/test ne pas exposer en prod.
*
* Usage :
* node ace banking:simulate-payment <invoiceId>
* node ace banking:simulate-payment <invoiceId> --no-reconcile # juste insérer
*/
export default class BankingSimulatePayment extends BaseCommand {
static commandName = 'banking:simulate-payment'
static description = 'Injecte une transaction synthétique qui matche une facture (dev)'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Facture id (UUID) à simuler payée' })
declare invoiceId: string
@flags.boolean({
description: 'Lance reconcile après injection (default true)',
default: true,
})
declare reconcile: boolean
async run() {
const invoice = await Invoice.query()
.where('id', this.invoiceId)
.preload('client')
.first()
if (!invoice) {
this.logger.error(`Facture ${this.invoiceId} introuvable`)
return
}
this.logger.info(
`Facture trouvée : ${invoice.numero} · ${invoice.client.name} · ${(invoice.amountTtcCents / 100).toFixed(2)}`
)
// Récupère le 1er compte courant de la 1re connection active de l'org.
const connection = await BankConnection.query()
.where('organizationId', invoice.organizationId)
.where('state', 'active')
.orderBy('createdAt', 'desc')
.first()
if (!connection) {
this.logger.error('Aucune connection bancaire active sur cette org. Connecte une banque d\'abord.')
return
}
const account = await BankAccount.query()
.where('bankConnectionId', connection.id)
.where('type', 'checking')
.first()
if (!account) {
this.logger.error('Aucun compte de type "checking" sur la connexion. Aucun compte à matcher.')
return
}
// Label "réaliste" : style VIR SEPA + nom client + numéro de facture
// pour que le matching HIGH-confidence trigger (label contient numero
// ou nom client).
const label = `VIR SEPA ${invoice.client.name.toUpperCase()} REF ${invoice.numero}`
const tx = await BankTransaction.create({
bankAccountId: account.id,
powensId: BigInt(Date.now()) as any, // pseudo-id unique
amountCents: invoice.amountTtcCents, // crédit, montant exact
label,
wording: 'Virement reçu',
valueDate: DateTime.now(),
bookedAt: DateTime.now(),
raw: {
synthetic: true,
sourceCommand: 'banking:simulate-payment',
invoiceId: invoice.id,
},
matchStatus: 'unmatched',
})
this.logger.success(`Transaction synthétique créée : ${tx.id}`)
this.logger.info(` Compte : ${account.name}`)
this.logger.info(` Montant : ${(tx.amountCents / 100).toFixed(2)}`)
this.logger.info(` Label : ${tx.label}`)
if (this.reconcile) {
this.logger.info('---')
this.logger.info('Lancement du reconcile…')
const result = await reconcileTransactionsForOrg(invoice.organizationId)
this.logger.success(
`Scanné : ${result.scanned} · Auto-confirmé : ${result.autoConfirmed} · Suggéré : ${result.suggested}`
)
} else {
this.logger.info('Reconcile sauté (--no-reconcile). Lance `node ace banking:reconcile` quand prêt.')
}
}
}

View File

@ -0,0 +1,39 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Banking colonnes Powens & paramètres de réconciliation sur organizations.
*
* - powens_user_id : id entier renvoyé par POST /auth/init côté Powens.
* bigint parce que Powens fait grossir ses ids, et int4 PG arrive en
* butée à 2^31. Nullable jusqu'à la première connexion bancaire.
* - powens_token_encrypted : auth_token permanent du user Powens, chiffré
* via app/services/encryption (clé APP_KEY). Jamais en clair en DB ni
* en log. Format opaque (Adonis Encryption prepend version + IV).
* - reconciliation_mode : 'manual' (default) ou 'auto'.
* manual on suggère le match (badge "Probablement payée" + bouton
* confirmer/rejeter). Le user valide avant que la facture passe
* en paid et que les relances s'arrêtent.
* auto si match exact (montant + référence détectée dans le
* label), la facture passe paid + les relances stoppent sans
* intervention. Choix sensible (faux positif = pas de relance
* envoyée alors que la facture n'est pas payée), default manual.
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.bigInteger('powens_user_id').nullable().unique()
table.text('powens_token_encrypted').nullable()
table.string('reconciliation_mode', 10).notNullable().defaultTo('manual')
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('powens_user_id')
table.dropColumn('powens_token_encrypted')
table.dropColumn('reconciliation_mode')
})
}
}

View File

@ -0,0 +1,56 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Une bank_connection = un lien entre une organization Rubis et une
* banque (via Powens). V1 = une seule connection active par org, mais
* on autorise plusieurs lignes pour garder l'historique (connections
* supprimées passent en state='revoked' plutôt que d'être hard-deletées).
*
* - powens_connection_id : id entier renvoyé par Powens (cf. GET /users/me/connections).
* - state : 'active' (sync OK) | 'error' (échec sync) | 'wrongpass'
* (creds invalides) | 'sca_required' (re-auth nécessaire) |
* 'revoked' (user a déconnecté). On garde string libre pour ne pas
* avoir à migrer si Powens ajoute des states (cf. doc Powens
* connection.error).
* - last_sync_at / last_error : pour afficher "synchronisé il y a X
* min" + message d'erreur lisible en UI quand state active.
* - bank_logo_url : URL CDN Powens (https://www.<region>.biapi.pro/...).
* On la cache plutôt que de re-fetcher la connection à chaque
* affichage de la section banque dans /parametres.
*/
export default class extends BaseSchema {
protected tableName = 'bank_connections'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('organization_id')
.notNullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')
table.bigInteger('powens_connection_id').notNullable()
table.string('bank_name', 255).notNullable()
table.text('bank_logo_url').nullable()
table.string('state', 30).notNullable().defaultTo('active')
table.timestamp('last_sync_at').nullable()
table.text('last_error').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
// Une même connection Powens ne peut pas être bindée deux fois sur la
// même org (le callback fait un upsert sur ce couple).
table.unique(['organization_id', 'powens_connection_id'])
// Lookup principal : "liste les connections actives de cette org".
table.index(['organization_id', 'state'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,50 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Un bank_account = un compte rattaché à une connection (compte courant,
* livret A, etc.). Une connection Powens en remonte typiquement 1 à 5.
*
* - powens_account_id : id entier Powens (GET /users/me/connections/{id}/accounts).
* - iban : nullable parce que tous les comptes n'en ont pas exposé
* (livrets, comptes joints partiels, comptes pro avec restrictions DSP2).
* Quand présent, on l'affiche masqué en UI : FR76 **** **** **** 1234.
* - balance_cents : solde courant tel que vu au dernier sync (peut être
* négatif pour découverts). Indicatif uniquement, on ne s'appuie pas
* dessus pour la réconciliation.
* - type : 'checking' | 'savings' | 'card' | 'loan' | ... (string libre,
* valeurs Powens, cf. account.type).
*
* Pour la réconciliation, on ne matche que sur les comptes type
* 'checking' par défaut (filtré au niveau du job, pas en DB).
*/
export default class extends BaseSchema {
protected tableName = 'bank_accounts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('bank_connection_id')
.notNullable()
.references('id')
.inTable('bank_connections')
.onDelete('CASCADE')
table.bigInteger('powens_account_id').notNullable()
table.string('iban', 34).nullable()
table.string('name', 255).notNullable()
table.string('currency', 3).notNullable().defaultTo('EUR')
table.integer('balance_cents').nullable()
table.string('type', 30).nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.unique(['bank_connection_id', 'powens_account_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,76 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Une bank_transaction = une opération bancaire telle que vue par Powens.
* On stocke uniquement ce dont on a besoin pour la réconciliation +
* payload brut en jsonb pour debug / extensions futures sans re-sync.
*
* - powens_id : id Powens (bigint, peut être grand).
* - amount_cents : signé. Positif = crédit (virement entrant
* candidat à matcher avec une facture émise). Négatif = débit.
* - label : libellé brut tel que renvoyé par la banque (champ
* `original_wording` côté Powens).
* - wording : libellé enrichi/normalisé par Powens (catégorisation,
* nettoyage). Sert pour le matching en complément du label brut.
* - value_date : date de valeur (jour d'encaissement effectif).
* Différente de booked_at (timestamp comptable).
* - raw : payload jsonb complet pour pouvoir extraire un champ
* additionnel plus tard sans re-sync (counterparty IBAN, BIC,
* coordinates, custom flags).
* - matched_invoice_id : FK vers la facture matchée. SET NULL si la
* facture est supprimée pour ne pas perdre la transaction. Une
* transaction n'a qu'au plus une facture (11), une facture peut
* avoir 0 ou plusieurs transactions matchées (paiements partiels
* pas encore gérés en V1 mais le schéma le permet).
* - match_status : 'unmatched' (default, jamais analysé ou pas de
* candidat) | 'suggested' (job a proposé, en attente de validation
* user en mode manual) | 'confirmed' (validé) | 'rejected' (refusé
* explicitement par le user, on ne le re-suggère plus).
*
* Indexes :
* - (account, value_date desc) : feed paginé "dernières transactions"
* - (match_status) : le job de réconciliation tape les unmatched
* - (matched_invoice_id) : feed côté détail facture
*/
export default class extends BaseSchema {
protected tableName = 'bank_transactions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('bank_account_id')
.notNullable()
.references('id')
.inTable('bank_accounts')
.onDelete('CASCADE')
table.bigInteger('powens_id').notNullable()
table.integer('amount_cents').notNullable()
table.text('label').notNullable()
table.text('wording').nullable()
table.date('value_date').notNullable()
table.timestamp('booked_at').nullable()
table.jsonb('raw').notNullable()
table
.uuid('matched_invoice_id')
.nullable()
.references('id')
.inTable('invoices')
.onDelete('SET NULL')
table.string('match_status', 20).notNullable().defaultTo('unmatched')
table.timestamp('created_at').notNullable()
table.unique(['bank_account_id', 'powens_id'])
table.index(['bank_account_id', 'value_date'])
table.index(['match_status'])
table.index(['matched_invoice_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -17,7 +17,7 @@ export class ActivityEventSchema extends BaseModel {
@column({ isPrimary: true })
declare id: string
@column()
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' | 'thanks_email_sent'
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column()
declare label: string
@column()
@ -53,6 +53,85 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class BankAccountSchema extends BaseModel {
static $columns = ['balanceCents', 'bankConnectionId', 'createdAt', 'currency', 'iban', 'id', 'name', 'powensAccountId', 'type', 'updatedAt'] as const
$columns = BankAccountSchema.$columns
@column()
declare balanceCents: number | null
@column()
declare bankConnectionId: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare currency: string
@column()
declare iban: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare name: string
@column()
declare powensAccountId: bigint | number
@column()
declare type: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class BankConnectionSchema extends BaseModel {
static $columns = ['bankLogoUrl', 'bankName', 'createdAt', 'id', 'lastError', 'lastSyncAt', 'organizationId', 'powensConnectionId', 'state', 'updatedAt'] as const
$columns = BankConnectionSchema.$columns
@column()
declare bankLogoUrl: string | null
@column()
declare bankName: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare lastError: string | null
@column.dateTime()
declare lastSyncAt: DateTime | null
@column()
declare organizationId: string
@column()
declare powensConnectionId: bigint | number
@column()
declare state: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class BankTransactionSchema extends BaseModel {
static $columns = ['amountCents', 'bankAccountId', 'bookedAt', 'createdAt', 'id', 'label', 'matchStatus', 'matchedInvoiceId', 'powensId', 'raw', 'valueDate', 'wording'] as const
$columns = BankTransactionSchema.$columns
@column()
declare amountCents: number
@column()
declare bankAccountId: string
@column.dateTime()
declare bookedAt: DateTime | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare label: string
@column()
declare matchStatus: string
@column()
declare matchedInvoiceId: string | null
@column()
declare powensId: bigint | number
@column()
declare raw: any
@column.date()
declare valueDate: DateTime
@column()
declare wording: string | null
}
export class CheckinTaskSchema extends BaseModel {
static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const
$columns = CheckinTaskSchema.$columns
@ -218,11 +297,13 @@ export class InvoiceSchema extends BaseModel {
}
export class OrganizationSchema extends BaseModel {
static $columns = ['billingCycle', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const
static $columns = ['billingCycle', 'brandSettings', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'powensTokenEncrypted', 'powensUserId', 'reconciliationMode', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const
$columns = OrganizationSchema.$columns
@column()
declare billingCycle: string | null
@column()
declare brandSettings: { logoPath?: string | null; logoUrl?: string | null; senderName?: string | null; primaryColor?: string | null; bannerColor?: string | null; bodyBgColor?: string | null; cardBgColor?: string | null; textColor?: string | null; textMutedColor?: string | null; borderColor?: string | null; linkColor?: string | null; buttonTextColor?: string | null } | null | null
@column()
declare cancelAtPeriodEnd: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@ -245,6 +326,12 @@ export class OrganizationSchema extends BaseModel {
@column()
declare plan: string
@column()
declare powensTokenEncrypted: string | null
@column()
declare powensUserId: bigint | number | null
@column()
declare reconciliationMode: string
@column()
declare rubisCount: number
@column()
declare siret: string | null

View File

@ -103,4 +103,35 @@ export default await Env.create(new URL('../', import.meta.url), {
*/
SENTRY_DSN_API: Env.schema.string.optional(),
APP_VERSION: Env.schema.string.optional(),
/*
|----------------------------------------------------------
| Banking agrégation bancaire (lecture seule, AISP)
|----------------------------------------------------------
| V1 : un seul provider supporté (Powens). On garde les flags
| BANKING_ENABLED / BANKING_PROVIDER pour pouvoir kill-switch
| la feature en prod sans redéploiement de code et pour
| anticiper un éventuel multi-provider (Bridge, Tink).
|
| Flux Powens : on init un user Powens par organization, on
| génère un code temporaire, on ouvre la webview Powens, le
| user choisit sa banque, Powens redirige sur POWENS_REDIRECT_URI
| (qui pointe sur notre API), on stocke la connection.
|
| En dev : POWENS_REDIRECT_URI doit pointer sur un tunnel HTTPS
| (Cloudflare Quick Tunnel, ngrok, ) parce que Powens refuse
| http://. Voir /docs/tech/banking-setup.md.
|
| POWENS_DOMAIN = slug du domaine (ex : 'rubis-sandbox').
| POWENS_API_BASE_URL = URL complète optionnelle pour override
| (sinon calculée : https://<slug>.biapi.pro/2.0/).
*/
BANKING_ENABLED: Env.schema.boolean.optional(),
BANKING_PROVIDER: Env.schema.enum.optional(['powens'] as const),
POWENS_DOMAIN: Env.schema.string.optional(),
POWENS_API_BASE_URL: Env.schema.string.optional({ format: 'url', tld: false }),
POWENS_CLIENT_ID: Env.schema.string.optional(),
POWENS_CLIENT_SECRET: Env.schema.secret.optional(),
POWENS_REDIRECT_URI: Env.schema.string.optional({ format: 'url', tld: false }),
POWENS_WEBHOOK_SECRET: Env.schema.secret.optional(),
})

View File

@ -50,4 +50,5 @@ export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
admin: () => import('#middleware/admin_middleware'),
assertBusinessPlan: () => import('#middleware/assert_business_plan_middleware'),
assertPaidPlan: () => import('#middleware/assert_paid_plan_middleware'),
})

View File

@ -21,6 +21,8 @@ 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')
router
@ -78,6 +80,36 @@ router
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')
@ -206,6 +238,30 @@ router
.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.
*/

View File

@ -0,0 +1,367 @@
import { useEffect } from "react";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import {
Banknote,
ArrowRight,
Lock,
Loader2,
Trash2,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { Button, Card, Chip } from "@rubis/ui";
import { useSubscription } from "@/lib/billing";
import {
useBankConnections,
useBankingSettings,
useBankingStatus,
useDisconnectBank,
useInitBanking,
useUpdateBankingSettings,
type BankConnection,
type BankingReconciliationMode,
} from "@/lib/banking";
/**
* BankingSection carte Banque dans /parametres.
*
* 3 états visuels :
* - Free : upsell card (CTA "Passer au plan Pro")
* - Pro/Business + 0 banque : carte vide avec CTA "Connecter une banque"
* - Pro/Business + 1 banque : carte connectée (logo, accounts, bouton
* déconnecter, toggle mode de réconciliation)
*
* Détection ?banking=connected|error dans l'URL : affiche un toast et
* nettoie l'URL (replace, pas de history entry). Évite le toast en
* boucle si on F5.
*/
type BankingSectionProps = {
/** Params lus depuis la route parent (cf. parametres.tsx → searchSchema). */
callbackStatus?: "connected" | "error";
callbackReason?: string;
};
export function BankingSection({
callbackStatus,
callbackReason,
}: BankingSectionProps) {
const { data: status } = useBankingStatus();
const { data: sub } = useSubscription();
const isPaid = sub?.plan === "pro" || sub?.plan === "business";
const navigate = useNavigate();
const connectionsQuery = useBankConnections({
enabled: status?.enabled === true && isPaid,
});
// Toast post-callback : on lit la search bag et on nettoie l'URL.
useEffect(() => {
if (!callbackStatus) return;
if (callbackStatus === "connected") {
toast.success("Banque connectée. Récupération des comptes en cours.");
void connectionsQuery.refetch();
} else {
toast.error(
callbackReason
? `Connexion impossible (${callbackReason}). Réessayez.`
: "Connexion impossible. Réessayez.",
);
}
// Nettoie l'URL pour ne pas re-déclencher le toast au refresh / nav.
void navigate({
to: "/parametres",
search: {},
replace: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callbackStatus, callbackReason]);
if (!isPaid) {
return <UpsellCard />;
}
return (
<BankingPaidView
isLoading={connectionsQuery.isLoading}
connections={connectionsQuery.data ?? []}
/>
);
}
// --------------------------------------------------------------------
// Upsell (Free) — visuel cohérent avec la section Marque (Business)
// --------------------------------------------------------------------
function UpsellCard() {
return (
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-start gap-3">
<div className="rounded-full bg-rubis-glow p-2 text-rubis">
<Lock size={16} aria-hidden="true" />
</div>
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Plan Pro ou Business requis
</p>
<p className="mt-1 text-[14px] text-ink-2 leading-snug max-w-[420px]">
Connectez votre banque pour détecter automatiquement les factures
payées et arrêter les relances inutiles.
</p>
</div>
</div>
<Button size="sm" variant="primary" asChild>
<a href="/parametres/abonnement">
Passer au plan Pro
<ArrowRight size={13} aria-hidden="true" />
</a>
</Button>
</Card>
);
}
// --------------------------------------------------------------------
// Vue Pro/Business
// --------------------------------------------------------------------
function BankingPaidView({
isLoading,
connections,
}: {
isLoading: boolean;
connections: BankConnection[];
}) {
const active = connections.find((c) => c.state !== "revoked");
if (isLoading) {
return (
<Card padding="md" className="flex items-center gap-2 text-ink-3 text-[13px]">
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
Chargement
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{active ? (
<ConnectedBankCard connection={active} />
) : (
<ConnectCta />
)}
<ReconciliationModeToggle />
</div>
);
}
// --------------------------------------------------------------------
// CTA "Connecter une banque"
// --------------------------------------------------------------------
function ConnectCta() {
const initMutation = useInitBanking();
const onConnect = async () => {
try {
const { webviewUrl } = await initMutation.mutateAsync();
// Full nav vers Powens — pas un popup, parce que le retour fait
// un redirect 302 via le tunnel API.
window.location.href = webviewUrl;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Connexion impossible. Réessayez.";
toast.error(msg);
}
};
return (
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-start gap-3">
<div className="rounded-full bg-rubis-glow p-2 text-rubis">
<Banknote size={16} aria-hidden="true" />
</div>
<div>
<p className="font-display text-[16px] font-semibold text-ink">
Aucune banque connectée
</p>
<p className="mt-1 text-[13px] text-ink-3 leading-snug max-w-[420px]">
Rubis lit vos virements entrants pour détecter les factures payées.
Lecture seule. Aucun déplacement de fonds.
</p>
</div>
</div>
<Button size="sm" variant="primary" onClick={onConnect} loading={initMutation.isPending}>
<Banknote size={14} aria-hidden="true" />
Connecter une banque
</Button>
</Card>
);
}
// --------------------------------------------------------------------
// Carte "Banque connectée"
// --------------------------------------------------------------------
function ConnectedBankCard({ connection }: { connection: BankConnection }) {
const disconnect = useDisconnectBank();
const onDisconnect = async () => {
if (!window.confirm(`Déconnecter ${connection.bankName} ?`)) return;
try {
await disconnect.mutateAsync(connection.id);
toast.success("Banque déconnectée.");
} catch {
toast.error("Déconnexion impossible. Réessayez.");
}
};
return (
<Card padding="md" className="flex flex-col gap-4">
<header className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="rounded-full bg-rubis-glow p-2 text-rubis">
<Banknote size={18} aria-hidden="true" />
</div>
<div>
<p className="font-display text-[16px] font-semibold text-ink">
{connection.bankName}
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-ink-3">
<StateBadge state={connection.state} />
{connection.lastSyncAt && (
<span>
· synchronisé {formatRelative(connection.lastSyncAt)}
</span>
)}
</p>
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={onDisconnect}
loading={disconnect.isPending}
>
<Trash2 size={14} aria-hidden="true" />
Déconnecter
</Button>
</header>
{connection.lastError && (
<div className="rounded-md border border-rubis-deep/20 bg-rubis-glow/30 px-3 py-2 text-[12.5px] text-rubis-deep">
{connection.lastError}
</div>
)}
<ul className="flex flex-col divide-y divide-ink/5">
{connection.accounts.length === 0 ? (
<li className="py-2 text-[13px] text-ink-3">
Aucun compte récupéré pour cette banque.
</li>
) : (
connection.accounts.map((a) => (
<li
key={a.id}
className="py-2.5 flex items-center justify-between gap-3 text-[13px]"
>
<div>
<p className="font-medium text-ink">{a.name}</p>
{a.ibanMasked && (
<p className="font-mono text-[11.5px] text-ink-3 tracking-tight">
{a.ibanMasked}
</p>
)}
</div>
{a.balanceCents !== null && (
<p className="font-mono text-[12.5px] text-ink-2">
{(a.balanceCents / 100).toLocaleString("fr-FR", {
style: "currency",
currency: a.currency,
})}
</p>
)}
</li>
))
)}
</ul>
</Card>
);
}
function StateBadge({ state }: { state: string }) {
if (state === "active") {
return (
<span className="inline-flex items-center gap-1 text-rubis">
<CheckCircle2 size={11} aria-hidden="true" />
Actif
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-rubis-deep">
<AlertCircle size={11} aria-hidden="true" />
{state}
</span>
);
}
// --------------------------------------------------------------------
// Toggle mode de réconciliation
// --------------------------------------------------------------------
function ReconciliationModeToggle() {
const { data, isLoading } = useBankingSettings();
const mutate = useUpdateBankingSettings();
if (isLoading || !data) return null;
const current = data.reconciliationMode;
const setMode = (mode: BankingReconciliationMode) => {
if (mode === current) return;
void mutate.mutateAsync(mode).then(
() => toast.success(mode === "auto" ? "Mode automatique activé." : "Mode manuel activé."),
() => toast.error("Mise à jour impossible. Réessayez."),
);
};
return (
<Card padding="md" className="flex flex-col gap-3">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Réconciliation
</p>
<p className="mt-1 text-[13px] text-ink-2 leading-snug">
Quand un virement matche une facture en attente, Rubis peut soit
vous suggérer le match, soit la marquer payée automatiquement.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Chip selected={current === "manual"} onClick={() => setMode("manual")}>
Manuelle je valide chaque match
</Chip>
<Chip selected={current === "auto"} onClick={() => setMode("auto")}>
Automatique match exact = facture payée
</Chip>
</div>
</Card>
);
}
// --------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const min = Math.round(diff / 60_000);
if (min < 1) return "à l'instant";
if (min < 60) return `il y a ${min} min`;
const h = Math.round(min / 60);
if (h < 24) return `il y a ${h} h`;
const d = Math.round(h / 24);
return `il y a ${d} j`;
}

115
apps/web/src/lib/banking.ts Normal file
View File

@ -0,0 +1,115 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
/**
* Hooks TanStack Query pour le module Banking (Powens). Convention
* cohérente avec lib/billing.ts : un hook par endpoint, invalidations
* ciblées dans les onSuccess.
*/
export type BankingReconciliationMode = "manual" | "auto";
export type BankAccount = {
id: string;
name: string;
type: string | null;
currency: string;
balanceCents: number | null;
/** IBAN masqué : "FR76 **** **** **** 1234". Null si pas exposé par la banque. */
ibanMasked: string | null;
};
export type BankConnection = {
id: string;
bankName: string;
bankLogoUrl: string | null;
/** 'active' | 'error' | 'wrongpass' | 'sca_required' | 'revoked' */
state: string;
lastSyncAt: string | null;
lastError: string | null;
createdAt: string;
accounts: BankAccount[];
};
export type BankingSettings = {
reconciliationMode: BankingReconciliationMode;
};
/**
* Kill switch banking. Lu au mount de la section banque dans /parametres
* pour décider de l'afficher ou pas. Indépendant de l'auth c'est un
* flag d'env (BANKING_ENABLED côté API) + check des credentials Powens.
*
* En prod on déploie souvent avec banking OFF tant que le KYC Powens
* n'est pas finalisé. Quand on l'active, on roll un manifest K3s et le
* flag bascule.
*/
export function useBankingStatus() {
return useQuery({
queryKey: ["banking", "status"] as const,
queryFn: () => api.get<{ enabled: boolean }>("/api/v1/banking/status"),
// Le flag bouge rarement — long cache OK.
staleTime: 5 * 60_000,
});
}
/**
* Liste les banques connectées (V1 = 0 ou 1 active).
* `enabled` permet de skipper le fetch tant qu'on n'a pas confirmé que
* le kill switch banking est ON (sinon /banking/connections renverrait
* 503 et polluerait les logs avec des erreurs prévisibles).
*/
export function useBankConnections(opts?: { enabled?: boolean }) {
return useQuery({
queryKey: ["banking", "connections"] as const,
queryFn: () => api.get<BankConnection[]>("/api/v1/banking/connections"),
staleTime: 10_000,
enabled: opts?.enabled ?? true,
});
}
/**
* Lance le flow webview Powens. Renvoie l'URL le caller doit faire
* `window.location.href = url` pour quitter le SPA vers Powens. Pas de
* popup : le retour passe par le tunnel API 302 SPA, donc on a
* besoin d'un full navigation, pas d'un postMessage.
*/
export function useInitBanking() {
return useMutation({
mutationFn: () =>
api.post<{ webviewUrl: string }>("/api/v1/banking/powens/init"),
});
}
/** Déconnecte une banque. Idempotent côté serveur (soft-revoke en DB). */
export function useDisconnectBank() {
const qc = useQueryClient();
return useMutation({
mutationFn: (connectionId: string) =>
api.delete<void>(`/api/v1/banking/connections/${connectionId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["banking", "connections"] });
},
});
}
export function useBankingSettings() {
return useQuery({
queryKey: ["banking", "settings"] as const,
queryFn: () => api.get<BankingSettings>("/api/v1/banking/settings"),
staleTime: 60_000,
});
}
export function useUpdateBankingSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (mode: BankingReconciliationMode) =>
api.patch<BankingSettings>("/api/v1/banking/settings", {
reconciliationMode: mode,
}),
onSuccess: (data) => {
qc.setQueryData(["banking", "settings"], data);
},
});
}

View File

@ -1,17 +1,32 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowRight, CreditCard, Palette } from "lucide-react";
import { z } from "zod";
import { SettingsSection } from "@/components/settings/SettingsSection";
import { AccountForm } from "@/components/settings/AccountForm";
import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { BankingSection } from "@/components/settings/BankingSection";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
import { Button } from "@rubis/ui";
import { Card } from "@rubis/ui";
import { useSubscription } from "@/lib/billing";
import { useBankingStatus } from "@/lib/banking";
/**
* Search params optionnels :
* - `?banking=connected` toast succès après retour de la webview Powens
* - `?banking=error&reason=...` toast d'erreur ; raisons typées dans
* apps/api/app/controllers/banking_controller.ts (callback).
*/
const searchSchema = z.object({
banking: z.enum(["connected", "error"]).optional(),
reason: z.string().optional(),
});
export const Route = createFileRoute("/_app/parametres")({
validateSearch: searchSchema,
component: ParametresPage,
});
@ -30,6 +45,11 @@ export const Route = createFileRoute("/_app/parametres")({
function ParametresPage() {
const { data: sub } = useSubscription();
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
const search = Route.useSearch();
// Kill switch banking : la section ENTIÈRE disparaît si BANKING_ENABLED=false
// côté API (typiquement en prod tant que le KYC Powens n'est pas validé).
const { data: bankingStatus } = useBankingStatus();
const showBanking = bankingStatus?.enabled === true;
return (
<div className="flex flex-col gap-2">
@ -134,6 +154,23 @@ function ParametresPage() {
</Card>
</SettingsSection>
{showBanking && (
<SettingsSection
eyebrow="Banque"
title={
<>
Connecter votre <em className="text-rubis">banque</em>
</>
}
description="Rubis lit vos virements entrants pour détecter automatiquement les factures payées. Lecture seule, aucun déplacement de fonds. Disponible sur les plans Pro et Business."
>
<BankingSection
callbackStatus={search.banking}
callbackReason={search.reason}
/>
</SettingsSection>
)}
<SettingsSection
eyebrow="Démonstration"
title={

View File

@ -0,0 +1,161 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { z } from "zod";
import { ArrowLeft, ArrowRight, Banknote, CheckCircle2, ShieldCheck } from "lucide-react";
import { Button, Card, Eyebrow, Gem } from "@rubis/ui";
import { useBankConnections } from "@/lib/banking";
/**
* Page de succès post-connection bancaire. Le backend redirige ici en
* 302 après que la webview Powens ait validé et que l'upsert DB soit
* terminé. Affiche une confirmation visuelle + récap des comptes
* synchronisés.
*
* On lit les détails de la connection via /banking/connections plutôt
* que de tout passer en query params : la query string ne sert qu'à
* confirmer qu'on arrive bien d'un callback légitime (le SPA pourrait
* être bookmarké, on ne veut pas afficher de fausse success).
*/
const searchSchema = z.object({
bank: z.string().optional(),
connectionId: z.string().uuid().optional(),
});
export const Route = createFileRoute("/_app/parametres_/banque/success")({
validateSearch: searchSchema,
component: BankingSuccessPage,
});
function BankingSuccessPage() {
const { bank, connectionId } = Route.useSearch();
const { data: connections, isLoading } = useBankConnections();
const connection = connectionId
? connections?.find((c) => c.id === connectionId)
: connections?.find((c) => c.bankName === bank && c.state !== "revoked");
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col gap-6">
<Link
to="/parametres"
className="inline-flex items-center gap-1 self-start text-[13px] text-ink-3 hover:text-ink transition-colors"
>
<ArrowLeft size={14} aria-hidden="true" />
Retour aux paramètres
</Link>
<Card padding="lg" className="flex flex-col items-center gap-5 text-center">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-rubis-glow blur-2xl opacity-60" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-full bg-rubis-glow text-rubis-deep">
<CheckCircle2 size={32} aria-hidden="true" strokeWidth={2.5} />
</div>
</div>
<div>
<Eyebrow tone="rubis">Banque connectée</Eyebrow>
<h1 className="mt-2 font-display text-[32px] font-bold tracking-[-0.022em] text-ink">
Votre banque est{" "}
<em className="text-rubis">en place</em>
</h1>
<p className="mt-3 text-[15px] text-ink-2 leading-relaxed max-w-[480px]">
Rubis va maintenant détecter automatiquement vos virements
entrants et arrêter les relances dès qu&apos;une facture est
payée.
</p>
</div>
{isLoading ? (
<p className="text-[13px] text-ink-3">Chargement des comptes</p>
) : connection ? (
<ConnectionRecap
bankName={connection.bankName}
accounts={connection.accounts}
/>
) : bank ? (
<p className="text-[13px] text-ink-3">
<strong className="text-ink-2">{bank}</strong> est connectée. Les
comptes apparaîtront dans quelques instants.
</p>
) : null}
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<Button asChild variant="primary" size="md">
<Link to="/parametres">
Voir mes paramètres
<ArrowRight size={14} aria-hidden="true" />
</Link>
</Button>
<Button asChild variant="secondary" size="md">
<Link to="/">
<Gem size={14} aria-hidden="true" />
Retour au tableau de bord
</Link>
</Button>
</div>
</Card>
<Card padding="md" className="flex items-start gap-3">
<div className="rounded-full bg-rubis-glow p-2 text-rubis shrink-0 mt-0.5">
<ShieldCheck size={16} aria-hidden="true" />
</div>
<div>
<p className="font-display text-[14px] font-semibold text-ink">
Lecture seule. Aucun déplacement de fonds.
</p>
<p className="mt-1 text-[12.5px] text-ink-3 leading-relaxed">
Rubis lit uniquement vos virements entrants pour détecter les
paiements. Vous pouvez déconnecter cette banque à tout moment
depuis vos paramètres. Un email de confirmation vient de vous
être envoyé.
</p>
</div>
</Card>
</div>
);
}
function ConnectionRecap({
bankName,
accounts,
}: {
bankName: string;
accounts: { id: string; name: string; ibanMasked: string | null; balanceCents: number | null; currency: string }[];
}) {
return (
<div className="w-full rounded-card border border-line bg-cream/50 px-5 py-4 text-left">
<p className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
<Banknote size={12} aria-hidden="true" />
Comptes synchronisés
</p>
<p className="mt-1.5 font-display text-[17px] font-bold text-ink">
{bankName}
</p>
<ul className="mt-3 flex flex-col divide-y divide-ink/5">
{accounts.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 py-2 text-[13px]"
>
<div>
<p className="font-medium text-ink">{a.name}</p>
{a.ibanMasked && (
<p className="font-mono text-[11.5px] text-ink-3 tracking-tight">
{a.ibanMasked}
</p>
)}
</div>
{a.balanceCents !== null && (
<p className="font-mono text-[12.5px] text-ink-2 shrink-0">
{(a.balanceCents / 100).toLocaleString("fr-FR", {
style: "currency",
currency: a.currency,
})}
</p>
)}
</li>
))}
</ul>
</div>
);
}

144
docs/tech/banking-setup.md Normal file
View File

@ -0,0 +1,144 @@
# Banking setup — Powens (AISP) en dev local
> Version : 0.1 · 2026-05-12
L'intégration banking utilise [Powens](https://www.powens.com/) (ex-Budget Insight) comme **agrégateur AISP** (Account Information Service Provider). On lit les transactions des comptes pros de l'user pour détecter les virements entrants et matcher avec les factures émises. **Lecture seule** — Rubis ne déplace jamais d'argent.
## Architecture du flux
```
[Front /parametres]
│ POST /api/v1/banking/powens/init
[API] crée/récupère le user Powens de l'org
génère un code temporaire
renvoie webviewUrl
[Powens webview] user choisit sa banque + se logue
▼ redirige sur POWENS_REDIRECT_URI
[API] /api/v1/banking/powens/callback
valide le state HMAC
fetch la connection, accounts, transactions
302 vers WEB_URL/parametres/banque?banking=connected
[Front] poll /banking/connections, affiche la banque connectée
```
Webhook Powens (`POST /api/v1/webhooks/powens`) garde les transactions fraîches sans cron lourd.
## Pourquoi un tunnel HTTPS en dev
Powens **refuse** les redirect_uri en `http://` ou sur `localhost`. Il faut une URL HTTPS publique whitelistée dans la console Powens. En dev local, on monte un tunnel **Cloudflare Quick Tunnel** (gratuit, sans compte, sans config DNS).
## 1. Compte Powens sandbox
1. Crée un compte sur https://console.powens.com/
2. Demande un domaine **sandbox** (gratuit, banques fakes, pas de SCA réelle).
3. Récupère :
- `client_id` (entier)
- `client_secret` (string)
- Le **slug** du domaine (ex : `rubis-sandbox`). L'API tape sur `https://<slug>.biapi.pro/2.0/`.
4. Génère un **webhook secret** dans la console (sera utilisé pour la vérif HMAC).
Renseigne dans `apps/api/.env` :
```bash
BANKING_ENABLED=true
BANKING_PROVIDER=powens
POWENS_DOMAIN=rubis-sandbox
POWENS_API_BASE_URL=https://rubis-sandbox.biapi.pro/2.0/
POWENS_CLIENT_ID=...
POWENS_CLIENT_SECRET=...
POWENS_WEBHOOK_SECRET=...
```
## 2. Tunnel HTTPS — Cloudflare Quick Tunnel
### Installer `cloudflared`
```bash
brew install cloudflared # macOS
# ou : https://github.com/cloudflare/cloudflared/releases
```
### Lancer le tunnel sur l'API Adonis (port 3333)
```bash
cloudflared tunnel --url http://localhost:3333
```
Sortie attendue :
```
+--------------------------------------------------------------------------------------------+
| Your quick Tunnel has been created! Visit it at (it may take a few moments to be reachable): |
| https://random-name-here.trycloudflare.com |
+--------------------------------------------------------------------------------------------+
```
Copie cette URL.
### Renseigner `POWENS_REDIRECT_URI`
Dans `apps/api/.env` :
```bash
POWENS_REDIRECT_URI=https://random-name-here.trycloudflare.com/api/v1/banking/powens/callback
```
Redémarre `pnpm dev:api` pour prendre la nouvelle valeur.
### Whitelister l'URL côté Powens
Console Powens → ton appli → **Allowed redirect URIs** → ajoute :
```
https://random-name-here.trycloudflare.com/api/v1/banking/powens/callback
```
Et l'URL prod (à ajouter dès qu'on déploie) :
```
https://app.rubis.pro/api/v1/banking/powens/callback
```
### Whitelister l'URL webhook
Même console → **Webhooks** → ajoute :
```
https://random-name-here.trycloudflare.com/api/v1/webhooks/powens
```
> ⚠️ L'URL Cloudflare Quick Tunnel **change à chaque restart** du `cloudflared`. Faut re-whitelister et update le `.env` à chaque session. Pour du long terme, prendre un ngrok payant (~10$/mois, URL stable) ou monter un Cloudflare Tunnel nommé avec un domaine perso.
## 3. Tester de bout en bout (sandbox)
1. SPA → `/parametres` → section **Banque** → clic **Connecter une banque**.
2. Webview Powens s'ouvre → choisir **Connecteur de test** (banque sandbox).
3. Credentials sandbox standards : login `1234567`, password `123456` (à confirmer sur la doc Powens du moment).
4. Validation → Powens redirige vers ton tunnel → API enregistre la connexion → 302 vers `/parametres/banque?banking=connected`.
5. Vérifier que `bank_connections` a une ligne `state='active'` et que `bank_accounts` contient les comptes du connecteur de test.
## 4. Prod
- Domaine Powens prod (KYC à valider).
- `POWENS_REDIRECT_URI=https://app.rubis.pro/api/v1/banking/powens/callback`.
- `POWENS_API_BASE_URL=https://<slug-prod>.biapi.pro/2.0/`.
- Webhook URL whitelistée : `https://app.rubis.pro/api/v1/webhooks/powens`.
## 5. Gating plan
Le module banking est **réservé aux plans Pro et Business**. Le middleware `requirePaidPlan` sur les routes `/banking/*` renvoie `403` pour les comptes Free. La section banking dans la SPA affiche un upsell pour les Free.
## 6. Sécurité — résumé
- `powens_token` chiffré au repos via `app/services/encryption` (clé `APP_KEY`).
- State HMAC signé sur la webview, vérifié au callback (anti-CSRF).
- Webhook HMAC (`POWENS_WEBHOOK_SECRET`) vérifié avant tout traitement.
- Scope par org : un `connection_id` est toujours filtré par `auth.user.organizationId` (anti-IDOR).
- IBAN masqué dans les logs (`FR76 **** **** **** 1234`).
- Rate limit sur `/banking/powens/init` (anti-abuse).

View File

@ -143,3 +143,20 @@ data:
# EXACTEMENT le redirect URI configuré côté Azure App registration.
MICROSOFT_TENANT: 'common'
MICROSOFT_CALLBACK_URL: 'https://app.rubis.pro/api/v1/auth/microsoft/callback'
# Banking / Powens (AISP, lecture seule).
# - BANKING_ENABLED=false par défaut : la section banque dans /parametres
# reste invisible et /api/v1/banking/* renvoie 503 tant qu'on n'a pas
# bouclé le KYC Powens prod. Flip à 'true' une fois les creds en
# place et testés.
# - POWENS_DOMAIN : slug du domaine Powens prod (ex 'rubis' →
# 'rubis.biapi.pro'). À remplacer par le vrai slug après KYC.
# - POWENS_CLIENT_ID / POWENS_CLIENT_SECRET / POWENS_WEBHOOK_SECRET
# sont dans rubis-app-secrets (cf. deploy-memory.md).
# - POWENS_REDIRECT_URI doit matcher EXACTEMENT ce qui est whitelisté
# côté console Powens prod.
BANKING_ENABLED: 'false'
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'