feat: add total balance calculation and display in account management; update account card to show calculated balance
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m26s

This commit is contained in:
Julien Froidefond
2025-12-20 11:30:26 +01:00
parent 4e1e623f93
commit 376bc8f84e
8 changed files with 367 additions and 56 deletions

View File

@@ -99,6 +99,7 @@ export default function AccountsPage() {
folderId: "folder-root", folderId: "folder-root",
externalUrl: "", externalUrl: "",
initialBalance: 0, initialBalance: 0,
totalBalance: 0,
}); });
// Folder management state // Folder management state
@@ -131,7 +132,7 @@ export default function AccountsPage() {
// Convert accountsWithStats to regular accounts for compatibility // Convert accountsWithStats to regular accounts for compatibility
const accounts = accountsWithStats.map( const accounts = accountsWithStats.map(
({ transactionCount: _transactionCount, ...account }) => account, ({ transactionCount: _transactionCount, calculatedBalance: _calculatedBalance, ...account }) => account,
); );
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -143,12 +144,14 @@ export default function AccountsPage() {
const handleEdit = (account: Account) => { const handleEdit = (account: Account) => {
setEditingAccount(account); setEditingAccount(account);
const totalBalance = getAccountBalance(account);
setFormData({ setFormData({
name: account.name, name: account.name,
type: account.type, type: account.type,
folderId: account.folderId || "folder-root", folderId: account.folderId || "folder-root",
externalUrl: account.externalUrl || "", externalUrl: account.externalUrl || "",
initialBalance: account.initialBalance || 0, initialBalance: account.initialBalance || 0,
totalBalance: totalBalance,
}); });
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -157,13 +160,21 @@ export default function AccountsPage() {
if (!editingAccount) return; if (!editingAccount) return;
try { try {
// Calculer le balance à partir du solde total et du solde initial
// balance = totalBalance - initialBalance
const balance = formData.totalBalance - formData.initialBalance;
// Convertir "folder-root" en null
const folderId = formData.folderId === "folder-root" ? null : formData.folderId;
const updatedAccount = { const updatedAccount = {
...editingAccount, ...editingAccount,
name: formData.name, name: formData.name,
type: formData.type, type: formData.type,
folderId: formData.folderId, folderId: folderId,
externalUrl: formData.externalUrl || null, externalUrl: formData.externalUrl || null,
initialBalance: formData.initialBalance, initialBalance: formData.initialBalance,
balance: balance,
}; };
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
invalidateAllAccountQueries(queryClient); invalidateAllAccountQueries(queryClient);
@@ -334,7 +345,7 @@ export default function AccountsPage() {
// Update cache directly // Update cache directly
queryClient.setQueryData( queryClient.setQueryData(
["accounts-with-stats"], ["accounts-with-stats"],
(old: Array<Account & { transactionCount: number }> | undefined) => { (old: Array<Account & { transactionCount: number; calculatedBalance: number }> | undefined) => {
if (!old) return old; if (!old) return old;
return old.map((a) => (a.id === accountId ? updatedAccount : a)); return old.map((a) => (a.id === accountId ? updatedAccount : a));
}, },
@@ -508,21 +519,25 @@ export default function AccountsPage() {
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
return ( const accountWithStats = accountsWithStats.find(
<AccountCard (a) => a.id === account.id,
key={account.id} );
account={account} return (
folder={folder} <AccountCard
transactionCount={getTransactionCount(account.id)} key={account.id}
onEdit={handleEdit} account={account}
onDelete={handleDelete} folder={folder}
formatCurrency={formatCurrency} transactionCount={getTransactionCount(account.id)}
isSelected={selectedAccounts.has(account.id)} calculatedBalance={accountWithStats?.calculatedBalance}
onSelect={toggleSelectAccount} onEdit={handleEdit}
draggableId={`account-${account.id}`} onDelete={handleDelete}
compact={isCompactView} formatCurrency={formatCurrency}
/> isSelected={selectedAccounts.has(account.id)}
); onSelect={toggleSelectAccount}
draggableId={`account-${account.id}`}
compact={isCompactView}
/>
);
})} })}
</div> </div>
</FolderDropZone> </FolderDropZone>
@@ -625,12 +640,16 @@ export default function AccountsPage() {
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
const accountWithStats = accountsWithStats.find(
(a) => a.id === account.id,
);
return ( return (
<AccountCard <AccountCard
key={account.id} key={account.id}
account={account} account={account}
folder={accountFolder} folder={accountFolder}
transactionCount={getTransactionCount(account.id)} transactionCount={getTransactionCount(account.id)}
calculatedBalance={accountWithStats?.calculatedBalance}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}

View File

@@ -29,6 +29,7 @@ interface AccountCardProps {
account: Account; account: Account;
folder?: Folder; folder?: Folder;
transactionCount: number; transactionCount: number;
calculatedBalance?: number;
onEdit: (account: Account) => void; onEdit: (account: Account) => void;
onDelete: (accountId: string) => void; onDelete: (accountId: string) => void;
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
@@ -42,6 +43,7 @@ export function AccountCard({
account, account,
folder, folder,
transactionCount, transactionCount,
calculatedBalance,
onEdit, onEdit,
onDelete, onDelete,
formatCurrency, formatCurrency,
@@ -53,6 +55,8 @@ export function AccountCard({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const Icon = accountTypeIcons[account.type]; const Icon = accountTypeIcons[account.type];
const realBalance = getAccountBalance(account); const realBalance = getAccountBalance(account);
const hasBalanceDifference = calculatedBalance !== undefined &&
Math.abs(account.balance - calculatedBalance) > 0.01;
const { const {
attributes, attributes,
@@ -172,21 +176,35 @@ export function AccountCard({
className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")} className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")}
> >
<div className="flex items-center justify-between gap-1.5"> <div className="flex items-center justify-between gap-1.5">
<div <div className="flex-1 min-w-0">
className={cn( <div
"font-bold truncate", className={cn(
compact "font-bold truncate",
? isMobile compact
? "text-sm" ? isMobile
: "text-lg" ? "text-sm"
: isMobile : "text-lg"
? "text-base" : isMobile
: "text-xl", ? "text-base"
!compact && !isMobile && "mb-1.5", : "text-xl",
realBalance >= 0 ? "text-emerald-600" : "text-red-600", !compact && !isMobile && "mb-1.5",
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(realBalance)}
</div>
{!compact && calculatedBalance !== undefined && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
Calculé: {formatCurrency(calculatedBalance)}
</span>
{hasBalanceDifference && (
<span className="text-xs text-destructive font-semibold">
(diff: {formatCurrency(account.balance - calculatedBalance)})
</span>
)}
</div>
)} )}
>
{formatCurrency(realBalance)}
</div> </div>
{compact && ( {compact && (
<Link <Link

View File

@@ -25,6 +25,7 @@ interface AccountFormData {
folderId: string; folderId: string;
externalUrl: string; externalUrl: string;
initialBalance: number; initialBalance: number;
totalBalance: number;
} }
interface AccountEditDialogProps { interface AccountEditDialogProps {
@@ -101,21 +102,42 @@ export function AccountEditDialog({
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Solde initial</Label> <Label>Solde total</Label>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
value={formData.initialBalance} value={formData.totalBalance}
onChange={(e) => onChange={(e) =>
onFormDataChange({ onFormDataChange({
...formData, ...formData,
initialBalance: parseFloat(e.target.value) || 0, totalBalance: parseFloat(e.target.value) || 0,
}) })
} }
placeholder="0.00" placeholder="0.00"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Solde de départ pour équilibrer le compte Solde total du compte (balance + solde initial)
</p>
</div>
<div className="space-y-2">
<Label>Solde initial</Label>
<Input
type="number"
step="0.01"
value={formData.initialBalance}
onChange={(e) => {
const newInitialBalance = parseFloat(e.target.value) || 0;
onFormDataChange({
...formData,
initialBalance: newInitialBalance,
// Ajuster le solde total pour maintenir la cohérence
totalBalance: formData.totalBalance,
});
}}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
Solde de départ pour équilibrer le compte. Le balance sera calculé automatiquement (solde total - solde initial).
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

0
dev.db
View File

View File

@@ -199,7 +199,7 @@ export function useAccountsWithStats() {
throw new Error("Failed to fetch accounts with stats"); throw new Error("Failed to fetch accounts with stats");
} }
return response.json() as Promise< return response.json() as Promise<
Array<Account & { transactionCount: number }> Array<Account & { transactionCount: number; calculatedBalance: number }>
>; >;
}, },
staleTime: 60 * 1000, // 1 minute staleTime: 60 * 1000, // 1 minute

View File

@@ -0,0 +1,76 @@
import { prisma } from "../lib/prisma";
async function main() {
const accountNumbers = process.argv.slice(2);
if (accountNumbers.length === 0) {
console.error("Usage: tsx scripts/fix-account-balances.ts <accountNumber1> [accountNumber2] ...");
console.error("Exemple: tsx scripts/fix-account-balances.ts 0748461N022 7555880857A");
process.exit(1);
}
for (const accountNumber of accountNumbers) {
console.log(`\n=== Correction du compte: ${accountNumber} ===\n`);
const account = await prisma.account.findFirst({
where: {
accountNumber: accountNumber,
},
include: {
transactions: {
select: {
amount: true,
},
},
},
});
if (!account) {
console.log(`❌ Compte non trouvé: ${accountNumber}`);
continue;
}
console.log(`Compte trouvé:`);
console.log(` ID: ${account.id}`);
console.log(` Nom: ${account.name}`);
console.log(` Bank ID: ${account.bankId}`);
console.log(` Balance actuelle: ${account.balance}`);
console.log(` Initial Balance: ${account.initialBalance}`);
console.log(` Transactions: ${account.transactions.length}`);
// Recalculer le solde à partir des transactions
const calculatedBalance = account.transactions.reduce(
(sum, t) => sum + t.amount,
0,
);
console.log(` Balance calculée: ${calculatedBalance}`);
if (Math.abs(account.balance - calculatedBalance) > 0.01) {
console.log(`\n⚠ Différence détectée: ${Math.abs(account.balance - calculatedBalance).toFixed(2)}`);
console.log(`Mise à jour du solde...`);
await prisma.account.update({
where: {
id: account.id,
},
data: {
balance: calculatedBalance,
},
});
console.log(`✅ Solde corrigé: ${calculatedBalance}`);
} else {
console.log(`\n✅ Le solde est déjà correct.`);
}
}
await prisma.$disconnect();
}
main()
.catch((e) => {
console.error("Erreur:", e);
process.exit(1);
});

View File

@@ -0,0 +1,168 @@
import { prisma } from "../lib/prisma";
async function main() {
const accountNumber = process.argv[2];
if (!accountNumber) {
console.error("Usage: tsx scripts/merge-duplicate-accounts.ts <accountNumber>");
process.exit(1);
}
console.log(`Fusion des comptes dupliqués pour: ${accountNumber}\n`);
// Trouver tous les comptes avec ce numéro
const accounts = await prisma.account.findMany({
where: {
accountNumber: accountNumber,
},
include: {
transactions: {
select: {
id: true,
date: true,
amount: true,
},
orderBy: {
date: "desc",
},
},
folder: true,
},
orderBy: {
createdAt: "desc", // Le plus récent en premier
},
});
if (accounts.length < 2) {
console.log("Aucun doublon trouvé.");
return;
}
console.log(`Trouvé ${accounts.length} comptes à fusionner:\n`);
accounts.forEach((acc, idx) => {
console.log(`Compte ${idx + 1}:`);
console.log(` ID: ${acc.id}`);
console.log(` Bank ID: ${acc.bankId}`);
console.log(` Balance: ${acc.balance}`);
console.log(` Transactions: ${acc.transactions.length}`);
console.log(` Folder: ${acc.folder?.name || "Aucun"}`);
console.log(` Created: ${acc.createdAt}`);
console.log("");
});
// Le compte avec bankId numérique (pas "FR") est le bon - le garder comme principal
// Si pas de bankId numérique, garder le plus récent
const primaryAccount = accounts.find(acc => acc.bankId !== "FR" && acc.bankId !== "") || accounts[0];
const accountsToMerge = accounts.filter(acc => acc.id !== primaryAccount.id);
console.log(`\nCompte principal (conservé): ${primaryAccount.id} (bankId: ${primaryAccount.bankId})`);
console.log(`Comptes à fusionner: ${accountsToMerge.map((a) => `${a.id} (bankId: ${a.bankId})`).join(", ")}\n`);
// Calculer l'initialBalance total (somme des initialBalance)
const totalInitialBalance = accounts.reduce(
(sum, acc) => sum + acc.initialBalance,
0,
);
console.log(`Initial balance totale: ${totalInitialBalance}\n`);
// Fusionner les transactions
let totalTransactionsMoved = 0;
for (const accountToMerge of accountsToMerge) {
console.log(
`Déplacement des transactions du compte ${accountToMerge.id}...`,
);
// Déplacer toutes les transactions vers le compte principal
const updateResult = await prisma.transaction.updateMany({
where: {
accountId: accountToMerge.id,
},
data: {
accountId: primaryAccount.id,
},
});
totalTransactionsMoved += updateResult.count;
console.log(`${updateResult.count} transactions déplacées`);
// Supprimer le compte fusionné
await prisma.account.delete({
where: {
id: accountToMerge.id,
},
});
console.log(` → Compte ${accountToMerge.id} supprimé\n`);
}
// Recalculer le solde à partir de toutes les transactions du compte fusionné
const allTransactions = await prisma.transaction.findMany({
where: {
accountId: primaryAccount.id,
},
select: {
amount: true,
},
});
const calculatedBalance = allTransactions.reduce(
(sum, t) => sum + t.amount,
0,
);
console.log(`Balance calculée à partir des transactions: ${calculatedBalance}`);
// Mettre à jour la balance du compte principal
// Garder le bankId du compte principal (celui qui est correct)
await prisma.account.update({
where: {
id: primaryAccount.id,
},
data: {
balance: calculatedBalance,
initialBalance: totalInitialBalance,
// Garder le bankId du compte principal (le bon)
bankId: primaryAccount.bankId,
// Garder le dernier import le plus récent parmi tous les comptes
lastImport:
accounts.reduce((latest, acc) => {
if (!acc.lastImport) return latest;
if (!latest) return acc.lastImport;
return acc.lastImport > latest ? acc.lastImport : latest;
}, null as string | null),
},
});
console.log(`\n✅ Fusion terminée:`);
console.log(` - Compte principal: ${primaryAccount.id}`);
console.log(` - Transactions totales déplacées: ${totalTransactionsMoved}`);
console.log(` - Nouvelle balance (calculée): ${calculatedBalance}`);
console.log(` - Comptes supprimés: ${accountsToMerge.length}`);
// Vérification finale
const finalAccount = await prisma.account.findUnique({
where: { id: primaryAccount.id },
include: {
transactions: {
select: { id: true },
},
},
});
if (finalAccount) {
console.log(`\nVérification finale:`);
console.log(` - Transactions dans le compte: ${finalAccount.transactions.length}`);
console.log(` - Balance: ${finalAccount.balance}`);
console.log(` - Bank ID: ${finalAccount.bankId}`);
}
await prisma.$disconnect();
}
main()
.catch((e) => {
console.error("Erreur:", e);
process.exit(1);
});

View File

@@ -369,7 +369,7 @@ export const bankingService = {
}, },
async getAccountsWithStats(): Promise< async getAccountsWithStats(): Promise<
Array<Account & { transactionCount: number }> Array<Account & { transactionCount: number; calculatedBalance: number }>
> { > {
const accounts = await prisma.account.findMany({ const accounts = await prisma.account.findMany({
include: { include: {
@@ -377,32 +377,40 @@ export const bankingService = {
}, },
}); });
// Get transaction counts for all accounts in one query // Get transaction counts and sums for all accounts in one query
const transactionCounts = await prisma.transaction.groupBy({ const transactionStats = await prisma.transaction.groupBy({
by: ["accountId"], by: ["accountId"],
_count: { _count: {
id: true, id: true,
}, },
_sum: {
amount: true,
},
}); });
const countMap = new Map<string, number>(); const countMap = new Map<string, number>();
transactionCounts.forEach((tc) => { const balanceMap = new Map<string, number>();
countMap.set(tc.accountId, tc._count.id); transactionStats.forEach((ts) => {
countMap.set(ts.accountId, ts._count.id);
balanceMap.set(ts.accountId, ts._sum.amount || 0);
}); });
return accounts.map((a): Account & { transactionCount: number } => ({ return accounts.map(
id: a.id, (a): Account & { transactionCount: number; calculatedBalance: number } => ({
name: a.name, id: a.id,
bankId: a.bankId, name: a.name,
accountNumber: a.accountNumber, bankId: a.bankId,
type: a.type as Account["type"], accountNumber: a.accountNumber,
folderId: a.folderId, type: a.type as Account["type"],
balance: a.balance, folderId: a.folderId,
initialBalance: a.initialBalance, balance: a.balance,
currency: a.currency, initialBalance: a.initialBalance,
lastImport: a.lastImport, currency: a.currency,
externalUrl: a.externalUrl, lastImport: a.lastImport,
transactionCount: countMap.get(a.id) || 0, externalUrl: a.externalUrl,
})); transactionCount: countMap.get(a.id) || 0,
calculatedBalance: balanceMap.get(a.id) || 0,
}),
);
}, },
}; };