351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { prisma } from "../lib/prisma";
|
|
import { transactionService } from "../services/transaction.service";
|
|
import { generateId } from "../lib/store-db";
|
|
|
|
interface CSVTransaction {
|
|
date: string;
|
|
amount: string;
|
|
libelle: string;
|
|
beneficiaire: string;
|
|
iban: string;
|
|
bic: string;
|
|
numeroCompte: string;
|
|
codeBanque: string;
|
|
categorie: string;
|
|
commentaire: string;
|
|
numeroCheque: string;
|
|
tags: string;
|
|
compte: string;
|
|
}
|
|
|
|
function parseCSVLine(line: string): string[] {
|
|
const result: string[] = [];
|
|
let current = "";
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes;
|
|
} else if (char === "," && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = "";
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
|
|
function parseCSV(csvPath: string): CSVTransaction[] {
|
|
const content = fs.readFileSync(csvPath, "utf-8");
|
|
const lines = content.split("\n");
|
|
|
|
// Skip header lines (first 8 lines)
|
|
const dataLines = lines.slice(8);
|
|
|
|
const transactions: CSVTransaction[] = [];
|
|
|
|
for (const line of dataLines) {
|
|
if (!line.trim()) continue;
|
|
|
|
const fields = parseCSVLine(line);
|
|
if (fields.length < 13) continue;
|
|
|
|
// Skip if date or amount is missing
|
|
if (!fields[0] || !fields[1] || !fields[2]) continue;
|
|
|
|
transactions.push({
|
|
date: fields[0],
|
|
amount: fields[1],
|
|
libelle: fields[2],
|
|
beneficiaire: fields[3],
|
|
iban: fields[4],
|
|
bic: fields[5],
|
|
numeroCompte: fields[6],
|
|
codeBanque: fields[7],
|
|
categorie: fields[8],
|
|
commentaire: fields[9],
|
|
numeroCheque: fields[10],
|
|
tags: fields[11],
|
|
compte: fields[12],
|
|
});
|
|
}
|
|
|
|
return transactions;
|
|
}
|
|
|
|
function parseDate(dateStr: string): string {
|
|
// Format: DD/MM/YYYY -> YYYY-MM-DD
|
|
const [day, month, year] = dateStr.split("/");
|
|
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
|
}
|
|
|
|
function parseAmount(amountStr: string): number {
|
|
if (!amountStr || amountStr.trim() === "" || amountStr === '""') {
|
|
return 0;
|
|
}
|
|
// Remove quotes, spaces (including non-breaking spaces), and replace comma with dot
|
|
const cleaned = amountStr.replace(/["\s\u00A0]/g, "").replace(",", ".");
|
|
const parsed = parseFloat(cleaned);
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
function generateFITID(transaction: CSVTransaction, index: number): string {
|
|
const date = parseDate(transaction.date);
|
|
const dateStr = date.replace(/-/g, "");
|
|
const amountStr = Math.abs(parseAmount(transaction.amount))
|
|
.toFixed(2)
|
|
.replace(".", "");
|
|
const libelleHash = transaction.libelle
|
|
.substring(0, 20)
|
|
.replace(/[^A-Z0-9]/gi, "");
|
|
return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
|
|
}
|
|
|
|
function removeAccountPrefix(accountName: string): string {
|
|
// Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive)
|
|
const prefixes = ["LivretA", "Livret A", "LDDS", "CCP", "PEL"];
|
|
let cleaned = accountName;
|
|
|
|
for (const prefix of prefixes) {
|
|
// Remove prefix followed by optional spaces and dashes
|
|
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, "i");
|
|
cleaned = cleaned.replace(regex, "");
|
|
}
|
|
|
|
return cleaned.trim();
|
|
}
|
|
|
|
function determineAccountType(
|
|
accountName: string,
|
|
): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
|
|
const upper = accountName.toUpperCase();
|
|
if (
|
|
upper.includes("LIVRET") ||
|
|
upper.includes("LDDS") ||
|
|
upper.includes("PEL")
|
|
) {
|
|
return "SAVINGS";
|
|
}
|
|
if (upper.includes("CCP") || upper.includes("COMPTE COURANT")) {
|
|
return "CHECKING";
|
|
}
|
|
return "OTHER";
|
|
}
|
|
|
|
async function main() {
|
|
const csvPath = path.join(__dirname, "../temp/all account.csv");
|
|
|
|
if (!fs.existsSync(csvPath)) {
|
|
console.error(`Fichier CSV introuvable: ${csvPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Lecture du fichier CSV...");
|
|
const csvTransactions = parseCSV(csvPath);
|
|
console.log(`✓ ${csvTransactions.length} transactions trouvées`);
|
|
|
|
// Group by account
|
|
const accountsMap = new Map<string, CSVTransaction[]>();
|
|
for (const transaction of csvTransactions) {
|
|
if (!transaction.compte) continue;
|
|
const amount = parseAmount(transaction.amount);
|
|
if (amount === 0) continue; // Skip zero-amount transactions
|
|
|
|
if (!accountsMap.has(transaction.compte)) {
|
|
accountsMap.set(transaction.compte, []);
|
|
}
|
|
accountsMap.get(transaction.compte)!.push(transaction);
|
|
}
|
|
|
|
console.log(`✓ ${accountsMap.size} comptes trouvés\n`);
|
|
|
|
let totalTransactionsCreated = 0;
|
|
let totalAccountsCreated = 0;
|
|
let totalAccountsUpdated = 0;
|
|
|
|
// Process each account
|
|
for (const [accountName, transactions] of accountsMap.entries()) {
|
|
console.log(`Traitement du compte: ${accountName}`);
|
|
console.log(` ${transactions.length} transactions`);
|
|
|
|
// Remove prefixes and extract account number from account name
|
|
const cleanedAccountName = removeAccountPrefix(accountName);
|
|
const accountNumber = cleanedAccountName
|
|
.replace(/[^A-Z0-9]/gi, "")
|
|
.substring(0, 22);
|
|
const bankId = transactions[0]?.codeBanque || "FR";
|
|
|
|
console.log(` Numéro de compte extrait: ${accountNumber}`);
|
|
|
|
// Find account by account number (try multiple strategies)
|
|
let account = await prisma.account.findFirst({
|
|
where: {
|
|
accountNumber: accountNumber,
|
|
bankId: bankId,
|
|
},
|
|
});
|
|
|
|
// If not found with bankId, try without bankId constraint
|
|
if (!account) {
|
|
account = await prisma.account.findFirst({
|
|
where: {
|
|
accountNumber: accountNumber,
|
|
},
|
|
});
|
|
}
|
|
|
|
// If still not found, try to find by account number in existing account numbers
|
|
// (some accounts might have been created with prefixes in accountNumber)
|
|
if (!account) {
|
|
const allAccounts = await prisma.account.findMany({
|
|
where: {
|
|
accountNumber: {
|
|
contains: accountNumber,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Try to find exact match in accountNumber (after cleaning)
|
|
for (const acc of allAccounts) {
|
|
const cleanedExisting = removeAccountPrefix(acc.accountNumber);
|
|
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, "");
|
|
if (existingNumber === accountNumber) {
|
|
account = acc;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!account) {
|
|
console.log(` → Création du compte...`);
|
|
account = await prisma.account.create({
|
|
data: {
|
|
name: accountName,
|
|
bankId: bankId,
|
|
accountNumber: accountNumber,
|
|
type: determineAccountType(accountName),
|
|
folderId: null,
|
|
balance: 0,
|
|
currency: "EUR",
|
|
lastImport: null,
|
|
externalUrl: null,
|
|
},
|
|
});
|
|
totalAccountsCreated++;
|
|
} else {
|
|
console.log(
|
|
` → Compte existant trouvé: ${account.name} (${account.accountNumber})`,
|
|
);
|
|
totalAccountsUpdated++;
|
|
}
|
|
|
|
// Sort transactions by date
|
|
transactions.sort((a, b) => {
|
|
const dateA = parseDate(a.date);
|
|
const dateB = parseDate(b.date);
|
|
return dateA.localeCompare(dateB);
|
|
});
|
|
|
|
// Deduplicate transactions: same amount + same date + same libelle (description)
|
|
const seenTransactions = new Map<string, CSVTransaction>();
|
|
const uniqueTransactions: CSVTransaction[] = [];
|
|
let duplicatesCount = 0;
|
|
|
|
for (const transaction of transactions) {
|
|
const amount = parseAmount(transaction.amount);
|
|
const date = parseDate(transaction.date);
|
|
const description = transaction.libelle.trim();
|
|
|
|
// Create a unique key: date-amount-description
|
|
const key = `${date}-${amount}-${description}`;
|
|
|
|
if (!seenTransactions.has(key)) {
|
|
seenTransactions.set(key, transaction);
|
|
uniqueTransactions.push(transaction);
|
|
} else {
|
|
duplicatesCount++;
|
|
}
|
|
}
|
|
|
|
if (duplicatesCount > 0) {
|
|
console.log(
|
|
` → ${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`,
|
|
);
|
|
}
|
|
|
|
// Calculate balance from unique transactions
|
|
const balance = uniqueTransactions.reduce(
|
|
(sum, t) => sum + parseAmount(t.amount),
|
|
0,
|
|
);
|
|
|
|
// Prepare transactions for insertion
|
|
const dbTransactions = uniqueTransactions.map((transaction, index) => {
|
|
const amount = parseAmount(transaction.amount);
|
|
const date = parseDate(transaction.date);
|
|
|
|
// Build memo from available fields
|
|
let memo = transaction.libelle;
|
|
if (transaction.beneficiaire) {
|
|
memo += ` - ${transaction.beneficiaire}`;
|
|
}
|
|
if (transaction.categorie) {
|
|
memo += ` [${transaction.categorie}]`;
|
|
}
|
|
if (transaction.commentaire) {
|
|
memo += ` (${transaction.commentaire})`;
|
|
}
|
|
|
|
return {
|
|
id: generateId(),
|
|
accountId: account.id,
|
|
date: date,
|
|
amount: amount,
|
|
description: transaction.libelle.substring(0, 255),
|
|
type: amount >= 0 ? ("CREDIT" as const) : ("DEBIT" as const),
|
|
categoryId: null, // Will be auto-categorized later if needed
|
|
isReconciled: false,
|
|
fitId: generateFITID(transaction, index),
|
|
memo: memo.length > 255 ? memo.substring(0, 255) : memo,
|
|
checkNum: transaction.numeroCheque || undefined,
|
|
};
|
|
});
|
|
|
|
// Insert transactions (will skip duplicates based on fitId)
|
|
const result = await transactionService.createMany(dbTransactions);
|
|
console.log(` → ${result.count} nouvelles transactions insérées`);
|
|
totalTransactionsCreated += result.count;
|
|
|
|
// Update account balance and lastImport
|
|
await prisma.account.update({
|
|
where: { id: account.id },
|
|
data: {
|
|
balance: balance,
|
|
lastImport: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`);
|
|
}
|
|
|
|
console.log("\n=== Résumé ===");
|
|
console.log(`Comptes créés: ${totalAccountsCreated}`);
|
|
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
|
|
console.log(`Transactions insérées: ${totalTransactionsCreated}`);
|
|
console.log("\n✓ Import terminé!");
|
|
|
|
await prisma.$disconnect();
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Erreur:", error);
|
|
process.exit(1);
|
|
});
|