feat(banking): intégration Powens AISP + auto-réconciliation factures
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).
Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
webview URL, list/get/delete connections, accounts, transactions,
vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
(TTL 10 min), handleCallback (upsert connection + accounts +
fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
+ label normalisé (numero ou nom client, NFD strip + alphanum).
Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
HIGH → invoice.status=paid + bonus rubis + cancel relances +
enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
(user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
/ SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
+ colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
(notif user après match auto, lien direct facture + libellé
bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
reconnecter la banque) et banking:simulate-payment (injecte une
bank_transaction synthétique qui matche une facture, pour test E2E
sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
credentials Powens. Endpoint public GET /banking/status renvoie
{ enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
rapporte désormais la vraie colonne en faute (numero/slug/…) avec
message lisible « Le numéro de facture "F2026-0013" existe déjà »,
au lieu d'un message ambigu sur organization_id.
Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
switch + plan Pro/Business. Si Free → upsell card avec CTA vers
/parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
une banque". Si banque connectée → carte avec accounts (IBAN
masqué FR76 **** **** **** 1234), solde, last sync, bouton
Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
avec badge ✓ animé + halo glow rubis, récap des comptes
synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
{ enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
useUpdateBankingSettings.
Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
via kubectl patch, à remplacer post-KYC).
Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
(anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
powens_account_id), (account, powens_id) → rejouer un event ou un
callback ne pose pas de problème.
Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
flip flag → smoke test), snippet kubectl patch pour rotation
ciblée de secrets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c590b489ef
commit
51217175ad
@ -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.
|
||||
|
||||
@ -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=
|
||||
|
||||
234
apps/api/app/controllers/banking_controller.ts
Normal file
234
apps/api/app/controllers/banking_controller.ts
Normal 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 où on veut
|
||||
* propager les params). Pour CE callback c'est l'inverse : on ne
|
||||
* veut PAS que les `?connection_id=&state=` de Powens collent au
|
||||
* redirect (sinon URL malformée). On utilise donc la forme chainable
|
||||
* `.withQs(false).toPath(...)` pour désactiver le forward sur ce
|
||||
* redirect uniquement.
|
||||
*
|
||||
* Au succès → page dédiée `/parametres/banque/success` (UX :
|
||||
* confirmation visuelle + récap, plutôt que retour discret avec toast).
|
||||
* À l'erreur → retour sur `/parametres?banking=error&reason=...` avec
|
||||
* toast d'erreur (pas de page dédiée erreur, un toast suffit).
|
||||
*/
|
||||
const redirectToError = (reason: string) => {
|
||||
const url = new URL('/parametres', webUrl)
|
||||
url.searchParams.set('banking', 'error')
|
||||
url.searchParams.set('reason', reason)
|
||||
return response.redirect().withQs(false).toPath(url.toString())
|
||||
}
|
||||
const redirectToSuccess = (params: { bank: string; connectionId: string }) => {
|
||||
const url = new URL('/parametres/banque/success', webUrl)
|
||||
url.searchParams.set('bank', params.bank)
|
||||
url.searchParams.set('connectionId', params.connectionId)
|
||||
return response.redirect().withQs(false).toPath(url.toString())
|
||||
}
|
||||
|
||||
const connectionIdRaw = request.input('connection_id')
|
||||
const state = request.input('state')
|
||||
|
||||
if (!connectionIdRaw || !state) {
|
||||
return redirectToError('missing_params')
|
||||
}
|
||||
const connectionId = Number(connectionIdRaw)
|
||||
if (!Number.isFinite(connectionId)) {
|
||||
return redirectToError('bad_connection_id')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleCallback({ connectionId, state: String(state) })
|
||||
// TODO commit 5 : enqueue SyncPowensConnection job pour fetch les transactions.
|
||||
logger.info(
|
||||
{ orgId: result.orgId, connectionId: result.connectionId, bank: result.bankName },
|
||||
'banking.callback.success'
|
||||
)
|
||||
return redirectToSuccess({
|
||||
bank: result.bankName,
|
||||
connectionId: result.connectionId,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'banking.callback.failed')
|
||||
const reason = err instanceof BankingStateError ? 'invalid_state' : 'callback_failed'
|
||||
return redirectToError(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /banking/connections — liste des banques connectées (avec accounts).
|
||||
*/
|
||||
async index({ auth, response }: HttpContext) {
|
||||
const orgId = requireOrgId(auth)
|
||||
const connections = await listConnectionsForOrg(orgId)
|
||||
return response.json({
|
||||
data: connections.map((c) => new BankConnectionTransformer(c).toObject()),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /banking/connections/:id — déconnecte une banque.
|
||||
* Idempotent : si la connection est déjà 'revoked', on renvoie 204
|
||||
* sans rejouer le DELETE Powens.
|
||||
*/
|
||||
async destroy({ auth, params, response }: HttpContext) {
|
||||
const orgId = requireOrgId(auth)
|
||||
await disconnect(orgId, String(params.id))
|
||||
return response.noContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /banking/settings — toggle reconciliation mode (manual/auto).
|
||||
* Retourne l'org mise à jour (juste le champ qui nous intéresse, le
|
||||
* front n'a pas besoin de re-fetch /organizations/me).
|
||||
*/
|
||||
async updateSettings({ auth, request, response }: HttpContext) {
|
||||
const orgId = requireOrgId(auth)
|
||||
const payload = await request.validateUsing(updateBankingSettingsValidator)
|
||||
const org = await setReconciliationMode(orgId, payload.reconciliationMode)
|
||||
return response.json({
|
||||
data: { reconciliationMode: org.reconciliationMode },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /banking/settings — lit le mode courant. Retourné séparément
|
||||
* de /organizations/me pour ne pas exposer powens_user_id et autres
|
||||
* champs internes côté front.
|
||||
*/
|
||||
async showSettings({ auth, response }: HttpContext) {
|
||||
const orgId = requireOrgId(auth)
|
||||
const org = await Organization.findOrFail(orgId)
|
||||
return response.json({
|
||||
data: { reconciliationMode: org.reconciliationMode },
|
||||
})
|
||||
}
|
||||
}
|
||||
223
apps/api/app/controllers/webhooks_powens_controller.ts
Normal file
223
apps/api/app/controllers/webhooks_powens_controller.ts
Normal 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()
|
||||
}
|
||||
@ -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}`
|
||||
}
|
||||
|
||||
229
apps/api/app/mails/bank_connected_email.tsx
Normal file
229
apps/api/app/mails/bank_connected_email.tsx
Normal 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'une facture est payée.
|
||||
</Text>
|
||||
|
||||
<Text style={greetingStyle}>
|
||||
Bonjour {firstName || 'et bienvenue'},
|
||||
</Text>
|
||||
<Text style={greetingStyle}>
|
||||
Bonne nouvelle : <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 ?</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>
|
||||
)
|
||||
}
|
||||
239
apps/api/app/mails/invoice_auto_paid_notification_email.tsx
Normal file
239
apps/api/app/mails/invoice_auto_paid_notification_email.tsx
Normal 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-là 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'ê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 été géré : 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'est
|
||||
10 minutes que vous n'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é :</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>
|
||||
)
|
||||
}
|
||||
35
apps/api/app/middleware/assert_paid_plan_middleware.ts
Normal file
35
apps/api/app/middleware/assert_paid_plan_middleware.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
13
apps/api/app/models/bank_account.ts
Normal file
13
apps/api/app/models/bank_account.ts
Normal 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>
|
||||
}
|
||||
13
apps/api/app/models/bank_connection.ts
Normal file
13
apps/api/app/models/bank_connection.ts
Normal 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>
|
||||
}
|
||||
13
apps/api/app/models/bank_transaction.ts
Normal file
13
apps/api/app/models/bank_transaction.ts
Normal 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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
413
apps/api/app/services/banking/banking_service.ts
Normal file
413
apps/api/app/services/banking/banking_service.ts
Normal 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
|
||||
}
|
||||
365
apps/api/app/services/banking/powens_client.ts
Normal file
365
apps/api/app/services/banking/powens_client.ts
Normal 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 où
|
||||
* 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 }
|
||||
312
apps/api/app/services/banking/reconcile_transactions.ts
Normal file
312
apps/api/app/services/banking/reconcile_transactions.ts
Normal 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à été 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
|
||||
113
apps/api/app/services/banking/sync_connection.ts
Normal file
113
apps/api/app/services/banking/sync_connection.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/api/app/transformers/bank_connection_transformer.ts
Normal file
50
apps/api/app/transformers/bank_connection_transformer.ts
Normal 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}`
|
||||
}
|
||||
11
apps/api/app/validators/banking.ts
Normal file
11
apps/api/app/validators/banking.ts
Normal 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),
|
||||
})
|
||||
53
apps/api/commands/banking_reconcile.ts
Normal file
53
apps/api/commands/banking_reconcile.ts
Normal 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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
112
apps/api/commands/banking_simulate_payment.ts
Normal file
112
apps/api/commands/banking_simulate_payment.ts
Normal 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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 (1↔1), 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
367
apps/web/src/components/settings/BankingSection.tsx
Normal file
367
apps/web/src/components/settings/BankingSection.tsx
Normal 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
115
apps/web/src/lib/banking.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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={
|
||||
|
||||
161
apps/web/src/routes/_app/parametres_.banque.success.tsx
Normal file
161
apps/web/src/routes/_app/parametres_.banque.success.tsx
Normal 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'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
144
docs/tech/banking-setup.md
Normal 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).
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user