308 lines
9.2 KiB
TypeScript
308 lines
9.2 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);
|
|
});
|
|
|
|
// Calculate balance
|
|
const balance = transactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
|
|
|
|
// Prepare transactions for insertion
|
|
const dbTransactions = transactions.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);
|
|
});
|
|
|