#!/usr/bin/env node
/**
* Sync grootboeken IDs van SnelStart API naar config files
*
* Dit script:
* 1. Haalt alle grootboeken op uit SnelStart API
* 2. Valideert de IDs in config/booking-rules.json en config/vca-defaults.json
* 3. Rapporteert en corrigeert mismatches
* 4. Analyseert welke grootboeken daadwerkelijk gebruikt worden (--usage)
*
* Usage:
* node scripts/sync-grootboeken.cjs # Valideer en toon mismatches
* node scripts/sync-grootboeken.cjs --fix # Corrigeer mismatches in config files
* node scripts/sync-grootboeken.cjs --export # Export alle grootboeken naar JSON
* node scripts/sync-grootboeken.cjs --usage # Toon daadwerkelijk gebruikte grootboeken (via mutaties)
* node scripts/sync-grootboeken.cjs --usage 2025 # Gebruikte grootboeken voor specifiek jaar
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const 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',
};
const BOOKING_RULES_PATH = path.join(__dirname, '..', 'config', 'booking-rules.json');
const VCA_DEFAULTS_PATH = path.join(__dirname, '..', 'config', 'vca-defaults.json');
let token = null;
// ===== API FUNCTIONS =====
async function getToken() {
if (token) return token;
return new Promise((resolve, reject) => {
const postData = `grant_type=clientkey&clientkey=${CONFIG.connectionKey}`;
const url = new URL(CONFIG.authUrl);
const req = https.request({
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
token = json.access_token;
resolve(token);
} catch (e) {
reject(new Error(`Token parse error: ${e.message}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
async function apiRequest(endpoint) {
const accessToken = await getToken();
return new Promise((resolve, reject) => {
const url = new URL(`${CONFIG.baseUrl}${endpoint}`);
const req = https.request({
hostname: url.hostname,
path: url.pathname + url.search,
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Ocp-Apim-Subscription-Key': CONFIG.subscriptionKey,
'Content-Type': 'application/json',
},
}, (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}`));
}
});
});
req.on('error', reject);
req.end();
});
}
async function fetchAllGrootboeken() {
console.log('π‘ Ophalen grootboeken uit SnelStart API...');
let allGrootboeken = [];
let skip = 0;
const top = 500;
while (true) {
const result = await apiRequest(`/grootboeken?$top=${top}&$skip=${skip}`);
if (!Array.isArray(result) || result.length === 0) break;
allGrootboeken.push(...result);
skip += top;
// Safety limit
if (skip >= 2000) break;
}
console.log(` β
${allGrootboeken.length} grootboeken geladen\n`);
return allGrootboeken;
}
async function fetchAllDagboeken() {
console.log('π‘ Ophalen dagboeken uit SnelStart API...');
const result = await apiRequest('/dagboeken');
const dagboeken = Array.isArray(result) ? result : [];
console.log(` β
${dagboeken.length} dagboeken geladen\n`);
return dagboeken;
}
async function fetchAllKostenplaatsen() {
console.log('π‘ Ophalen kostenplaatsen uit SnelStart API...');
const result = await apiRequest('/kostenplaatsen');
const kostenplaatsen = Array.isArray(result) ? result : [];
console.log(` β
${kostenplaatsen.length} kostenplaatsen geladen\n`);
return kostenplaatsen;
}
// ===== VALIDATION FUNCTIONS =====
function validateConfig(configPath, snelstartData, configName) {
console.log(`\nπ Valideren ${configName}...`);
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const issues = [];
const fixes = [];
// Build lookup maps
const grootboekenByNummer = {};
const grootboekenById = {};
for (const gb of snelstartData.grootboeken) {
grootboekenByNummer[gb.nummer] = gb;
grootboekenById[gb.id] = gb;
}
const dagboekenByNummer = {};
for (const db of snelstartData.dagboeken) {
dagboekenByNummer[db.nummer] = db;
}
const kostenplaatsenByNaam = {};
for (const kp of snelstartData.kostenplaatsen) {
kostenplaatsenByNaam[kp.omschrijving.toLowerCase()] = kp;
}
// Check grootboeken in config
function checkGrootboek(nummer, configId, configNaam, path) {
const snelstart = grootboekenByNummer[nummer];
if (!snelstart) {
issues.push({
type: 'NOT_FOUND',
path,
message: `Grootboek ${nummer} bestaat niet in SnelStart`,
});
return;
}
if (configId && configId !== snelstart.id) {
issues.push({
type: 'ID_MISMATCH',
path,
nummer,
expected: snelstart.id,
found: configId,
snelstartNaam: snelstart.omschrijving,
});
fixes.push({ path, field: 'id', oldValue: configId, newValue: snelstart.id });
}
// Optionally check naam
if (configNaam && configNaam !== snelstart.omschrijving) {
issues.push({
type: 'NAME_MISMATCH',
path,
nummer,
expected: snelstart.omschrijving,
found: configNaam,
});
fixes.push({ path, field: 'naam', oldValue: configNaam, newValue: snelstart.omschrijving });
}
}
// Check dagboeken in config
function checkDagboek(nummer, configId, path) {
const snelstart = dagboekenByNummer[nummer];
if (!snelstart) {
issues.push({
type: 'NOT_FOUND',
path,
message: `Dagboek ${nummer} bestaat niet in SnelStart`,
});
return;
}
if (configId && configId !== snelstart.id) {
issues.push({
type: 'ID_MISMATCH',
path,
nummer,
expected: snelstart.id,
found: configId,
});
fixes.push({ path, field: 'id', oldValue: configId, newValue: snelstart.id });
}
}
// === Check booking-rules.json structure ===
if (config.leveranciers) {
for (const [key, lev] of Object.entries(config.leveranciers)) {
if (lev.grootboek) {
checkGrootboek(
lev.grootboek.nummer,
lev.grootboek.id,
lev.grootboek.naam,
`leveranciers.${key}.grootboek`
);
}
}
}
// Check grootboeken section in booking-rules (keyed by nummer as string)
if (config.grootboeken && config.leveranciers) {
for (const [nummer, gb] of Object.entries(config.grootboeken)) {
const nummerInt = parseInt(nummer);
if (!isNaN(nummerInt)) {
checkGrootboek(nummerInt, gb.id, gb.naam, `grootboeken.${nummer}`);
}
}
}
if (config.dagboeken) {
for (const [key, db] of Object.entries(config.dagboeken)) {
checkDagboek(db.nummer, db.id, `dagboeken.${key}`);
}
}
// === Check vca-defaults.json structure ===
if (config.adminId) {
// This is vca-defaults format
if (config.grootboeken) {
for (const [key, gb] of Object.entries(config.grootboeken)) {
if (gb && typeof gb === 'object' && gb.nummer) {
checkGrootboek(gb.nummer, gb.id, gb.naam, `grootboeken.${key}`);
}
}
}
if (config.dagboeken) {
for (const [key, db] of Object.entries(config.dagboeken)) {
if (db && typeof db === 'object' && db.nummer) {
checkDagboek(db.nummer, db.id, `dagboeken.${key}`);
}
}
}
}
return { issues, fixes, config };
}
function applyFixes(configPath, config, fixes) {
if (fixes.length === 0) return false;
for (const fix of fixes) {
const pathParts = fix.path.split('.');
let obj = config;
// Navigate to parent object
for (let i = 0; i < pathParts.length; i++) {
obj = obj[pathParts[i]];
}
// Apply fix
if (obj && fix.field) {
obj[fix.field] = fix.newValue;
}
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
return true;
}
function printReport(results) {
let hasIssues = false;
for (const [name, { issues, fixes }] of Object.entries(results)) {
if (issues.length === 0) {
console.log(` β
${name}: Alle IDs correct`);
} else {
hasIssues = true;
console.log(` β ${name}: ${issues.length} issues gevonden:`);
for (const issue of issues) {
if (issue.type === 'ID_MISMATCH') {
console.log(` - ${issue.path}: ID mismatch voor ${issue.nummer}`);
console.log(` Config: ${issue.found}`);
console.log(` SnelStart: ${issue.expected}`);
if (issue.snelstartNaam) {
console.log(` (${issue.snelstartNaam})`);
}
} else if (issue.type === 'NAME_MISMATCH') {
console.log(` - ${issue.path}: Naam mismatch voor ${issue.nummer}`);
console.log(` Config: "${issue.found}"`);
console.log(` SnelStart: "${issue.expected}"`);
} else {
console.log(` - ${issue.path}: ${issue.message}`);
}
}
}
}
return hasIssues;
}
// ===== USAGE ANALYSIS =====
async function analyzeUsage(grootboeken, year) {
const fromDate = `${year}-01-01`;
const toDate = `${year}-12-31`;
console.log(`\nπ Analyseren grootboekgebruik ${year}...`);
console.log(` Dit kan even duren (checkt ${grootboeken.length} grootboeken)...\n`);
// Build lookup map
const grootboekenById = {};
for (const gb of grootboeken) {
grootboekenById[gb.id] = gb;
}
// Check which grootboeken have mutations
const usedGrootboeken = [];
let checked = 0;
for (const gb of grootboeken) {
checked++;
process.stdout.write(`\r Checken: ${checked}/${grootboeken.length} - ${gb.nummer} ${gb.omschrijving.slice(0, 30)}...`);
try {
// Small delay to avoid rate limits
if (checked % 10 === 0) {
await new Promise(r => setTimeout(r, 1000));
}
const result = await apiRequest(`/grootboekmutaties?$filter=datum ge ${fromDate} and datum le ${toDate} and grootboek/id eq guid'${gb.id}'&$top=1`);
if (Array.isArray(result) && result.length > 0) {
// Get count for this grootboek
const countResult = await apiRequest(`/grootboekmutaties/$count?$filter=datum ge ${fromDate} and datum le ${toDate} and grootboek/id eq guid'${gb.id}'`);
const count = parseInt(countResult) || 1;
usedGrootboeken.push({
nummer: gb.nummer,
id: gb.id,
naam: gb.omschrijving,
rubriek: gb.grootboekRubriek?.omschrijving || '-',
mutaties: count,
});
}
} catch (e) {
// Rate limit or other error - wait and continue
await new Promise(r => setTimeout(r, 2000));
}
}
console.log('\n');
// Sort by nummer
usedGrootboeken.sort((a, b) => a.nummer - b.nummer);
// Print results
console.log('β'.repeat(80));
console.log(`π GEBRUIKTE GROOTBOEKEN IN ${year} (${usedGrootboeken.length} van ${grootboeken.length})`);
console.log('β'.repeat(80));
// Group by rubriek
const byRubriek = {};
for (const gb of usedGrootboeken) {
if (!byRubriek[gb.rubriek]) byRubriek[gb.rubriek] = [];
byRubriek[gb.rubriek].push(gb);
}
for (const [rubriek, gbs] of Object.entries(byRubriek).sort()) {
console.log(`\n${rubriek}:`);
for (const gb of gbs) {
console.log(` ${gb.nummer.toString().padEnd(5)} ${gb.naam.slice(0, 45).padEnd(45)} ${gb.mutaties.toString().padStart(5)} mutaties`);
}
}
// Generate config snippet
console.log('\n');
console.log('β'.repeat(80));
console.log('π CONFIG SNIPPET (voor vca-defaults.json grootboeken sectie):');
console.log('β'.repeat(80));
console.log('{');
// Only include kosten grootboeken (4xxx, 7xxx) - not balans rekeningen
const kostenGrootboeken = usedGrootboeken.filter(gb =>
gb.nummer >= 4000 && gb.nummer < 8000
);
for (const gb of kostenGrootboeken) {
const key = gb.naam
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.split(' ')
.map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1))
.join('');
console.log(` "${key}": {`);
console.log(` "id": "${gb.id}",`);
console.log(` "nummer": ${gb.nummer},`);
console.log(` "naam": "${gb.naam}"`);
console.log(` },`);
}
console.log('}');
return usedGrootboeken;
}
// ===== EXPORT FUNCTION =====
async function exportGrootboeken(grootboeken) {
const exportPath = path.join(__dirname, '..', 'config', 'snelstart-grootboeken.json');
const exportData = {
_generated: new Date().toISOString(),
_source: 'SnelStart API',
_count: grootboeken.length,
grootboeken: grootboeken.map(gb => ({
id: gb.id,
nummer: gb.nummer,
omschrijving: gb.omschrijving,
grootboekfunctie: gb.grootboekfunctie,
grootboekRubriek: gb.grootboekRubriek?.omschrijving,
rgsCode: gb.rgsCode,
})).sort((a, b) => a.nummer - b.nummer),
};
fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2) + '\n');
console.log(`\nπ GeΓ«xporteerd naar ${exportPath}`);
}
// ===== MAIN =====
async function main() {
const doFix = process.argv.includes('--fix');
const doExport = process.argv.includes('--export');
const doUsage = process.argv.includes('--usage');
// Get year for usage analysis (default: current year)
let usageYear = new Date().getFullYear();
const yearArg = process.argv.find(arg => /^\d{4}$/.test(arg));
if (yearArg) usageYear = parseInt(yearArg);
if (!CONFIG.subscriptionKey || !CONFIG.connectionKey) {
console.error('β SnelStart credentials niet gevonden in .env');
process.exit(1);
}
// Fetch data from SnelStart
const grootboeken = await fetchAllGrootboeken();
// Usage analysis mode - only needs grootboeken
if (doUsage) {
await analyzeUsage(grootboeken, usageYear);
return;
}
const dagboeken = await fetchAllDagboeken();
const kostenplaatsen = await fetchAllKostenplaatsen();
const snelstartData = { grootboeken, dagboeken, kostenplaatsen };
// Export if requested
if (doExport) {
await exportGrootboeken(grootboeken);
}
// Validate configs
const results = {};
if (fs.existsSync(BOOKING_RULES_PATH)) {
results['booking-rules.json'] = validateConfig(BOOKING_RULES_PATH, snelstartData, 'booking-rules.json');
}
if (fs.existsSync(VCA_DEFAULTS_PATH)) {
results['vca-defaults.json'] = validateConfig(VCA_DEFAULTS_PATH, snelstartData, 'vca-defaults.json');
}
// Print report
console.log('\nπ Validatie Rapport');
console.log('β'.repeat(50));
const hasIssues = printReport(results);
// Apply fixes if requested
if (doFix && hasIssues) {
console.log('\nπ§ Fixes toepassen...');
for (const [name, { fixes, config }] of Object.entries(results)) {
if (fixes.length > 0) {
const configPath = name === 'booking-rules.json' ? BOOKING_RULES_PATH : VCA_DEFAULTS_PATH;
if (applyFixes(configPath, config, fixes)) {
console.log(` β
${name}: ${fixes.length} fixes toegepast`);
}
}
}
console.log('\nβ
Config files bijgewerkt!');
} else if (hasIssues) {
console.log('\nπ‘ Tip: Gebruik --fix om de config files automatisch te corrigeren');
}
console.log('');
}
main().catch(e => {
console.error('Error:', e.message);
process.exit(1);
});