/**
* Scan facturen folder en update FACTUREN-OVERZICHT.md
* Synchroniseert status met SnelStart (welke facturen zijn geboekt)
*
* Usage:
* node scripts/scan-facturen.cjs # Update alles + sync met SnelStart
* node scripts/scan-facturen.cjs --only=lulu # Update alleen Lulu Press
* node scripts/scan-facturen.cjs --only=lulu,github # Update meerdere leveranciers
* node scripts/scan-facturen.cjs --offline # Zonder SnelStart API check
* node scripts/scan-facturen.cjs --json # Output alleen JSON
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const https = require('https');
const FACTUREN_DIR = path.join(__dirname, '..', 'facturen');
const OVERZICHT_PATH = path.join(FACTUREN_DIR, 'FACTUREN-OVERZICHT.md');
// SnelStart API configuratie (uit .env)
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const SNELSTART_CONFIG = {
subscriptionKey: process.env.SNELSTART_VCA_SUBSCRIPTION_KEY,
connectionKey: process.env.SNELSTART_VCA_CONNECTION_KEY,
baseUrl: 'https://b2bapi.snelstart.nl/v2',
authUrl: 'https://auth.snelstart.nl/b2b/token',
};
// Cache voor SnelStart data
let snelstartCache = {
boekingen: [], // Alle inkoopboekingen met factuurnummer
token: null,
tokenExpiry: null,
};
// Configuratie per leverancier
const LEVERANCIER_CONFIG = {
'Anthropic (Claude Pro)': {
folder: 'claude-ai-2025 (gemaild)',
section: 'Anthropic - Claude Pro',
defaultStatus: '⏸️ privé',
},
'Anthropic API': {
folder: 'anthropic-api-2025',
section: 'Anthropic - API Kosten',
defaultStatus: '[ ]',
},
'GitHub': {
folder: 'github-2025',
section: 'GitHub',
defaultStatus: '⏸️ privé',
},
'Netlify': {
folder: 'netlify-2025',
section: 'Netlify',
defaultStatus: '[ ]',
},
'Supabase': {
folder: 'supabase-2025',
section: 'Supabase',
defaultStatus: '[ ]',
},
'OpenAI': {
folder: 'openai-2025',
section: 'OpenAI',
defaultStatus: '⏸️ privé',
},
'Lulu Press': {
folder: 'lulu',
section: 'Lulu Press',
defaultStatus: '[ ]',
},
'Speekly': {
folder: 'speekly-2025',
section: 'Speekly',
defaultStatus: '[ ]',
},
'Cursor': {
folder: 'cursor-2025',
section: 'Cursor',
defaultStatus: '[ ]',
},
'Stape.io': {
folder: 'stape-2025',
section: 'Stape.io',
defaultStatus: '[ ]',
},
'Facebook Ads': {
folder: 'facebook-ads-2025',
section: 'Facebook Ads',
defaultStatus: '[ ]',
},
};
// Scan een folder voor facturen
function scanFolder(folderPath, leverancier) {
const results = [];
if (!fs.existsSync(folderPath)) {
return results;
}
const files = fs.readdirSync(folderPath);
for (const file of files) {
if (file.startsWith('.') || file.endsWith('.json') || file.endsWith('.md')) continue;
const filePath = path.join(folderPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) continue;
const entry = {
leverancier,
bestand: file,
pad: filePath,
type: path.extname(file).toLowerCase(),
bedrag: null,
valuta: null,
datum: null,
factuurnummer: null,
maand: null,
};
// Probeer data te extraheren uit PDF
if (file.endsWith('.pdf')) {
extractFromPdf(filePath, entry);
}
// Probeer bedrag te extraheren uit TXT
if (file.endsWith('.txt')) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
extractFromText(content, entry);
} catch (e) {}
}
// Extraheer info uit bestandsnaam (als fallback)
extractFromFilename(file, entry);
results.push(entry);
}
// Sorteer op maand/datum
results.sort((a, b) => {
const aKey = a.maand || a.datum || a.bestand;
const bKey = b.maand || b.datum || b.bestand;
return aKey.localeCompare(bKey);
});
return results;
}
// Cache voor geëxtraheerde PDF data (persistent)
const PDF_CACHE_PATH = path.join(FACTUREN_DIR, '.pdf-cache.json');
let pdfCache = {};
function loadPdfCache() {
try {
if (fs.existsSync(PDF_CACHE_PATH)) {
pdfCache = JSON.parse(fs.readFileSync(PDF_CACHE_PATH, 'utf-8'));
}
} catch (e) {
pdfCache = {};
}
}
function savePdfCache() {
try {
fs.writeFileSync(PDF_CACHE_PATH, JSON.stringify(pdfCache, null, 2));
} catch (e) {
console.error('Kon PDF cache niet opslaan:', e.message);
}
}
// Extraheer data uit PDF via pdftotext met slimme leverancier-specifieke parsing
function extractFromPdf(pdfPath, entry) {
// Check cache eerst (op basis van bestandsnaam + mtime)
const stat = fs.statSync(pdfPath);
const cacheKey = `${entry.bestand}:${stat.mtimeMs}`;
if (pdfCache[cacheKey]) {
const cached = pdfCache[cacheKey];
entry.bedrag = cached.bedrag;
entry.valuta = cached.valuta;
entry.datum = cached.datum;
entry.factuurnummer = cached.factuurnummer || entry.factuurnummer;
return;
}
try {
const text = execSync(`pdftotext -layout "${pdfPath}" -`, {
encoding: 'utf-8',
maxBuffer: 1024 * 1024,
timeout: 10000,
stdio: ['pipe', 'pipe', 'ignore'] // suppress stderr
});
// Bepaal leverancier-specifieke extractie
const leverancier = entry.leverancier?.toLowerCase() || '';
if (leverancier.includes('lulu')) {
extractLulu(text, entry);
} else if (leverancier.includes('cursor')) {
extractCursor(text, entry);
} else if (leverancier.includes('supabase')) {
extractSupabase(text, entry);
} else if (leverancier.includes('netlify')) {
extractNetlify(text, entry);
} else if (leverancier.includes('anthropic') || leverancier.includes('claude')) {
extractAnthropic(text, entry);
} else if (leverancier.includes('github')) {
extractGitHub(text, entry);
} else {
// Generieke extractie
extractGeneric(text, entry);
}
// Als we nog steeds geen bedrag hebben, probeer AI fallback
if (!entry.bedrag && process.env.ANTHROPIC_API_KEY) {
extractWithAI(text, entry);
}
// Cache resultaat
pdfCache[cacheKey] = {
bedrag: entry.bedrag,
valuta: entry.valuta,
datum: entry.datum,
factuurnummer: entry.factuurnummer,
};
} catch (e) {
// pdftotext failed, continue without PDF data
}
}
// === LEVERANCIER-SPECIFIEKE EXTRACTIE ===
function extractLulu(text, entry) {
// Print Job ID: altijd aanwezig
const jobMatch = text.match(/Print Job ID\s*[\n\r]*\s*(\d+)/);
if (jobMatch) {
entry.factuurnummer = `job-${jobMatch[1]}`;
}
// PAYMENT TOTAL: "22.22 EUR" - kan op zelfde regel staan met veel spaties
// Patroon 1: "PAYMENT TOTAL" gevolgd door spaties/newlines en dan bedrag
const paymentMatch = text.match(/PAYMENT TOTAL[\s\S]{0,100}?([\d]+[.,]\d{2})\s*EUR/i);
if (paymentMatch) {
entry.bedrag = parseFloat(paymentMatch[1].replace(',', '.'));
entry.valuta = 'EUR';
}
// Patroon 2: zoek naar "XX.XX EUR" patroon in de tekst (backup)
if (!entry.bedrag) {
// Zoek alle EUR bedragen en pak de eerste redelijke (tussen 5 en 500)
const eurMatches = text.matchAll(/([\d]+[.,]\d{2})\s*EUR/gi);
for (const match of eurMatches) {
const amount = parseFloat(match[1].replace(',', '.'));
if (amount >= 5 && amount <= 500) {
entry.bedrag = amount;
entry.valuta = 'EUR';
break;
}
}
}
// Datum: "Placed on Dec 28, 2025"
const dateMatch = text.match(/Placed on\s+(\w+\s+\d{1,2},?\s+\d{4})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractCursor(text, entry) {
// Bedrag: "$20.00" prominent bovenaan
const amountMatch = text.match(/\$(\d+[.,]\d{2})/);
if (amountMatch) {
entry.bedrag = parseFloat(amountMatch[1].replace(',', '.'));
entry.valuta = 'USD';
}
// Invoice number: "06C38271-0001"
const invoiceMatch = text.match(/Invoice number\s+([A-Z0-9]+-\d+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
// Payment date: "January 31, 2025"
const dateMatch = text.match(/Payment date\s+(\w+\s+\d{1,2},?\s+\d{4})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractSupabase(text, entry) {
// Invoice number: "YHLOBG-00007"
const invoiceMatch = text.match(/Invoice number\s+([A-Z]+-\d+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
// Amount due: "$55.00" (in USD)
const amountMatch = text.match(/Amount (?:due|paid)\s+\$?([\d]+[.,]\d{2})/i);
if (amountMatch) {
entry.bedrag = parseFloat(amountMatch[1].replace(',', '.'));
entry.valuta = 'USD';
}
// Invoice Date: "Apr 17, 2025"
const dateMatch = text.match(/Invoice Date\s+(\w+\s+\d{1,2},?\s+\d{4})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractNetlify(text, entry) {
// Invoice number: "ORGYEZ-00003"
const invoiceMatch = text.match(/Invoice number\s+([A-Z]+-\d+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
// Amount paid: "$19.00"
const amountMatch = text.match(/Amount paid\s+\$?([\d]+[.,]\d{2})/i);
if (amountMatch) {
entry.bedrag = parseFloat(amountMatch[1].replace(',', '.'));
entry.valuta = 'USD';
}
// Payment date of Receipt date
const dateMatch = text.match(/(?:Payment|Receipt) date\s+(\w+\s+\d{1,2},?\s+\d{4})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractAnthropic(text, entry) {
// Invoice number: "B8C8486C-0017" of "20C48A60-0010"
const invoiceMatch = text.match(/Invoice\s*(?:number|#)?[:\s]*([A-Z0-9]+-\d+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
// Amount/Total: zoek naar EUR of USD bedrag
const eurMatch = text.match(/(?:Total|Amount)[:\s]*€?\s*([\d]+[.,]\d{2})\s*(?:EUR)?/i);
const usdMatch = text.match(/(?:Total|Amount)[:\s]*\$?\s*([\d]+[.,]\d{2})\s*(?:USD)?/i);
if (eurMatch) {
entry.bedrag = parseFloat(eurMatch[1].replace(',', '.'));
entry.valuta = 'EUR';
} else if (usdMatch) {
entry.bedrag = parseFloat(usdMatch[1].replace(',', '.'));
entry.valuta = 'USD';
}
// Date patterns
const dateMatch = text.match(/(?:Invoice\s+)?Date[:\s]+(\w+\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractGitHub(text, entry) {
// GitHub receipts: zoek naar totaal bedrag
const amountMatch = text.match(/(?:Total|Amount)[:\s]*\$?([\d]+[.,]\d{2})/i);
if (amountMatch) {
entry.bedrag = parseFloat(amountMatch[1].replace(',', '.'));
entry.valuta = 'USD';
}
// Receipt/Invoice number
const invoiceMatch = text.match(/(?:Receipt|Invoice)\s*(?:#|number)?[:\s]*([A-Z0-9-]+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
// Date
const dateMatch = text.match(/(?:Date|Issued)[:\s]+(\w+\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})/i);
if (dateMatch) {
entry.datum = parseDateString(dateMatch[1]);
}
}
function extractGeneric(text, entry) {
// Probeer verschillende bedrag patronen
const patterns = [
// "Payment Total 20.28 EUR"
/Payment Total\s+([\d]+[.,]\d{2})\s*(EUR|USD)/i,
// "Total: €734.02" of "Total: $38.00"
/Total[:\s]*[€\$]?\s*([\d]+[.,]\d{2})\s*(EUR|USD)?/i,
// "Amount due: $55.00"
/Amount\s*(?:due|paid)?[:\s]*[€\$]?\s*([\d]+[.,]\d{2})/i,
// "€18.00" of "$20.00" alleenstaand (groot bedrag)
/[€\$]\s*([\d]+[.,]\d{2})/,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
entry.bedrag = parseFloat(match[1].replace(',', '.'));
entry.valuta = match[2] || (text.includes('€') ? 'EUR' : 'USD');
break;
}
}
// Invoice/factuurnummer patronen
const invoicePatterns = [
/Invoice\s*(?:Number|#|No\.?)?[:\s]*([A-Z0-9]+-\d+)/i,
/Factuurnummer[:\s]*([A-Z0-9-]+)/i,
/Receipt\s*#?[:\s]*([A-Z0-9-]+)/i,
/Print Job ID\s*[\n\r]*\s*(\d+)/,
];
for (const pattern of invoicePatterns) {
const match = text.match(pattern);
if (match && !entry.factuurnummer) {
entry.factuurnummer = pattern.toString().includes('Print Job')
? `job-${match[1]}`
: match[1];
break;
}
}
// Datum patronen
const datePatterns = [
/Placed on\s+(\w+\s+\d{1,2},?\s+\d{4})/i,
/(?:Invoice\s+)?Date[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i,
/(?:Invoice\s+)?Date[:\s]+(\d{4}-\d{2}-\d{2})/i,
/(\w+\s+\d{1,2},\s+\d{4})/,
];
for (const pattern of datePatterns) {
const match = text.match(pattern);
if (match && !entry.datum) {
entry.datum = parseDateString(match[1]);
break;
}
}
}
// Parse datum string naar ISO formaat
function parseDateString(dateStr) {
if (!dateStr) return null;
// Al in ISO formaat
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return dateStr;
}
// "Jan 15, 2025" of "January 15, 2025"
const months = {
jan: '01', january: '01',
feb: '02', february: '02',
mar: '03', march: '03',
apr: '04', april: '04',
may: '05',
jun: '06', june: '06',
jul: '07', july: '07',
aug: '08', august: '08',
sep: '09', september: '09',
oct: '10', october: '10',
nov: '11', november: '11',
dec: '12', december: '12',
};
const match = dateStr.match(/(\w+)\s+(\d{1,2}),?\s+(\d{4})/i);
if (match) {
const month = months[match[1].toLowerCase()];
const day = match[2].padStart(2, '0');
const year = match[3];
if (month) {
return `${year}-${month}-${day}`;
}
}
return dateStr; // Return original if parsing fails
}
// AI fallback voor moeilijke PDFs (via Claude Haiku)
function extractWithAI(text, entry) {
// Beperk tekst tot eerste 2000 chars voor snelheid/kosten
const truncatedText = text.substring(0, 2000);
try {
const prompt = `Extract from this invoice text:
1. Total amount (number only, e.g., 24.08)
2. Currency (EUR or USD)
3. Invoice/job number
4. Date (YYYY-MM-DD format)
Text:
${truncatedText}
Reply ONLY with JSON: {"bedrag": 24.08, "valuta": "EUR", "factuurnummer": "xxx", "datum": "2025-01-15"}
If a field is not found, use null.`;
const result = execSync(`curl -s https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: ${process.env.ANTHROPIC_API_KEY}" \
-H "anthropic-version: 2023-06-01" \
-d '${JSON.stringify({
model: "claude-3-haiku-20240307",
max_tokens: 150,
messages: [{ role: "user", content: prompt }]
}).replace(/'/g, "'\\''")}'`, {
encoding: 'utf-8',
timeout: 15000,
stdio: ['pipe', 'pipe', 'ignore']
});
const response = JSON.parse(result);
if (response.content && response.content[0]) {
const aiText = response.content[0].text;
// Extract JSON from response
const jsonMatch = aiText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const extracted = JSON.parse(jsonMatch[0]);
if (extracted.bedrag && !entry.bedrag) entry.bedrag = extracted.bedrag;
if (extracted.valuta && !entry.valuta) entry.valuta = extracted.valuta;
if (extracted.factuurnummer && !entry.factuurnummer) entry.factuurnummer = extracted.factuurnummer;
if (extracted.datum && !entry.datum) entry.datum = extracted.datum;
}
}
} catch (e) {
// AI extraction failed, continue without
}
}
// Extraheer bedrag uit tekstbestand
function extractFromText(content, entry) {
// Amount: $38.00 of Amount: €18.00
const amountMatch = content.match(/Amount:\s*[\$€]?([\d,.]+)/i);
if (amountMatch) {
entry.bedrag = parseFloat(amountMatch[1].replace(',', '.'));
entry.valuta = content.includes('$') ? 'USD' : 'EUR';
}
// Payment Total: 20.28 EUR
const paymentMatch = content.match(/Payment Total[:\s]*([\d,.]+)\s*(EUR|USD)/i);
if (paymentMatch) {
entry.bedrag = parseFloat(paymentMatch[1].replace(',', '.'));
entry.valuta = paymentMatch[2];
}
// Date: April 11, 2025
const dateMatch = content.match(/Date:\s*(\w+\s+\d+,?\s+\d{4})/i);
if (dateMatch) {
entry.datum = dateMatch[1];
}
// Transaction ID / Invoice number
const txMatch = content.match(/Transaction ID:\s*(\S+)/i);
if (txMatch) {
entry.factuurnummer = txMatch[1];
}
const invoiceMatch = content.match(/Invoice number[:\s]*(\S+)/i);
if (invoiceMatch) {
entry.factuurnummer = invoiceMatch[1];
}
}
// Extraheer info uit bestandsnaam
function extractFromFilename(filename, entry) {
// netlify-2025-Apr-11-$38.00.txt - bedrag uit bestandsnaam
// Zoek specifiek naar $XX.XX patroon
const dollarPrice = filename.match(/\$(\d+\.\d{2})\./);
if (dollarPrice && !entry.bedrag) {
entry.bedrag = parseFloat(dollarPrice[1]);
entry.valuta = 'USD';
}
// claude-2025-04-Invoice-20C48A60-0010.pdf of claude-2025-04-20C48A60-0010.pdf
const invoiceInName = filename.match(/Invoice[_-]?(\S+?)\.pdf$/i);
if (invoiceInName && !entry.factuurnummer) {
entry.factuurnummer = invoiceInName[1];
}
// Factuurnummer uit bestandsnaam zonder "Invoice" prefix
const simpleInvoice = filename.match(/[_-]([A-Z0-9]+-\d{4})\.pdf$/i);
if (simpleInvoice && !entry.factuurnummer) {
entry.factuurnummer = simpleInvoice[1];
}
// supabase-2025-04-YHLOBG-00007.pdf
const supabaseInvoice = filename.match(/([A-Z]{6}-\d{5})\.pdf$/i);
if (supabaseInvoice && !entry.factuurnummer) {
entry.factuurnummer = supabaseInvoice[1];
}
// 2025-04 of 2025-Apr of 2026-01
const monthMatch = filename.match(/202[5-6]-(\d{2}|[A-Za-z]{3})/);
if (monthMatch) {
entry.maand = monthMatch[0];
}
// lulu-job-2203141.pdf
const jobMatch = filename.match(/job[_-]?(\d+)/i);
if (jobMatch) {
entry.factuurnummer = `job-${jobMatch[1]}`;
}
}
// ===== SNELSTART API FUNCTIES =====
async function getSnelstartToken() {
if (snelstartCache.token && snelstartCache.tokenExpiry > Date.now()) {
return snelstartCache.token;
}
return new Promise((resolve, reject) => {
const postData = `grant_type=clientkey&clientkey=${SNELSTART_CONFIG.connectionKey}`;
const url = new URL(SNELSTART_CONFIG.authUrl);
const options = {
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
snelstartCache.token = json.access_token;
snelstartCache.tokenExpiry = Date.now() + (json.expires_in - 60) * 1000;
resolve(json.access_token);
} catch (e) {
reject(new Error(`Token parse error: ${e.message}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
async function snelstartRequest(endpoint) {
const token = await getSnelstartToken();
return new Promise((resolve, reject) => {
// Endpoint al bevat query string, dus direct samenvoegen
const fullUrl = `${SNELSTART_CONFIG.baseUrl}${endpoint}`;
const url = new URL(fullUrl);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Ocp-Apim-Subscription-Key': SNELSTART_CONFIG.subscriptionKey,
'Content-Type': 'application/json',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`API parse error: ${e.message} - Raw: ${data.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
async function fetchSnelstartBoekingen() {
console.log('📡 Ophalen inkoopfacturen uit SnelStart...');
try {
// Paginate door alle facturen (max 500 per request)
let allFacturen = [];
let skip = 0;
const top = 500;
while (true) {
const facturen = await snelstartRequest(`/inkoopfacturen?$top=${top}&$skip=${skip}`);
if (!Array.isArray(facturen) || facturen.length === 0) {
break;
}
allFacturen.push(...facturen);
skip += top;
// Safety: max 10 pagina's (5000 facturen)
if (skip >= 5000) break;
}
// Filter alleen recente (2024+)
const minDate = '2024-01-01';
snelstartCache.boekingen = allFacturen
.filter(f => f.factuurDatum >= minDate)
.map(f => ({
id: f.id,
factuurnummer: f.factuurnummer,
datum: f.factuurDatum?.substring(0, 10),
bedrag: f.factuurBedrag,
leverancier: f.relatie?.naam || 'Onbekend',
inkoopBoekingId: f.inkoopBoeking?.id,
}));
console.log(` ✅ ${snelstartCache.boekingen.length} inkoopfacturen geladen (van ${allFacturen.length} totaal)`);
} catch (e) {
console.error(` ❌ SnelStart API fout: ${e.message}`);
}
return snelstartCache.boekingen;
}
function findBoekingByFactuurnummer(factuurnummer) {
if (!factuurnummer) return null;
// Exacte match
let match = snelstartCache.boekingen.find(b =>
b.factuurnummer === factuurnummer
);
// Als geen match, probeer zonder prefix
if (!match && factuurnummer.startsWith('job-')) {
const jobNum = factuurnummer.replace('job-', '');
match = snelstartCache.boekingen.find(b =>
b.factuurnummer === jobNum || b.factuurnummer === `job-${jobNum}`
);
}
return match;
}
// ===== STATUS FUNCTIES =====
// Lees bestaande statussen uit MD bestand
function parseExistingStatuses(mdContent) {
const statuses = {};
// Match tabel rijen met status
// Zoek naar: | bestandsnaam | ... | status |
const tableRowRegex = /\|\s*([^|]+\.(?:pdf|txt))\s*\|[^|]*\|[^|]*(?:\|[^|]*)?\s*\|\s*([[\]xX⏸️❌✅].*?)\s*\|/g;
let match;
while ((match = tableRowRegex.exec(mdContent)) !== null) {
const bestand = match[1].trim();
const status = match[2].trim();
statuses[bestand] = status;
}
// Ook zoeken naar simpelere patronen
const simpleRowRegex = /\|\s*\d{4}-\d{2}[a-z]?\s*\|\s*([^|]+\.(?:pdf|txt))\s*\|[^|]*\|\s*([[\]xX⏸️❌✅].*?)\s*\|/g;
while ((match = simpleRowRegex.exec(mdContent)) !== null) {
const bestand = match[1].trim();
const status = match[2].trim();
statuses[bestand] = status;
}
return statuses;
}
// Genereer markdown voor een leverancier sectie
function generateSection(leverancier, facturen, existingStatuses, config) {
if (config.skipDetailedList) {
return null; // Wordt handmatig beheerd
}
const lines = [];
// Bepaal kolommen op basis van leverancier
if (leverancier === 'Anthropic (Claude Pro)') {
lines.push('| Maand | Bestand | Factuurnummer | Bedrag | Status |');
lines.push('|-------|---------|---------------|--------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
const bedrag = f.bedrag ? `€${f.bedrag.toFixed(2)}` : '?';
lines.push(`| ${f.maand || '?'} | ${f.bestand} | ${f.factuurnummer || '?'} | ${bedrag} | ${status} |`);
}
} else if (leverancier === 'GitHub') {
lines.push('| Maand | Bestand | Status |');
lines.push('|-------|---------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
lines.push(`| ${f.maand || '?'} | ${f.bestand} | ${status} |`);
}
} else if (leverancier === 'Netlify') {
lines.push('| Maand | Bestand | Bedrag USD | Status |');
lines.push('|-------|---------|------------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
const bedrag = f.bedrag ? `$${f.bedrag.toFixed(2)}` : '?';
lines.push(`| ${f.maand || '?'} | ${f.bestand} | ${bedrag} | ${status} |`);
}
} else if (leverancier === 'Supabase') {
lines.push('| Maand | Bestand | Factuurnummer | Status |');
lines.push('|-------|---------|---------------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
lines.push(`| ${f.maand || '?'} | ${f.bestand} | ${f.factuurnummer || '?'} | ${status} |`);
}
} else if (leverancier === 'OpenAI') {
lines.push('| Maand | Bestand | Status |');
lines.push('|-------|---------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
lines.push(`| ${f.maand || '?'} | ${f.bestand} | ${status} |`);
}
} else {
// Generieke tabel
lines.push('| Bestand | Factuurnummer | Bedrag | Status |');
lines.push('|---------|---------------|--------|--------|');
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
const bedrag = f.bedrag ? `€${f.bedrag.toFixed(2)}` : '?';
lines.push(`| ${f.bestand} | ${f.factuurnummer || '?'} | ${bedrag} | ${status} |`);
}
}
return lines.join('\n');
}
// Update een sectie in het MD bestand
function updateMdSection(mdContent, sectionName, newTableContent) {
// Escape speciale regex karakters in sectionName
const escapedName = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Zoek de sectie: header + optionele tekst + tabel
// Stopt bij de volgende --- of ## of einde bestand
const lines = mdContent.split('\n');
let inSection = false;
let sectionStart = -1;
let tableStart = -1;
let tableEnd = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Start van onze sectie
if (line.match(new RegExp(`^## ${escapedName}`))) {
inSection = true;
sectionStart = i;
continue;
}
// Einde van sectie (nieuwe sectie of ----)
if (inSection && (line.match(/^## /) || line === '---')) {
if (tableStart !== -1 && tableEnd === -1) {
tableEnd = i;
}
break;
}
// Tabel header detectie
if (inSection && line.startsWith('|') && line.includes('|')) {
if (tableStart === -1) {
tableStart = i;
}
tableEnd = i + 1; // Update elke tabelrij
}
// Niet-tabel regel na tabel = einde tabel
if (inSection && tableStart !== -1 && !line.startsWith('|') && line.trim() !== '') {
break;
}
}
if (sectionStart === -1 || tableStart === -1) {
return mdContent; // Sectie of tabel niet gevonden
}
// Vervang tabel regels
const newLines = [
...lines.slice(0, tableStart),
newTableContent,
...lines.slice(tableEnd),
];
return newLines.join('\n');
}
// Update samenvatting tabel
function updateSummary(mdContent, allFacturen, existingStatuses) {
const summary = {};
for (const [leverancier, config] of Object.entries(LEVERANCIER_CONFIG)) {
const facturen = allFacturen.filter(f => f.leverancier === leverancier);
const totaal = facturen.length;
let geboekt = 0;
let prive = 0;
for (const f of facturen) {
const status = existingStatuses[f.bestand] || config.defaultStatus;
if (status.includes('[x]') || status.includes('✅')) {
geboekt++;
} else if (status.includes('⏸️') || status.toLowerCase().includes('privé')) {
prive++;
}
}
summary[leverancier] = { totaal, geboekt, prive, open: totaal - geboekt - prive };
}
// Update de samenvatting tabel
const summaryLines = [
'| Leverancier | Totaal | Geboekt | Privé/N.v.t. | Open |',
'|-------------|--------|---------|--------------|------|',
];
for (const [leverancier, stats] of Object.entries(summary)) {
if (stats.totaal > 0) {
summaryLines.push(`| ${leverancier} | ${stats.totaal} | ${stats.geboekt} | ${stats.prive} | ${stats.open} |`);
}
}
// Vervang samenvatting sectie
const summaryRegex = /(## Samenvatting\n\n)((?:\|[^\n]+\|\n)+)/s;
if (mdContent.match(summaryRegex)) {
mdContent = mdContent.replace(summaryRegex, `$1${summaryLines.join('\n')}\n`);
}
return mdContent;
}
const MAX_FACTUREN_IN_HOOFDBESTAND = 30;
// Genereer apart facturen bestand voor leverancier
function generateSeparateFile(leverancier, facturen, computedStatuses, config) {
const today = new Date().toISOString().split('T')[0];
// Tel statistieken
let geboekt = 0, open = 0, prive = 0;
for (const f of facturen) {
const status = computedStatuses[f.bestand] || config.defaultStatus;
if (status === '[x]') geboekt++;
else if (status.includes('⏸️') || status.includes('❌')) prive++;
else open++;
}
const lines = [
`# ${leverancier} Facturen`,
'',
`> Dit bestand wordt automatisch gegenereerd door \`scripts/scan-facturen.cjs\``,
`> Status wordt gesynchroniseerd met SnelStart`,
`> Terug naar [FACTUREN-OVERZICHT.md](../FACTUREN-OVERZICHT.md)`,
'',
'## Statistieken',
'',
`| Totaal | Geboekt | Open | Privé/N.v.t. |`,
`|--------|---------|------|--------------|`,
`| ${facturen.length} | ${geboekt} | ${open} | ${prive} |`,
'',
'## Status Legenda',
'- [ ] Nog te verwerken',
'- [x] Geboekt in SnelStart ✓',
'- ⏸️ Privé creditcard - wordt niet verwerkt',
'- ❌ Geen match gevonden op zakelijke creditcard',
'',
'---',
'',
'## Facturen',
'',
'| Bestand | Factuurnummer | Datum | Bedrag | Status |',
'|---------|---------------|-------|--------|--------|',
];
// Sorteer op bedrag voor makkelijk zoeken
const sorted = [...facturen].sort((a, b) => (a.bedrag || 0) - (b.bedrag || 0));
for (const f of sorted) {
const status = computedStatuses[f.bestand] || config.defaultStatus;
const bedrag = f.bedrag ? `€${f.bedrag.toFixed(2)}` : '?';
const nummer = f.factuurnummer || '?';
const datum = f.datum || '?';
lines.push(`| ${f.bestand} | ${nummer} | ${datum} | ${bedrag} | ${status} |`);
}
lines.push('');
lines.push('---');
lines.push('');
lines.push(`*Laatst bijgewerkt: ${today} (gesynchroniseerd met SnelStart)*`);
return lines.join('\n');
}
// Bepaal status op basis van SnelStart data
function determineStatus(factuur, existingStatus, config) {
// Check of factuur in SnelStart staat
const boeking = findBoekingByFactuurnummer(factuur.factuurnummer);
if (boeking) {
// Geboekt in SnelStart!
return '[x]';
}
// Behoud handmatige status (privé, n.v.t., etc)
if (existingStatus && (existingStatus.includes('⏸️') || existingStatus.includes('❌'))) {
return existingStatus;
}
// Default: nog te verwerken
return config.defaultStatus;
}
// Parse --only=lulu,github argument
function parseOnlyFilter() {
const onlyArg = process.argv.find(arg => arg.startsWith('--only='));
if (!onlyArg) return null;
const values = onlyArg.replace('--only=', '').toLowerCase().split(',');
const leverancierKeys = Object.keys(LEVERANCIER_CONFIG);
// Map shortnames to full leverancier names
const shortNameMap = {
'lulu': 'Lulu Press',
'github': 'GitHub',
'netlify': 'Netlify',
'supabase': 'Supabase',
'openai': 'OpenAI',
'anthropic': 'Anthropic (Claude Pro)',
'anthropic-api': 'Anthropic API',
'claude': 'Anthropic (Claude Pro)',
'speekly': 'Speekly',
'cursor': 'Cursor',
'stape': 'Stape.io',
'facebook': 'Facebook Ads',
};
const filtered = values
.map(v => shortNameMap[v] || leverancierKeys.find(k => k.toLowerCase().includes(v)))
.filter(Boolean);
return filtered.length > 0 ? filtered : null;
}
// Main (async voor SnelStart API)
async function main() {
const jsonOnly = process.argv.includes('--json');
const offline = process.argv.includes('--offline');
const onlyFilter = parseOnlyFilter();
// Laad PDF cache
loadPdfCache();
// Scan alle folders
const allFacturen = [];
for (const [leverancier, config] of Object.entries(LEVERANCIER_CONFIG)) {
const folderPath = path.join(FACTUREN_DIR, config.folder);
const facturen = scanFolder(folderPath, leverancier);
allFacturen.push(...facturen);
}
if (jsonOnly) {
console.log(JSON.stringify(allFacturen, null, 2));
return;
}
// Haal SnelStart data op (tenzij offline mode)
if (!offline && SNELSTART_CONFIG.subscriptionKey) {
await fetchSnelstartBoekingen();
} else if (!offline) {
console.log('⚠️ SnelStart credentials niet gevonden, draai in offline mode');
}
// Lees bestaand MD bestand
let mdContent = '';
if (fs.existsSync(OVERZICHT_PATH)) {
mdContent = fs.readFileSync(OVERZICHT_PATH, 'utf-8');
} else {
console.error('FACTUREN-OVERZICHT.md niet gevonden!');
process.exit(1);
}
// Parse bestaande statussen uit hoofdbestand (als fallback)
const existingStatuses = parseExistingStatuses(mdContent);
// Bereken nieuwe statussen op basis van SnelStart
const computedStatuses = {};
let geboektCount = 0;
for (const factuur of allFacturen) {
const config = LEVERANCIER_CONFIG[factuur.leverancier];
const existingStatus = existingStatuses[factuur.bestand];
const newStatus = determineStatus(factuur, existingStatus, config);
computedStatuses[factuur.bestand] = newStatus;
if (newStatus === '[x]' && existingStatus !== '[x]') {
geboektCount++;
}
}
if (geboektCount > 0) {
console.log(`🔄 ${geboektCount} facturen gemarkeerd als geboekt (uit SnelStart)`);
}
if (onlyFilter) {
console.log(`🔍 Filter: alleen ${onlyFilter.join(', ')}`);
}
// Update elke sectie
for (const [leverancier, config] of Object.entries(LEVERANCIER_CONFIG)) {
// Skip als filter actief en deze leverancier niet in filter
if (onlyFilter && !onlyFilter.includes(leverancier)) {
continue;
}
const facturen = allFacturen.filter(f => f.leverancier === leverancier);
if (facturen.length === 0) continue;
// Als > 30 facturen → apart bestand in subfolder
if (facturen.length > MAX_FACTUREN_IN_HOOFDBESTAND) {
const separateFilePath = path.join(FACTUREN_DIR, config.folder, 'FACTUREN.md');
// Parse ook statussen uit apart bestand (voor handmatige statussen)
if (fs.existsSync(separateFilePath)) {
const separateContent = fs.readFileSync(separateFilePath, 'utf-8');
const separateStatuses = parseExistingStatuses(separateContent);
// Merge maar laat SnelStart-based statussen winnen
for (const [k, v] of Object.entries(separateStatuses)) {
if (!computedStatuses[k]) {
computedStatuses[k] = v;
}
}
}
const separateContent = generateSeparateFile(leverancier, facturen, computedStatuses, config);
fs.writeFileSync(separateFilePath, separateContent);
console.log(`${leverancier}: ${facturen.length} facturen → ${config.folder}/FACTUREN.md`);
continue;
}
const tableContent = generateSection(leverancier, facturen, computedStatuses, config);
if (tableContent) {
mdContent = updateMdSection(mdContent, config.section, tableContent);
console.log(`${leverancier}: ${facturen.length} facturen bijgewerkt`);
}
}
// Update samenvatting met computed statussen
mdContent = updateSummary(mdContent, allFacturen, computedStatuses);
// Update timestamp
const today = new Date().toISOString().split('T')[0];
mdContent = mdContent.replace(
/\*Laatst bijgewerkt: \d{4}-\d{2}-\d{2}\*/,
`*Laatst bijgewerkt: ${today}*`
);
// Schrijf terug
fs.writeFileSync(OVERZICHT_PATH, mdContent);
console.log(`\n✅ ${OVERZICHT_PATH} bijgewerkt`);
// Sla PDF cache op
savePdfCache();
console.log(`💾 PDF cache opgeslagen (${Object.keys(pdfCache).length} items)`);
}
main().catch(e => {
console.error('Error:', e.message);
process.exit(1);
});