rubis/apps/web/src/components/settings/BankingSection.tsx
ordinarthur 51217175ad
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
feat(banking): intégration Powens AISP + auto-réconciliation factures
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).

Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
  webview URL, list/get/delete connections, accounts, transactions,
  vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
  encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
  (TTL 10 min), handleCallback (upsert connection + accounts +
  fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
  Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
  + label normalisé (numero ou nom client, NFD strip + alphanum).
  Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
  HIGH → invoice.status=paid + bonus rubis + cancel relances +
  enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
  (user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
  powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
  USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
  / SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
  puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
  + colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
  sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
  récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
  (notif user après match auto, lien direct facture + libellé
  bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
  jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
  reconnecter la banque) et banking:simulate-payment (injecte une
  bank_transaction synthétique qui matche une facture, pour test E2E
  sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
  credentials Powens. Endpoint public GET /banking/status renvoie
  { enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
  rapporte désormais la vraie colonne en faute (numero/slug/…) avec
  message lisible « Le numéro de facture "F2026-0013" existe déjà »,
  au lieu d'un message ambigu sur organization_id.

Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
  switch + plan Pro/Business. Si Free → upsell card avec CTA vers
  /parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
  une banque". Si banque connectée → carte avec accounts (IBAN
  masqué FR76 **** **** **** 1234), solde, last sync, bouton
  Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
  avec badge ✓ animé + halo glow rubis, récap des comptes
  synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
  note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
  { enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
  useUpdateBankingSettings.

Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
  BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
  POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
  POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
  POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
  via kubectl patch, à remplacer post-KYC).

Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
  clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
  (anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
  POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
  powens_account_id), (account, powens_id) → rejouer un event ou un
  callback ne pose pas de problème.

Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
  Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
  prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
  flip flag → smoke test), snippet kubectl patch pour rotation
  ciblée de secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:03:32 +02:00

368 lines
11 KiB
TypeScript

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