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=REDIS_PASSWORD="" \
|
||||||
--from-literal=GOOGLE_CLIENT_ID=... \
|
--from-literal=GOOGLE_CLIENT_ID=... \
|
||||||
--from-literal=GOOGLE_CLIENT_SECRET=... \
|
--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 -
|
--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
|
### Google SSO — setup Google Cloud Console
|
||||||
Si la clé OAuth est perdue ou qu'on doit la régénérer :
|
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
|
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
|
renouveler avant échéance ; sinon les nouvelles connexions échoueront
|
||||||
en silence après expiration.
|
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
|
### Mise à jour
|
||||||
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
|
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
|
||||||
modifiés. Build+rollout indépendants.
|
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`)
|
- Namespace + secret registry K3s (`gitea-registry`)
|
||||||
- Postgres : base `rubis_prod` + user `rubis` (10.10.10.3)
|
- Postgres : base `rubis_prod` + user `rubis` (10.10.10.3)
|
||||||
- MinIO : bucket `rubis-prod-invoices`
|
- MinIO : bucket `rubis-prod-invoices`
|
||||||
- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral)
|
- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral,
|
||||||
- ConfigMap `rubis-api-config` (env non-sensibles)
|
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.
|
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
|
MICROSOFT_CALLBACK_URL=http://localhost:3333/api/v1/auth/microsoft/callback
|
||||||
|
|
||||||
LIMITER_STORE=redis
|
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)
|
if (!isObject(error)) return super.handle(error, ctx)
|
||||||
|
|
||||||
// Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol).
|
// 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') {
|
if (error.code === '23505') {
|
||||||
const detail = typeof error.detail === 'string' ? error.detail : ''
|
const detail = typeof error.detail === 'string' ? error.detail : ''
|
||||||
const fieldMatch = detail.match(/Key \(([^)]+)\)=/)
|
const m = detail.match(/Key \(([^)]+)\)=\(([^)]+)\)/)
|
||||||
const field = fieldMatch?.[1]?.split(',')[0]?.trim()
|
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)
|
ctx.response.status(422)
|
||||||
return ctx.response.json({
|
return ctx.response.json({
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
code: 'duplicate',
|
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,
|
field: field ?? undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -121,3 +141,21 @@ export default class HttpExceptionHandler extends ExceptionHandler {
|
|||||||
function isObject(v: unknown): v is Record<string, unknown> {
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
return v !== null && typeof v === 'object'
|
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 { column, hasMany } from '@adonisjs/lucid/orm'
|
||||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||||
import User from '#models/user'
|
import User from '#models/user'
|
||||||
|
import BankConnection from '#models/bank_connection'
|
||||||
import type { BrandSettings } from '#services/brand'
|
import type { BrandSettings } from '#services/brand'
|
||||||
|
|
||||||
export default class Organization extends OrganizationSchema {
|
export default class Organization extends OrganizationSchema {
|
||||||
@ -17,4 +18,13 @@ export default class Organization extends OrganizationSchema {
|
|||||||
|
|
||||||
@hasMany(() => User)
|
@hasMany(() => User)
|
||||||
declare users: HasMany<typeof 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 { CheckinEmail } from '#mails/checkin_email'
|
||||||
import { RelanceEmail } from '#mails/relance_email'
|
import { RelanceEmail } from '#mails/relance_email'
|
||||||
import { PaymentThanksEmail } from '#mails/payment_thanks_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'
|
import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -426,3 +428,190 @@ export async function sendPaymentThanksEmail({
|
|||||||
throw err
|
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 })
|
@column({ isPrimary: true })
|
||||||
declare id: string
|
declare id: string
|
||||||
@column()
|
@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()
|
@column()
|
||||||
declare label: string
|
declare label: string
|
||||||
@column()
|
@column()
|
||||||
@ -53,6 +53,85 @@ export class AuthAccessTokenSchema extends BaseModel {
|
|||||||
declare updatedAt: DateTime | null
|
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 {
|
export class CheckinTaskSchema extends BaseModel {
|
||||||
static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const
|
static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const
|
||||||
$columns = CheckinTaskSchema.$columns
|
$columns = CheckinTaskSchema.$columns
|
||||||
@ -218,11 +297,13 @@ export class InvoiceSchema extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OrganizationSchema 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
|
$columns = OrganizationSchema.$columns
|
||||||
@column()
|
@column()
|
||||||
declare billingCycle: string | null
|
declare billingCycle: string | null
|
||||||
@column()
|
@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
|
declare cancelAtPeriodEnd: boolean
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@ -245,6 +326,12 @@ export class OrganizationSchema extends BaseModel {
|
|||||||
@column()
|
@column()
|
||||||
declare plan: string
|
declare plan: string
|
||||||
@column()
|
@column()
|
||||||
|
declare powensTokenEncrypted: string | null
|
||||||
|
@column()
|
||||||
|
declare powensUserId: bigint | number | null
|
||||||
|
@column()
|
||||||
|
declare reconciliationMode: string
|
||||||
|
@column()
|
||||||
declare rubisCount: number
|
declare rubisCount: number
|
||||||
@column()
|
@column()
|
||||||
declare siret: string | null
|
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(),
|
SENTRY_DSN_API: Env.schema.string.optional(),
|
||||||
APP_VERSION: 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'),
|
auth: () => import('#middleware/auth_middleware'),
|
||||||
admin: () => import('#middleware/admin_middleware'),
|
admin: () => import('#middleware/admin_middleware'),
|
||||||
assertBusinessPlan: () => import('#middleware/assert_business_plan_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 AdminPostsController = () => import('#controllers/admin_posts_controller')
|
||||||
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
|
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
|
||||||
const BrandController = () => import('#controllers/brand_controller')
|
const BrandController = () => import('#controllers/brand_controller')
|
||||||
|
const BankingController = () => import('#controllers/banking_controller')
|
||||||
|
const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller')
|
||||||
|
|
||||||
|
|
||||||
router
|
router
|
||||||
@ -78,6 +80,36 @@ router
|
|||||||
router
|
router
|
||||||
.get('uploads/brand-logos/:filename', [BrandController, 'showLogo'])
|
.get('uploads/brand-logos/:filename', [BrandController, 'showLogo'])
|
||||||
.as('uploads.brand_logos.show')
|
.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')
|
.prefix('/api/v1')
|
||||||
|
|
||||||
@ -206,6 +238,30 @@ router
|
|||||||
.as('brand')
|
.as('brand')
|
||||||
.use([middleware.auth(), middleware.assertBusinessPlan()])
|
.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.
|
* 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 { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { ArrowRight, CreditCard, Palette } from "lucide-react";
|
import { ArrowRight, CreditCard, Palette } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SettingsSection } from "@/components/settings/SettingsSection";
|
import { SettingsSection } from "@/components/settings/SettingsSection";
|
||||||
import { AccountForm } from "@/components/settings/AccountForm";
|
import { AccountForm } from "@/components/settings/AccountForm";
|
||||||
import { OrganizationForm } from "@/components/settings/OrganizationForm";
|
import { OrganizationForm } from "@/components/settings/OrganizationForm";
|
||||||
import { SignatureForm } from "@/components/settings/SignatureForm";
|
import { SignatureForm } from "@/components/settings/SignatureForm";
|
||||||
|
import { BankingSection } from "@/components/settings/BankingSection";
|
||||||
import { DangerZone } from "@/components/settings/DangerZone";
|
import { DangerZone } from "@/components/settings/DangerZone";
|
||||||
import { DemoToggle } from "@/components/demo/DemoToggle";
|
import { DemoToggle } from "@/components/demo/DemoToggle";
|
||||||
import { Button } from "@rubis/ui";
|
import { Button } from "@rubis/ui";
|
||||||
import { Card } from "@rubis/ui";
|
import { Card } from "@rubis/ui";
|
||||||
import { useSubscription } from "@/lib/billing";
|
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")({
|
export const Route = createFileRoute("/_app/parametres")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
component: ParametresPage,
|
component: ParametresPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -30,6 +45,11 @@ export const Route = createFileRoute("/_app/parametres")({
|
|||||||
function ParametresPage() {
|
function ParametresPage() {
|
||||||
const { data: sub } = useSubscription();
|
const { data: sub } = useSubscription();
|
||||||
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@ -134,6 +154,23 @@ function ParametresPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SettingsSection>
|
</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
|
<SettingsSection
|
||||||
eyebrow="Démonstration"
|
eyebrow="Démonstration"
|
||||||
title={
|
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.
|
# EXACTEMENT le redirect URI configuré côté Azure App registration.
|
||||||
MICROSOFT_TENANT: 'common'
|
MICROSOFT_TENANT: 'common'
|
||||||
MICROSOFT_CALLBACK_URL: 'https://app.rubis.pro/api/v1/auth/microsoft/callback'
|
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