From 51217175ad94da288c20d71548fc21512faf5cf5 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Tue, 12 May 2026 14:03:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(banking):=20int=C3=A9gration=20Powens=20AI?= =?UTF-8?q?SP=20+=20auto-r=C3=A9conciliation=20factures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/deploy-memory.md | 75 +++- apps/api/.env.example | 26 ++ .../api/app/controllers/banking_controller.ts | 234 ++++++++++ .../controllers/webhooks_powens_controller.ts | 223 ++++++++++ apps/api/app/exceptions/handler.ts | 44 +- apps/api/app/mails/bank_connected_email.tsx | 229 ++++++++++ .../invoice_auto_paid_notification_email.tsx | 239 ++++++++++ .../middleware/assert_paid_plan_middleware.ts | 35 ++ apps/api/app/models/bank_account.ts | 13 + apps/api/app/models/bank_connection.ts | 13 + apps/api/app/models/bank_transaction.ts | 13 + apps/api/app/models/organization.ts | 10 + .../app/services/banking/banking_service.ts | 413 ++++++++++++++++++ .../api/app/services/banking/powens_client.ts | 365 ++++++++++++++++ .../banking/reconcile_transactions.ts | 312 +++++++++++++ .../app/services/banking/sync_connection.ts | 113 +++++ apps/api/app/services/mail_dispatcher.ts | 189 ++++++++ .../bank_connection_transformer.ts | 50 +++ apps/api/app/validators/banking.ts | 11 + apps/api/commands/banking_reconcile.ts | 53 +++ apps/api/commands/banking_simulate_payment.ts | 112 +++++ ...00000_add_powens_to_organizations_table.ts | 39 ++ ...500000100_create_bank_connections_table.ts | 56 +++ ...778500000200_create_bank_accounts_table.ts | 50 +++ ...00000300_create_bank_transactions_table.ts | 76 ++++ apps/api/database/schema.ts | 91 +++- apps/api/start/env.ts | 31 ++ apps/api/start/kernel.ts | 1 + apps/api/start/routes.ts | 56 +++ .../components/settings/BankingSection.tsx | 367 ++++++++++++++++ apps/web/src/lib/banking.ts | 115 +++++ apps/web/src/routes/_app/parametres.tsx | 37 ++ .../_app/parametres_.banque.success.tsx | 161 +++++++ docs/tech/banking-setup.md | 144 ++++++ k3s/app/api.yml | 17 + 35 files changed, 4006 insertions(+), 7 deletions(-) create mode 100644 apps/api/app/controllers/banking_controller.ts create mode 100644 apps/api/app/controllers/webhooks_powens_controller.ts create mode 100644 apps/api/app/mails/bank_connected_email.tsx create mode 100644 apps/api/app/mails/invoice_auto_paid_notification_email.tsx create mode 100644 apps/api/app/middleware/assert_paid_plan_middleware.ts create mode 100644 apps/api/app/models/bank_account.ts create mode 100644 apps/api/app/models/bank_connection.ts create mode 100644 apps/api/app/models/bank_transaction.ts create mode 100644 apps/api/app/services/banking/banking_service.ts create mode 100644 apps/api/app/services/banking/powens_client.ts create mode 100644 apps/api/app/services/banking/reconcile_transactions.ts create mode 100644 apps/api/app/services/banking/sync_connection.ts create mode 100644 apps/api/app/transformers/bank_connection_transformer.ts create mode 100644 apps/api/app/validators/banking.ts create mode 100644 apps/api/commands/banking_reconcile.ts create mode 100644 apps/api/commands/banking_simulate_payment.ts create mode 100644 apps/api/database/migrations/1778500000000_add_powens_to_organizations_table.ts create mode 100644 apps/api/database/migrations/1778500000100_create_bank_connections_table.ts create mode 100644 apps/api/database/migrations/1778500000200_create_bank_accounts_table.ts create mode 100644 apps/api/database/migrations/1778500000300_create_bank_transactions_table.ts create mode 100644 apps/web/src/components/settings/BankingSection.tsx create mode 100644 apps/web/src/lib/banking.ts create mode 100644 apps/web/src/routes/_app/parametres_.banque.success.tsx create mode 100644 docs/tech/banking-setup.md diff --git a/.claude/deploy-memory.md b/.claude/deploy-memory.md index e9c909a..14c6e61 100644 --- a/.claude/deploy-memory.md +++ b/.claude/deploy-memory.md @@ -89,9 +89,30 @@ kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \ --from-literal=REDIS_PASSWORD="" \ --from-literal=GOOGLE_CLIENT_ID=... \ --from-literal=GOOGLE_CLIENT_SECRET=... \ + --from-literal=MICROSOFT_CLIENT_ID=... \ + --from-literal=MICROSOFT_CLIENT_SECRET=... \ + --from-literal=STRIPE_SECRET_KEY=... \ + --from-literal=STRIPE_WEBHOOK_SECRET=... \ + --from-literal=SENTRY_DSN_API=... \ + --from-literal=POWENS_CLIENT_ID=... \ + --from-literal=POWENS_CLIENT_SECRET=... \ + --from-literal=POWENS_WEBHOOK_SECRET=... \ --dry-run=client -o yaml | kubectl apply -f - ``` +> ⚠️ Si tu rotates UN secret, repose la commande complète avec **toutes** +> les vars (sinon `apply` supprime celles omises). Garde la liste à jour +> dans un coffre-fort perso (1Password, Bitwarden…). +> +> **Alternative pour ajouter UNE/quelques clés sans toucher au reste** : +> ```bash +> kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \ +> -n rubis patch secret rubis-app-secrets --type=strategic --patch '{ +> "stringData": { "NOUVELLE_VAR": "valeur" } +> }' +> ``` +> `stringData` accepte du clair — Kubernetes encode en base64 automatiquement. + ### Google SSO — setup Google Cloud Console Si la clé OAuth est perdue ou qu'on doit la régénérer : 1. https://console.cloud.google.com/apis/credentials → projet courant @@ -135,6 +156,53 @@ Le client secret expire (Azure force 6 ou 12 mois max) — penser à le renouveler avant échéance ; sinon les nouvelles connexions échoueront en silence après expiration. +### Banking (Powens) — activation prod + +La feature banking est **déployée mais désactivée par défaut** en prod via +le flag `BANKING_ENABLED: 'false'` dans le ConfigMap `rubis-api-config`. +La section banque dans /parametres reste invisible, et `/api/v1/banking/*` +renvoie 503 `banking_disabled`. Procédure d'activation une fois le KYC +Powens prod validé : + +1. **Powens** — créer un domaine prod chez Powens (KYC requis : Kbis, + contrat AISP/DSP2). Récupérer : + - Slug du domaine (ex : `rubis` → URL `rubis.biapi.pro`). + - `client_id` + `client_secret` prod (différents du sandbox). + - Webhook secret (à générer dans la console Powens prod). + +2. **Whitelist côté console Powens prod** : + - Allowed redirect URIs : + `https://app.rubis.pro/api/v1/banking/powens/callback` + - Webhook URL : + `https://app.rubis.pro/api/v1/webhooks/powens` + +3. **Mettre à jour les secrets K3s** (re-pose le snippet + `kubectl create secret generic` ci-dessus en remplissant les 3 vars + `POWENS_CLIENT_ID` / `POWENS_CLIENT_SECRET` / `POWENS_WEBHOOK_SECRET` + avec les valeurs prod). + +4. **Mettre à jour le ConfigMap** dans `k3s/app/api.yml` si le slug + diffère de `rubis` (changer `POWENS_DOMAIN` et `POWENS_API_BASE_URL`). + Puis `BANKING_ENABLED: 'true'`. Commit + push → CI redéploie. + +5. **Smoke test prod** : se connecter avec un compte Pro/Business, aller + sur `/parametres` → la section "Connecter votre banque" doit + apparaître. Cliquer "Connecter une banque" → la webview Powens prod + doit s'ouvrir. Tester avec un vrai compte bancaire perso d'abord + pour valider toute la chaîne (sync + reconcile + emails). + +6. **Backup avant flip** : la 1re connexion crée un user Powens prod et + stocke `powens_user_id` + token chiffré (clé `APP_KEY`) sur l'org. + Si on doit un jour faire rotate `APP_KEY`, prévoir une migration des + tokens (déchiffrer avec ancienne clé → re-chiffrer avec nouvelle). + +> ℹ️ Le webhook Powens (`POST /api/v1/webhooks/powens`) attend la +> signature HMAC SHA-256 dans le header `BI-Signature` (ou +> `X-Webhook-Signature`). Vérification automatique dans le controller +> avec `POWENS_WEBHOOK_SECRET`. Si Powens reçoit autre chose qu'un 200 +> en réponse il retry agressivement → garder un œil sur les logs au +> démarrage. + ### Mise à jour Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths modifiés. Build+rollout indépendants. @@ -236,7 +304,10 @@ Quand confiance acquise et plus aucune référence vivante : - Namespace + secret registry K3s (`gitea-registry`) - Postgres : base `rubis_prod` + user `rubis` (10.10.10.3) - MinIO : bucket `rubis-prod-invoices` -- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral) -- ConfigMap `rubis-api-config` (env non-sensibles) +- Secret K3s `rubis-app-secrets` (APP_KEY, DB pwd, MinIO, Resend, Mistral, + Google/Microsoft SSO, Stripe ; Powens à poser au moment de l'activation + KYC) +- ConfigMap `rubis-api-config` (env non-sensibles incl. banking flags + désactivés par défaut) Les prochains `/deploy` font uniquement build + rollout via push git. diff --git a/apps/api/.env.example b/apps/api/.env.example index 1fe0fcb..0d9d6f9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -108,3 +108,29 @@ MICROSOFT_TENANT=common MICROSOFT_CALLBACK_URL=http://localhost:3333/api/v1/auth/microsoft/callback LIMITER_STORE=redis + +#-------------------------------------------------------------------- +# Banking — agrégation bancaire (AISP, lecture seule) +#-------------------------------------------------------------------- +# Setup complet : /docs/tech/banking-setup.md +# +# 1. Créer un compte sur https://console.powens.com/ + demander un +# domaine sandbox (gratuit). Récupérer client_id / client_secret. +# 2. Whitelister les redirect_uri dans la console Powens : +# - https://.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://.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= diff --git a/apps/api/app/controllers/banking_controller.ts b/apps/api/app/controllers/banking_controller.ts new file mode 100644 index 0000000..9237faa --- /dev/null +++ b/apps/api/app/controllers/banking_controller.ts @@ -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 }, + }) + } +} diff --git a/apps/api/app/controllers/webhooks_powens_controller.ts b/apps/api/app/controllers/webhooks_powens_controller.ts new file mode 100644 index 0000000..6e28c91 --- /dev/null +++ b/apps/api/app/controllers/webhooks_powens_controller.ts @@ -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 { + 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 { + 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() +} diff --git a/apps/api/app/exceptions/handler.ts b/apps/api/app/exceptions/handler.ts index bbe6b73..c67189f 100644 --- a/apps/api/app/exceptions/handler.ts +++ b/apps/api/app/exceptions/handler.ts @@ -21,16 +21,36 @@ export default class HttpExceptionHandler extends ExceptionHandler { if (!isObject(error)) return super.handle(error, ctx) // Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol). + // + // Format du detail PG : `Key (col1, col2)=(val1, val2) already exists.` + // + // On veut un message lisible côté SPA. Pour les contraintes composites + // multi-tenant (qui contiennent toujours `organization_id` comme 1re + // colonne sur ce projet), `organization_id` n'est pas la colonne en + // faute — c'est l'autre colonne (numero, slug, email…). On reporte + // donc la 1re colonne NON-`organization_id` comme `field`, avec la + // valeur correspondante pour un message explicite. if (error.code === '23505') { const detail = typeof error.detail === 'string' ? error.detail : '' - const fieldMatch = detail.match(/Key \(([^)]+)\)=/) - const field = fieldMatch?.[1]?.split(',')[0]?.trim() + const m = detail.match(/Key \(([^)]+)\)=\(([^)]+)\)/) + const columns = m?.[1]?.split(',').map((s) => s.trim()) ?? [] + const values = m?.[2]?.split(',').map((s) => s.trim()) ?? [] + // Index de la 1re colonne != organization_id, sinon fallback sur 0. + const idx = Math.max( + 0, + columns.findIndex((c) => c !== 'organization_id') + ) + const field = columns[idx] + const value = values[idx] ctx.response.status(422) return ctx.response.json({ errors: [ { code: 'duplicate', - message: 'Cette valeur existe déjà.', + message: + field && value + ? `${humanizeField(field)} « ${value} » existe déjà.` + : 'Cette valeur existe déjà.', field: field ?? undefined, }, ], @@ -121,3 +141,21 @@ export default class HttpExceptionHandler extends ExceptionHandler { function isObject(v: unknown): v is Record { 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 = { + 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}` +} diff --git a/apps/api/app/mails/bank_connected_email.tsx b/apps/api/app/mails/bank_connected_email.tsx new file mode 100644 index 0000000..a61b128 --- /dev/null +++ b/apps/api/app/mails/bank_connected_email.tsx @@ -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 ( + +
+ +
+ + + Votre banque est connectée + + + Rubis va maintenant détecter automatiquement vos virements entrants + et arrêter les relances dès qu'une facture est payée. + + + + Bonjour {firstName || 'et bienvenue'}, + + + Bonne nouvelle : {bank.name} 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 :`} + + +
+ Comptes synchronisés + {bank.name} + {accounts.map((a, i) => ( +
+ {a.name} + {a.ibanMasked && {a.ibanMasked}} +
+ ))} +
+ +
+ + Comment ça marche ? 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). + +
+ +
+ +
+ + + 🔒 Lecture seule. Aucun déplacement de fonds possible. +
+ Vous pouvez déconnecter cette banque à tout moment depuis vos + paramètres. +
+
+ ) +} diff --git a/apps/api/app/mails/invoice_auto_paid_notification_email.tsx b/apps/api/app/mails/invoice_auto_paid_notification_email.tsx new file mode 100644 index 0000000..ca31fe7 --- /dev/null +++ b/apps/api/app/mails/invoice_auto_paid_notification_email.tsx @@ -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 ( + +
+ +
+ + + Une facture vient d'être payée + + + Rubis a détecté un virement entrant qui correspond à une de vos factures. + Tout a été géré : relances stoppées, remerciement envoyé. + + + + Bonjour {firstName || ''}, + + + {client.name} vient de régler votre facture + {invoice.numero}. Vous gagnez un rubis 💎 — c'est + 10 minutes que vous n'aurez pas à passer à relancer ce client. + + +
+ + Client + {client.name} + + + Facture + {invoice.numero} + + + Montant + {invoice.amountFormatted} + + + Détecté via + {bankName} + +
+ +
+ + Libellé bancaire détecté : +
+ + {bankLabel} + +
+
+ +
+ +
+ + + Vous êtes en mode réconciliation automatique. Pour + repasser à une validation manuelle (vous validez chaque match avant + que la facture passe payée), rendez-vous dans Paramètres → Banque. + +
+ ) +} diff --git a/apps/api/app/middleware/assert_paid_plan_middleware.ts b/apps/api/app/middleware/assert_paid_plan_middleware.ts new file mode 100644 index 0000000..914121c --- /dev/null +++ b/apps/api/app/middleware/assert_paid_plan_middleware.ts @@ -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() + } +} diff --git a/apps/api/app/models/bank_account.ts b/apps/api/app/models/bank_account.ts new file mode 100644 index 0000000..3ecd72e --- /dev/null +++ b/apps/api/app/models/bank_account.ts @@ -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 + + @hasMany(() => BankTransaction) + declare transactions: HasMany +} diff --git a/apps/api/app/models/bank_connection.ts b/apps/api/app/models/bank_connection.ts new file mode 100644 index 0000000..b7a0b5e --- /dev/null +++ b/apps/api/app/models/bank_connection.ts @@ -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 + + @hasMany(() => BankAccount) + declare accounts: HasMany +} diff --git a/apps/api/app/models/bank_transaction.ts b/apps/api/app/models/bank_transaction.ts new file mode 100644 index 0000000..b0076a6 --- /dev/null +++ b/apps/api/app/models/bank_transaction.ts @@ -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 + + @belongsTo(() => Invoice, { foreignKey: 'matchedInvoiceId' }) + declare matchedInvoice: BelongsTo +} diff --git a/apps/api/app/models/organization.ts b/apps/api/app/models/organization.ts index d716251..366f9d0 100644 --- a/apps/api/app/models/organization.ts +++ b/apps/api/app/models/organization.ts @@ -2,6 +2,7 @@ import { OrganizationSchema } from '#database/schema' import { column, hasMany } from '@adonisjs/lucid/orm' import type { HasMany } from '@adonisjs/lucid/types/relations' import User from '#models/user' +import BankConnection from '#models/bank_connection' import type { BrandSettings } from '#services/brand' export default class Organization extends OrganizationSchema { @@ -17,4 +18,13 @@ export default class Organization extends OrganizationSchema { @hasMany(() => User) declare users: HasMany + + /** + * 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 } diff --git a/apps/api/app/services/banking/banking_service.ts b/apps/api/app/services/banking/banking_service.ts new file mode 100644 index 0000000..654f9f1 --- /dev/null +++ b/apps/api/app/services/banking/banking_service.ts @@ -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(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 { + 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(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 { + // 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 { + 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(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 { + const org = await Organization.findOrFail(orgId) + org.reconciliationMode = mode + await org.save() + return org +} diff --git a/apps/api/app/services/banking/powens_client.ts b/apps/api/app/services/banking/powens_client.ts new file mode 100644 index 0000000..60d8bd7 --- /dev/null +++ b/apps/api/app/services/banking/powens_client.ts @@ -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 ` + */ + +// -------------------------------------------------------------------- +// 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 { + return this.request('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 { + return this.request('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//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 { + return this.request( + 'GET', + `users/me/connections/${connectionId}?expand=connector`, + { userToken } + ) + } + + async deleteConnection(userToken: string, connectionId: number): Promise { + await this.request('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( + method: 'GET' | 'POST' | 'DELETE', + path: string, + opts: { + userToken?: string + body?: unknown + expectNoBody?: boolean + } = {} + ): Promise { + const headers: Record = { + 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 } diff --git a/apps/api/app/services/banking/reconcile_transactions.ts b/apps/api/app/services/banking/reconcile_transactions.ts new file mode 100644 index 0000000..d2a0908 --- /dev/null +++ b/apps/api/app/services/banking/reconcile_transactions.ts @@ -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 { + 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 { + 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 { + 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 ${invoice.numero} 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 { + 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 diff --git a/apps/api/app/services/banking/sync_connection.ts b/apps/api/app/services/banking/sync_connection.ts new file mode 100644 index 0000000..944ef97 --- /dev/null +++ b/apps/api/app/services/banking/sync_connection.ts @@ -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(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 { + 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 +} diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index 83fe14c..dcfc1a0 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -16,6 +16,8 @@ import type Organization from '#models/organization' import { CheckinEmail } from '#mails/checkin_email' import { RelanceEmail } from '#mails/relance_email' import { PaymentThanksEmail } from '#mails/payment_thanks_email' +import { BankConnectedEmail } from '#mails/bank_connected_email' +import { InvoiceAutoPaidNotificationEmail } from '#mails/invoice_auto_paid_notification_email' import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand' /** @@ -426,3 +428,190 @@ export async function sendPaymentThanksEmail({ throw err } } + +type BankConnectedPayload = { + user: User + bank: { name: string } + accounts: Array<{ name: string; ibanMasked: string | null }> +} + +/** + * Envoie un email de confirmation À L'UTILISATEUR (pas au client final) + * quand sa banque vient d'être connectée via Powens. Notif interne + * Rubis → user, donc toujours en branding Rubis (pas de marque blanche + * même pour Business). + * + * Idempotence : appelée en fire-and-forget depuis BankingService.handleCallback. + * Si l'envoi échoue, on log et on swallow — la connexion bancaire est déjà + * en DB, on ne veut pas casser le flow utilisateur pour un mail loupé. + */ +export async function sendBankConnectedEmail({ + user, + bank, + accounts, +}: BankConnectedPayload) { + const subject = `${bank.name} est connectée à Rubis` + + const accountsList = accounts + .map((a) => ` • ${a.name}${a.ibanMasked ? ` (${a.ibanMasked})` : ''}`) + .join('\n') + + const body = `Bonjour ${user.fullName ?? ''}, + +Bonne nouvelle : ${bank.name} est désormais reliée à votre espace Rubis. + +Comptes synchronisés : +${accountsList} + +Rubis va maintenant détecter automatiquement vos virements entrants +et arrêter les relances dès qu'une facture est payée. + +Voir mes paramètres : ${env.get('WEB_URL', 'http://localhost:5173')}/parametres + +Lecture seule. Aucun déplacement de fonds possible. Vous pouvez +déconnecter cette banque à tout moment depuis vos paramètres. + +— L'équipe Rubis` + + const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro') + const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle") + + const settingsUrl = `${env.get('WEB_URL', 'http://localhost:5173')}/parametres` + const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') + + const htmlBody = await render( + BankConnectedEmail({ + tokens: DEFAULT_BRAND, // notif Rubis → user, jamais en marque blanche + user: { fullName: user.fullName ?? null }, + bank, + accounts, + settingsUrl, + landingUrl, + }) + ) + + const driver = env.get('MAIL_DRIVER', 'smtp') + logger.info( + { + userId: user.id, + to: user.email, + bank: bank.name, + accountsCount: accounts.length, + driver, + }, + 'sendBankConnectedEmail: envoi via driver' + ) + + try { + const mailer = mail.use(driver) + await mailer.send((m) => { + m.from(fromAddress, fromName) + .to(user.email, user.fullName ?? user.email) + .subject(subject) + .html(htmlBody) + .text(body) + }) + logger.info( + { userId: user.id, bank: bank.name, driver }, + 'sendBankConnectedEmail: send OK' + ) + } catch (err) { + logger.error( + { err, userId: user.id, bank: bank.name, driver }, + 'sendBankConnectedEmail: échec envoi' + ) + throw err + } +} + +type InvoiceAutoPaidNotifPayload = { + user: User + invoice: Invoice + client: Client + bankLabel: string + bankName: string +} + +/** + * Notif À L'UTILISATEUR (pas au client) quand la réconciliation auto + * a marqué une facture payée à partir d'un virement bancaire détecté. + * Toujours en branding Rubis. Inclut le lien direct vers la facture. + * + * À distinguer de `sendPaymentThanksEmail` qui, lui, va au CLIENT pour + * le remercier. + */ +export async function sendInvoiceAutoPaidNotification({ + user, + invoice, + client, + bankLabel, + bankName, +}: InvoiceAutoPaidNotifPayload) { + const amountFormatted = formatAmountFr(invoice.amountTtcCents) + const subject = `${client.name} a payé la facture ${invoice.numero}` + + const webUrl = env.get('WEB_URL', 'http://localhost:5173') + const detailUrl = `${webUrl}/factures/${invoice.id}` + + const body = `Bonjour ${user.fullName ?? ''}, + +${client.name} vient de régler la facture ${invoice.numero} d'un montant de ${amountFormatted}. + +Rubis a détecté le virement entrant sur votre ${bankName} et a tout géré pour vous : + • Facture marquée payée + • Relances futures annulées + • Email de remerciement envoyé à ${client.name} + +Libellé bancaire détecté : + ${bankLabel} + +Voir la facture : ${detailUrl} + +Mode de réconciliation : automatique. Pour repasser en validation manuelle, +rendez-vous dans Paramètres → Banque. + +— L'équipe Rubis` + + const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro') + const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle") + const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') + + const htmlBody = await render( + InvoiceAutoPaidNotificationEmail({ + tokens: DEFAULT_BRAND, + user: { fullName: user.fullName ?? null }, + invoice: { numero: invoice.numero, amountFormatted, detailUrl }, + client: { name: client.name }, + bankLabel, + bankName, + landingUrl, + }) + ) + + const driver = env.get('MAIL_DRIVER', 'smtp') + logger.info( + { userId: user.id, invoiceId: invoice.id, numero: invoice.numero, driver }, + 'sendInvoiceAutoPaidNotification: envoi via driver' + ) + + try { + const mailer = mail.use(driver) + await mailer.send((m) => { + m.from(fromAddress, fromName) + .to(user.email, user.fullName ?? user.email) + .subject(subject) + .html(htmlBody) + .text(body) + }) + logger.info( + { userId: user.id, invoiceId: invoice.id, driver }, + 'sendInvoiceAutoPaidNotification: send OK' + ) + } catch (err) { + logger.error( + { err, userId: user.id, invoiceId: invoice.id, driver }, + 'sendInvoiceAutoPaidNotification: échec envoi' + ) + throw err + } +} diff --git a/apps/api/app/transformers/bank_connection_transformer.ts b/apps/api/app/transformers/bank_connection_transformer.ts new file mode 100644 index 0000000..65220e7 --- /dev/null +++ b/apps/api/app/transformers/bank_connection_transformer.ts @@ -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 { + 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}` +} diff --git a/apps/api/app/validators/banking.ts b/apps/api/app/validators/banking.ts new file mode 100644 index 0000000..6d55d42 --- /dev/null +++ b/apps/api/app/validators/banking.ts @@ -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), +}) diff --git a/apps/api/commands/banking_reconcile.ts b/apps/api/commands/banking_reconcile.ts new file mode 100644 index 0000000..eb9c0b0 --- /dev/null +++ b/apps/api/commands/banking_reconcile.ts @@ -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 # 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}` + ) + } +} diff --git a/apps/api/commands/banking_simulate_payment.ts b/apps/api/commands/banking_simulate_payment.ts new file mode 100644 index 0000000..0ec75cf --- /dev/null +++ b/apps/api/commands/banking_simulate_payment.ts @@ -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 + * node ace banking:simulate-payment --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.') + } + } +} diff --git a/apps/api/database/migrations/1778500000000_add_powens_to_organizations_table.ts b/apps/api/database/migrations/1778500000000_add_powens_to_organizations_table.ts new file mode 100644 index 0000000..4f7626c --- /dev/null +++ b/apps/api/database/migrations/1778500000000_add_powens_to_organizations_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/migrations/1778500000100_create_bank_connections_table.ts b/apps/api/database/migrations/1778500000100_create_bank_connections_table.ts new file mode 100644 index 0000000..ddd8bc0 --- /dev/null +++ b/apps/api/database/migrations/1778500000100_create_bank_connections_table.ts @@ -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..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) + } +} diff --git a/apps/api/database/migrations/1778500000200_create_bank_accounts_table.ts b/apps/api/database/migrations/1778500000200_create_bank_accounts_table.ts new file mode 100644 index 0000000..960c4b2 --- /dev/null +++ b/apps/api/database/migrations/1778500000200_create_bank_accounts_table.ts @@ -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) + } +} diff --git a/apps/api/database/migrations/1778500000300_create_bank_transactions_table.ts b/apps/api/database/migrations/1778500000300_create_bank_transactions_table.ts new file mode 100644 index 0000000..33a83f0 --- /dev/null +++ b/apps/api/database/migrations/1778500000300_create_bank_transactions_table.ts @@ -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) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 9c28f8a..640465a 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -17,7 +17,7 @@ export class ActivityEventSchema extends BaseModel { @column({ isPrimary: true }) declare id: string @column() - declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' | 'thanks_email_sent' + declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' @column() declare label: string @column() @@ -53,6 +53,85 @@ export class AuthAccessTokenSchema extends BaseModel { declare updatedAt: DateTime | null } +export class BankAccountSchema extends BaseModel { + static $columns = ['balanceCents', 'bankConnectionId', 'createdAt', 'currency', 'iban', 'id', 'name', 'powensAccountId', 'type', 'updatedAt'] as const + $columns = BankAccountSchema.$columns + @column() + declare balanceCents: number | null + @column() + declare bankConnectionId: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column() + declare currency: string + @column() + declare iban: string | null + @column({ isPrimary: true }) + declare id: string + @column() + declare name: string + @column() + declare powensAccountId: bigint | number + @column() + declare type: string | null + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + +export class BankConnectionSchema extends BaseModel { + static $columns = ['bankLogoUrl', 'bankName', 'createdAt', 'id', 'lastError', 'lastSyncAt', 'organizationId', 'powensConnectionId', 'state', 'updatedAt'] as const + $columns = BankConnectionSchema.$columns + @column() + declare bankLogoUrl: string | null + @column() + declare bankName: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: string + @column() + declare lastError: string | null + @column.dateTime() + declare lastSyncAt: DateTime | null + @column() + declare organizationId: string + @column() + declare powensConnectionId: bigint | number + @column() + declare state: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + +export class BankTransactionSchema extends BaseModel { + static $columns = ['amountCents', 'bankAccountId', 'bookedAt', 'createdAt', 'id', 'label', 'matchStatus', 'matchedInvoiceId', 'powensId', 'raw', 'valueDate', 'wording'] as const + $columns = BankTransactionSchema.$columns + @column() + declare amountCents: number + @column() + declare bankAccountId: string + @column.dateTime() + declare bookedAt: DateTime | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: string + @column() + declare label: string + @column() + declare matchStatus: string + @column() + declare matchedInvoiceId: string | null + @column() + declare powensId: bigint | number + @column() + declare raw: any + @column.date() + declare valueDate: DateTime + @column() + declare wording: string | null +} + export class CheckinTaskSchema extends BaseModel { static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const $columns = CheckinTaskSchema.$columns @@ -218,11 +297,13 @@ export class InvoiceSchema extends BaseModel { } export class OrganizationSchema extends BaseModel { - static $columns = ['billingCycle', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const + static $columns = ['billingCycle', 'brandSettings', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'powensTokenEncrypted', 'powensUserId', 'reconciliationMode', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const $columns = OrganizationSchema.$columns @column() declare billingCycle: string | null @column() + declare brandSettings: { logoPath?: string | null; logoUrl?: string | null; senderName?: string | null; primaryColor?: string | null; bannerColor?: string | null; bodyBgColor?: string | null; cardBgColor?: string | null; textColor?: string | null; textMutedColor?: string | null; borderColor?: string | null; linkColor?: string | null; buttonTextColor?: string | null } | null | null + @column() declare cancelAtPeriodEnd: boolean @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @@ -245,6 +326,12 @@ export class OrganizationSchema extends BaseModel { @column() declare plan: string @column() + declare powensTokenEncrypted: string | null + @column() + declare powensUserId: bigint | number | null + @column() + declare reconciliationMode: string + @column() declare rubisCount: number @column() declare siret: string | null diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts index 2bcd471..cefdf68 100644 --- a/apps/api/start/env.ts +++ b/apps/api/start/env.ts @@ -103,4 +103,35 @@ export default await Env.create(new URL('../', import.meta.url), { */ SENTRY_DSN_API: Env.schema.string.optional(), APP_VERSION: Env.schema.string.optional(), + + /* + |---------------------------------------------------------- + | Banking — agrégation bancaire (lecture seule, AISP) + |---------------------------------------------------------- + | V1 : un seul provider supporté (Powens). On garde les flags + | BANKING_ENABLED / BANKING_PROVIDER pour pouvoir kill-switch + | la feature en prod sans redéploiement de code et pour + | anticiper un éventuel multi-provider (Bridge, Tink…). + | + | Flux Powens : on init un user Powens par organization, on + | génère un code temporaire, on ouvre la webview Powens, le + | user choisit sa banque, Powens redirige sur POWENS_REDIRECT_URI + | (qui pointe sur notre API), on stocke la connection. + | + | En dev : POWENS_REDIRECT_URI doit pointer sur un tunnel HTTPS + | (Cloudflare Quick Tunnel, ngrok, …) parce que Powens refuse + | http://. Voir /docs/tech/banking-setup.md. + | + | POWENS_DOMAIN = slug du domaine (ex : 'rubis-sandbox'). + | POWENS_API_BASE_URL = URL complète optionnelle pour override + | (sinon calculée : https://.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(), }) diff --git a/apps/api/start/kernel.ts b/apps/api/start/kernel.ts index 55ce3b8..9fbfd66 100644 --- a/apps/api/start/kernel.ts +++ b/apps/api/start/kernel.ts @@ -50,4 +50,5 @@ export const middleware = router.named({ auth: () => import('#middleware/auth_middleware'), admin: () => import('#middleware/admin_middleware'), assertBusinessPlan: () => import('#middleware/assert_business_plan_middleware'), + assertPaidPlan: () => import('#middleware/assert_paid_plan_middleware'), }) diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 533bdcc..9a8d061 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -21,6 +21,8 @@ const BlogController = () => import('#controllers/blog_controller') const AdminPostsController = () => import('#controllers/admin_posts_controller') const BlogUploadsController = () => import('#controllers/blog_uploads_controller') const BrandController = () => import('#controllers/brand_controller') +const BankingController = () => import('#controllers/banking_controller') +const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller') router @@ -78,6 +80,36 @@ router router .get('uploads/brand-logos/:filename', [BrandController, 'showLogo']) .as('uploads.brand_logos.show') + + /** + * Banking — callback Powens. Public parce que le navigateur du user + * arrive ici en GET après que Powens ait fini la webview, sans + * Bearer token. La sécurité est portée par le `state` HMAC signé + * dans l'URL (verifié dans le service). Redirige toujours en 302 + * vers le SPA avec ?banking=connected|error. + */ + router + .get('banking/powens/callback', [BankingController, 'callback']) + .as('banking.powens.callback') + + /** + * Banking status — public. Renvoie `{ enabled }` pour que le SPA + * sache s'il doit afficher la section banque dans /parametres. + * Pas de Bearer requis : c'est un flag d'env, pas une info user. + */ + router + .get('banking/status', [BankingController, 'status']) + .as('banking.status') + + /** + * Banking — webhook Powens. Public (auth via HMAC sur le body). + * Reçoit CONNECTION_SYNCED, NEW_TRANSACTIONS, CONNECTION_ERROR… + * Vérification de la signature avec POWENS_WEBHOOK_SECRET dans + * le controller. + */ + router + .post('webhooks/powens', [WebhooksPowensController, 'handle']) + .as('webhooks.powens') }) .prefix('/api/v1') @@ -206,6 +238,30 @@ router .as('brand') .use([middleware.auth(), middleware.assertBusinessPlan()]) + /** + * Banking — agrégation bancaire Powens (Pro + Business uniquement). + * Le middleware `assertPaidPlan` throw 403 `paid_plan_required` que + * le SPA catch pour afficher l'upsell sur Free. + * + * La route callback (`GET /banking/powens/callback`) est dans le + * groupe public au-dessus parce que Powens redirige le navigateur + * du user sans Bearer token (state HMAC signé en remplacement). + */ + router + .group(() => { + router.post('powens/init', [BankingController, 'init']).as('powens.init') + router.get('connections', [BankingController, 'index']).as('connections.index') + router + .delete('connections/:id', [BankingController, 'destroy']) + .as('connections.destroy') + .where('id', router.matchers.uuid()) + router.get('settings', [BankingController, 'showSettings']).as('settings.show') + router.patch('settings', [BankingController, 'updateSettings']).as('settings.update') + }) + .prefix('banking') + .as('banking') + .use([middleware.auth(), middleware.assertPaidPlan()]) + /** * Clients — auth requise, scope par organization de l'utilisateur courant. */ diff --git a/apps/web/src/components/settings/BankingSection.tsx b/apps/web/src/components/settings/BankingSection.tsx new file mode 100644 index 0000000..873cc73 --- /dev/null +++ b/apps/web/src/components/settings/BankingSection.tsx @@ -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 ; + } + + return ( + + ); +} + +// -------------------------------------------------------------------- +// Upsell (Free) — visuel cohérent avec la section Marque (Business) +// -------------------------------------------------------------------- + +function UpsellCard() { + return ( + +
+
+
+
+

+ Plan Pro ou Business requis +

+

+ Connectez votre banque pour détecter automatiquement les factures + payées et arrêter les relances inutiles. +

+
+
+ +
+ ); +} + +// -------------------------------------------------------------------- +// Vue Pro/Business +// -------------------------------------------------------------------- + +function BankingPaidView({ + isLoading, + connections, +}: { + isLoading: boolean; + connections: BankConnection[]; +}) { + const active = connections.find((c) => c.state !== "revoked"); + + if (isLoading) { + return ( + + + ); + } + + return ( +
+ {active ? ( + + ) : ( + + )} + +
+ ); +} + +// -------------------------------------------------------------------- +// 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 ( + +
+
+
+
+

+ Aucune banque connectée +

+

+ Rubis lit vos virements entrants pour détecter les factures payées. + Lecture seule. Aucun déplacement de fonds. +

+
+
+ +
+ ); +} + +// -------------------------------------------------------------------- +// 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 ( + +
+
+
+
+
+

+ {connection.bankName} +

+

+ + {connection.lastSyncAt && ( + + · synchronisé {formatRelative(connection.lastSyncAt)} + + )} +

+
+
+ +
+ + {connection.lastError && ( +
+ {connection.lastError} +
+ )} + +
    + {connection.accounts.length === 0 ? ( +
  • + Aucun compte récupéré pour cette banque. +
  • + ) : ( + connection.accounts.map((a) => ( +
  • +
    +

    {a.name}

    + {a.ibanMasked && ( +

    + {a.ibanMasked} +

    + )} +
    + {a.balanceCents !== null && ( +

    + {(a.balanceCents / 100).toLocaleString("fr-FR", { + style: "currency", + currency: a.currency, + })} +

    + )} +
  • + )) + )} +
+
+ ); +} + +function StateBadge({ state }: { state: string }) { + if (state === "active") { + return ( + + + ); + } + return ( + + + ); +} + +// -------------------------------------------------------------------- +// 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 ( + +
+

+ Réconciliation +

+

+ Quand un virement matche une facture en attente, Rubis peut soit + vous suggérer le match, soit la marquer payée automatiquement. +

+
+
+ setMode("manual")}> + Manuelle — je valide chaque match + + setMode("auto")}> + Automatique — match exact = facture payée + +
+
+ ); +} + +// -------------------------------------------------------------------- +// 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`; +} diff --git a/apps/web/src/lib/banking.ts b/apps/web/src/lib/banking.ts new file mode 100644 index 0000000..64458f7 --- /dev/null +++ b/apps/web/src/lib/banking.ts @@ -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("/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(`/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("/api/v1/banking/settings"), + staleTime: 60_000, + }); +} + +export function useUpdateBankingSettings() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (mode: BankingReconciliationMode) => + api.patch("/api/v1/banking/settings", { + reconciliationMode: mode, + }), + onSuccess: (data) => { + qc.setQueryData(["banking", "settings"], data); + }, + }); +} diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx index a699dc4..2b1a3ad 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -1,17 +1,32 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowRight, CreditCard, Palette } from "lucide-react"; +import { z } from "zod"; import { SettingsSection } from "@/components/settings/SettingsSection"; import { AccountForm } from "@/components/settings/AccountForm"; import { OrganizationForm } from "@/components/settings/OrganizationForm"; import { SignatureForm } from "@/components/settings/SignatureForm"; +import { BankingSection } from "@/components/settings/BankingSection"; import { DangerZone } from "@/components/settings/DangerZone"; import { DemoToggle } from "@/components/demo/DemoToggle"; import { Button } from "@rubis/ui"; import { Card } from "@rubis/ui"; import { useSubscription } from "@/lib/billing"; +import { useBankingStatus } from "@/lib/banking"; + +/** + * Search params optionnels : + * - `?banking=connected` → toast succès après retour de la webview Powens + * - `?banking=error&reason=...` → toast d'erreur ; raisons typées dans + * apps/api/app/controllers/banking_controller.ts (callback). + */ +const searchSchema = z.object({ + banking: z.enum(["connected", "error"]).optional(), + reason: z.string().optional(), +}); export const Route = createFileRoute("/_app/parametres")({ + validateSearch: searchSchema, component: ParametresPage, }); @@ -30,6 +45,11 @@ export const Route = createFileRoute("/_app/parametres")({ function ParametresPage() { const { data: sub } = useSubscription(); const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free"; + const search = Route.useSearch(); + // Kill switch banking : la section ENTIÈRE disparaît si BANKING_ENABLED=false + // côté API (typiquement en prod tant que le KYC Powens n'est pas validé). + const { data: bankingStatus } = useBankingStatus(); + const showBanking = bankingStatus?.enabled === true; return (
@@ -134,6 +154,23 @@ function ParametresPage() { + {showBanking && ( + + Connecter votre banque + + } + 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." + > + + + )} + c.id === connectionId) + : connections?.find((c) => c.bankName === bank && c.state !== "revoked"); + + return ( +
+ +