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>
368 lines
11 KiB
TypeScript
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`;
|
|
}
|