Files
fixfin/server/reports.js
T
2026-08-19 12:39:14 +02:00

128 lines
13 KiB
JavaScript

import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
const MONTHS=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12}};
const money=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'});
function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;}
function dateMonth(value){return typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)?value.slice(0,7):null;}
function activeInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;}
function occursInMonth(item,year,monthIndex){
if(!activeInMonth(item,year,monthIndex))return false;
const interval=item.interval||'monthly'; if(interval==='monthly')return true;
const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false;
const start=dueMonth-1; const step=INTERVALS[interval]?.step||12; return monthIndex>=start&&(monthIndex-start)%step===0;
}
function effectiveAmount(item,year,monthIndex){
const key=monthKey(year,monthIndex); let amount=Number(item.amount||0); let best='';
for(const change of item.amountChanges||[]){const mk=dateMonth(change.effectiveFrom);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}}
return amount;
}
function accountName(data,id){return data.accounts.find(a=>a.id===id)?.name||'Unbekannt';}
function categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';}
export function buildAnnualForecast(data,year,accountId){
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]}));
for(const item of data.incomes||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});}
for(const item of data.expenses||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
for(const item of data.transfers||[]) if(item.fromAccountId===accountId||item.toAccountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
const amount=Number(item.amount||0);
if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}
if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}
}
for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;}
return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)};
}
function occurrenceRows(data,year,accountId){
const result=[];
const push=(item,kind,monthIndex,direction='')=>{
const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex);
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),amount});
};
for(const item of data.incomes||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m);
for(const item of data.expenses||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m);
for(const item of data.transfers||[])if(item.fromAccountId===accountId||item.toAccountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
if(item.toAccountId===accountId)push(item,'transfer',m,'in');
if(item.fromAccountId===accountId)push(item,'transfer',m,'out');
}
const order={income:0,transferIn:1,expense:2,transferOut:3};
const typeOrder=r=>r.kind==='income'?order.income:r.kind==='expense'?order.expense:r.direction==='in'?order.transferIn:order.transferOut;
return result.sort((a,b)=>a.monthIndex-b.monthIndex||typeOrder(a)-typeOrder(b)||a.name.localeCompare(b.name,'de'));
}
function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense'?'Ausgabe':row.direction==='in'?'Transfer rein':'Transfer raus';}
function isInflow(row){return row.kind==='income'||row.direction==='in';}
function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;}
function addTitle(sheet,title,subtitle=''){
sheet.mergeCells('A1:F1');const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32;
if(subtitle){sheet.mergeCells('A2:F2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
}
function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
export async function createExcelReport(data,{year,accountId}={}){
const reportYear=Number(year)||new Date().getFullYear();
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
const forecast=buildAnnualForecast(data,reportYear,accountId); const rows=occurrenceRows(data,reportYear,accountId);
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]});
addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
statement.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4));
rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,5));euroCell(statement.getCell(rr,6));if(isInflow(r))statement.getCell(rr,5).font={color:{argb:'FF067647'}};else statement.getCell(rr,6).font={color:{argb:'FFB42318'}};});
const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,5).value=forecast.yearIncome;statement.getCell(totalRow,6).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,5));euroCell(statement.getCell(totalRow,6));statement.getCell(totalRow,5).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,6).font={bold:true,color:{argb:'FFB42318'}};
statement.autoFilter={from:'A4',to:'F4'};widths(statement,[16,18,34,24,18,18]);
const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]});
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · Liquiditätssicht inklusive Transfers`);
summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4));
forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};});
summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]);
return wb.xlsx.writeBuffer();
}
function collectPdfBuffer(doc){return new Promise((resolve,reject)=>{const chunks=[];doc.on('data',c=>chunks.push(c));doc.on('end',()=>resolve(Buffer.concat(chunks)));doc.on('error',reject);});}
function pdfMoney(value){return money.format(Number(value||0));}
function drawPdfHeader(doc,title,subtitle){doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(20).text(title);doc.moveDown(.2).fillColor('#667085').font('Helvetica').fontSize(9).text(subtitle);doc.moveDown(.7);doc.strokeColor('#CCD5E1').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.7);}
function addPageIfNeeded(doc,height=80){if(doc.y+height>760)doc.addPage();}
function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const width=495/items.length;items.forEach((item,i)=>{const x=50+i*width;doc.roundedRect(x,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(item.label,x+8,y+7,{width:width-24});doc.fillColor(item.negative?'#B42318':item.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(item.value,x+8,y+20,{width:width-24});});doc.y=y+50;}
function pdfTableHeader(doc,cols){const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;}
function drawTableRow(doc,cols,values,{toneCols={}}={}){const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(toneCols[i]||'#344054').font('Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;}
export async function createPdfReport(data,{year,accountId}={}){
const reportYear=Number(year)||new Date().getFullYear();
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
const forecast=buildAnnualForecast(data,reportYear,accountId);const rows=occurrenceRows(data,reportYear,accountId);
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]);
const cols=[{label:'Art',width:88},{label:'Bezeichnung',width:167},{label:'Kategorie',width:105},{label:'Zufluss',width:68,align:'right'},{label:'Abfluss',width:67,align:'right'}];
for(let monthIndex=0;monthIndex<12;monthIndex++){
const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex];
// Jeder Monat beginnt auf einer eigenen Seite. Der Monatsname steht immer
// links und nutzt die volle Inhaltsbreite, damit er niemals umbrechen kann.
doc.addPage();
drawPdfHeader(doc,`Kontoauszug ${reportYear}`,account.name);
const monthY=doc.y;
doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(16)
.text(month.name,50,monthY,{width:495,align:'left',lineBreak:false});
doc.y=monthY+24;
pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(month.displayIncome),positive:true},{label:'Abflüsse',value:pdfMoney(month.displayExpense),negative:true},{label:'Nettofluss',value:pdfMoney(month.balance),positive:month.balance>=0,negative:month.balance<0}]);
if(!monthRows.length){doc.fillColor('#667085').font('Helvetica').fontSize(8).text('Keine geplanten Kontobewegungen.');doc.moveDown(.8);continue;}
pdfTableHeader(doc,cols);
for(const row of monthRows){
if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);}
const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{3:'#067647'}:{4:'#B42318'}});
}
doc.moveDown(.8);
}
addPageIfNeeded(doc,40);doc.strokeColor('#D6DCE7').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.45);doc.fillColor('#667085').font('Helvetica').fontSize(7).text('Erstellt aus FixFin-Planungsdaten. Kein von einer Bank ausgestellter Kontoauszug.');
doc.end();return done;
}