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';} function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohne Person';} function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.filter(Boolean):typeof item?.personId==='string'&&item.personId?[item.personId]:[];} function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';} function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;} function ownerIdsOf(account){return Array.isArray(account?.ownerPersonIds)?[...new Set(account.ownerPersonIds.filter(Boolean))]:[];} function transferFactorForAccount(data,item,personId='all',accountId=''){ if(personId==='all')return 1; const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId);const selected=data.accounts.find(a=>a.id===accountId); let ids=[]; if(selected?.ownership==='personal')ids=ownerIdsOf(selected); else if(item.toAccountId===accountId&&from?.ownership==='personal')ids=ownerIdsOf(from); else if(item.fromAccountId===accountId&&to?.ownership==='personal')ids=ownerIdsOf(to); else return personFactor(item,personId); return ids.includes(personId)?1:0; } function transferPersonLabel(data,item){ const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId); if(from?.ownership==='personal'&&to?.ownership==='personal'&&ownerIdsOf(from).length&&ownerIdsOf(to).length)return `${ownerIdsOf(from).map(id=>personName(data,id)).join(', ')} → ${ownerIdsOf(to).map(id=>personName(data,id)).join(', ')}`; return personLabel(data,item); } export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){ 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||[]){ const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});} } for(const item of data.expenses||[]){ const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} } if(includeTransfers) for(const item of data.transfers||[]){ const factor=transferFactorForAccount(data,item,personId,accountId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ const amount=Number(item.amount||0)*factor; 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+(includeTransfers?r.transferIn:0);r.displayExpense=r.expense+(includeTransfers?r.transferOut:0);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,personId='all',includeTransfers=true){ const result=[]; const push=(item,kind,monthIndex,direction='',factor=1)=>{ const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:kind==='transfer'?transferPersonLabel(data,item):personLabel(data,item),category:categoryName(data,item.categoryId),amount}); }; for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);} for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);} if(includeTransfers) for(const item of data.transfers||[]){const factor=transferFactorForAccount(data,item,personId,accountId);if(factor&&(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',factor); if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor); }} 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='',lastColumn='F'){ sheet.mergeCells(`A1:${lastColumn}1`);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:${lastColumn}2`);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,personId='all',includeTransfers=true}={}){ 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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers); const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers); 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} · ${selectedPerson} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`,'G'); statement.getRow(4).values=['Monat','Art','Bezeichnung','Personen','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.person,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,6));euroCell(statement.getCell(rr,7));if(isInflow(r))statement.getCell(rr,6).font={color:{argb:'FF067647'}};else statement.getCell(rr,7).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,6).value=forecast.yearIncome;statement.getCell(totalRow,7).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,6));euroCell(statement.getCell(totalRow,7));statement.getCell(totalRow,6).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,7).font={bold:true,color:{argb:'FFB42318'}}; statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]); const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]}); addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'Liquiditätssicht inklusive Transfers':'Auswertung ohne Transfers'}`,'D'); 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,personId='all',includeTransfers=true}={}){ 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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers);const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers); 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} · ${selectedPerson} · ${includeTransfers?'inkl. Transfers':'ohne Transfers'} · 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.yearIncomer.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} · ${selectedPerson}`); 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} · ${selectedPerson} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);} const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.person,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{4:'#067647'}:{5:'#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; }