diff --git a/CHANGELOG_v3.32.txt b/CHANGELOG_v3.32.txt
new file mode 100644
index 0000000..93c6748
--- /dev/null
+++ b/CHANGELOG_v3.32.txt
@@ -0,0 +1,25 @@
+FixFin v3.32 – nachvollziehbare und geprüfte Statistik
+
+UI
+- Statistik erhält eine Jahresauswahl.
+- Grundprinzip der Statistik wird direkt auf der Seite erklärt.
+- Zentrale Kennzahlen haben Info-Dialoge mit Bedeutung und Formel.
+- Kennzahlen besitzen Detail-Akkordeons mit Rechenweg bzw. Einzelpositionen.
+- Kategorien zeigen Jahresbetrag, Monatsdurchschnitt und Einzelpositionen.
+- Gemeinschaftsfinanzierung, Einkommensvergleich und Transferarten erhalten Erklärungen und Detailansichten.
+
+Rechenprüfung / Korrekturen
+- Statistik-Ø/Monat = geplanter Jahresbetrag / 12.
+- Gesamtkennzahlen sind unabhängig vom Dashboard-Schalter für periodische Kosten.
+- Einmalige Positionen zählen genau einmal im ausgewählten Jahr.
+- Fällige periodische Positionen werden anhand ihrer echten Fälligkeitsmonate berücksichtigt.
+- Periodische Positionen ohne Fälligkeitsmonat werden nachvollziehbar auf den Jahresdurchschnitt normalisiert.
+- Betragsänderungen und Enddaten werden innerhalb des ausgewählten Jahres berücksichtigt.
+- Einzelkonto + Personenfilter: echte Eingänge folgen der Bewegungs-Personenzuordnung.
+- Alle Konten + Personenfilter: persönliches Einkommen folgt weiterhin dem Kontoeigentum.
+- "Ohne Kategorie" zählt nur echte Ein- und Ausgänge und bleibt vom Transfer-Schalter unabhängig.
+- Kontoliquidität bleibt unabhängig vom Transferdetails-Schalter immer inklusive Transfers.
+
+Datenmodell
+- Keine Migration.
+- finance.json bleibt version 11.
diff --git a/README.md b/README.md
index 4310941..b31b39d 100644
--- a/README.md
+++ b/README.md
@@ -142,3 +142,16 @@ Die Kategorienverwaltung wurde vollständig aus der Statistik entfernt. Kategori
### v3.30 – einmalige geplante Positionen
Eingänge, Ausgänge und Transfers unterstützen zusätzlich den Intervalltyp **Einmalig**. Dafür wird ein konkretes Datum (`oneTimeDate`) hinterlegt. Die Position wird nur im betreffenden Monat der Jahresplanung, Statistiken und Kontoauszüge berücksichtigt und nicht auf einen Monatsdurchschnitt verteilt. Einmalige Ausgaben können wie andere Ausgaben per Pushover im Zielmonat erinnert werden.
+
+### v3.31 – Transferanalyse
+
+Unter **Auswertung → Transferanalyse** können ein oder mehrere Quellkonten und ein oder mehrere Zielkonten gleichzeitig ausgewählt werden. Die Analyse zeigt das geplante Transfervolumen des gewählten Jahres je Quellkonto, die prozentuale und absolute Verteilung auf die Zielkonten, die einzelnen zugrunde liegenden Transfers sowie eine Quelle-Ziel-Matrix. Einmalige Transfers werden nur in ihrem Zieljahr berücksichtigt; periodische Transfers ohne konkreten Fälligkeitsmonat werden für den Jahreswert über ihren Monatsdurchschnitt normalisiert.
+
+
+### v3.32 – nachvollziehbare und geprüfte Statistik
+
+Die Statistik ist jetzt jahresbezogen und erklärt ihre Rechenwege direkt in der Oberfläche. **Ø / Monat** bedeutet durchgehend: der im gewählten Kalenderjahr eingeplante Betrag geteilt durch 12. Dadurch werden monatliche, periodische und einmalige Positionen auf einer konsistenten Basis verglichen. Die Statistik ist damit unabhängig vom Dashboard-Schalter, der periodische Kosten nur für den aktuellen Monats-Saldo ein- oder ausblendet.
+
+Gesamtkennzahlen und Kennzahlen der aktuellen Konto-/Personenauswahl sind visuell getrennt. Jede zentrale Kennzahl besitzt einen aufklappbaren Rechenweg bzw. eine Zusammensetzung aus Einzelpositionen; zusätzliche Info-Buttons öffnen ausführliche Erläuterungen zu Aussage und Formel. Kategorieauswertungen zeigen neben dem Monatsdurchschnitt auch den Jahresbetrag und lassen sich bis zu den einzelnen Positionen aufklappen. Gemeinschaftsfinanzierung, einkommensbasierte Vergleichssicht und Transferarten besitzen ebenfalls Rechenweg-/Detailakkordeons.
+
+Bei der Rechenprüfung wurden zwei semantische Inkonsistenzen korrigiert: Die Gesamtkennzahlen verwenden nun immer alle für das gewählte Jahr geplanten periodischen und einmaligen Positionen, unabhängig von der Dashboard-Einstellung. Außerdem verwendet ein ausgewähltes Einzelkonto bei Personenfiltern für echte Eingänge die Bewegungs-Personenzuordnung, während die Gesamt-Personensicht persönliches Einkommen weiterhin korrekt aus dem Kontoeigentum ableitet. Der Zähler **Ohne Kategorie** zählt nur echte Ein- und Ausgänge und ändert sich nicht mehr durch das Ein-/Ausblenden von Transferdetails. Das JSON-Datenmodell bleibt bei `version: 11`.
diff --git a/public/sw.js b/public/sw.js
index b28177e..4c3082a 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,4 +1,4 @@
-const SHELL_CACHE='fixfin-shell-v3.30';
+const SHELL_CACHE='fixfin-shell-v3.32';
const DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
diff --git a/server/index.js b/server/index.js
index 2bb81c6..c34dda6 100644
--- a/server/index.js
+++ b/server/index.js
@@ -116,7 +116,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile,
async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};}
async function sendPushover(config,{title,message}){
const body=new URLSearchParams({token:config.token,user:config.user,title,message});
- const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.24'},body});
+ const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.32'},body});
let payload={}; try{payload=await response.json();}catch{}
if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
return payload;
diff --git a/src/main.jsx b/src/main.jsx
index e92171c..5defad5 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -300,14 +300,17 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year, includeTransfers };
}
-function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true) {
+function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true, year = new Date().getFullYear()) {
const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name]));
const isAll = accountId === 'all';
const accountById=id=>data.accounts.find(a=>a.id===id);
const incomeFactor=item=>{
if(personId==='all')return 1;
- const account=accountById(item.accountId);
- return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0;
+ if(isAll){
+ const account=accountById(item.accountId);
+ return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0;
+ }
+ return personFactor(item,personId);
};
const defaultFactor=item=>personFactor(item,personId);
const aggregate = (items,factorFn=defaultFactor) => {
@@ -317,13 +320,16 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe
if(!factor) continue;
const key = item.categoryId || '__uncategorized__';
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie';
- const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' };
- const amount = monthlyEquivalent(item) * factor;
- current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current);
+ const current = buckets.get(key) || { id:key, name, amount:0, annual:0, count:0, items:[], uncategorized:key==='__uncategorized__' };
+ const annual = plannedAmountForYear(item,year) * factor;
+ if(!annual)continue;
+ const amount = annual/12;
+ current.amount += amount; current.annual += annual; current.count += 1; current.items.push({...item, statAmount:amount, statAnnual:annual}); buckets.set(key,current);
}
const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0);
+ const annual = [...buckets.values()].reduce((sum,x)=>sum+x.annual,0);
const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0}));
- return { total, rows };
+ return { total, annual, rows };
};
const incomeItems=(data.incomes||[]).filter(x=>(isAll||x.accountId===accountId)&&incomeFactor(x)>0).map(x=>({...x,_statType:'income',_statFactor:incomeFactor(x)}));
const expenseItems=(data.expenses||[]).filter(x=>(isAll||x.accountId===accountId)&&defaultFactor(x)>0).map(x=>({...x,_statType:'expense',_statFactor:defaultFactor(x)}));
@@ -331,7 +337,57 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe
const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out';
return {...x,_statType:direction==='in'?'transfer-in':direction==='out'?'transfer-out':'transfer',_statFactor:transferPersonFactor(data,x,personId,accountId)};
}):[];
- return { incomes:aggregate(incomeItems,x=>x._statFactor), expenses:aggregate(expenseItems,x=>x._statFactor), transfers:aggregate(transferItems,x=>x._statFactor), includeTransfers };
+ return { incomes:aggregate(incomeItems,x=>x._statFactor), expenses:aggregate(expenseItems,x=>x._statFactor), transfers:aggregate(transferItems,x=>x._statFactor), includeTransfers, year };
+}
+
+function plannedAmountForYear(item, year){
+ if(isOnce(item)){
+ const when=dateMonth(item.oneTimeDate);
+ if(!when||Number(when.slice(0,4))!==Number(year))return 0;
+ return effectiveAmount(item,year,Number(when.slice(5,7))-1);
+ }
+ const interval=item.interval||'monthly';
+ const hasDue=Number.isInteger(Number(item.dueMonth))&&Number(item.dueMonth)>=1&&Number(item.dueMonth)<=12;
+ let total=0;
+ for(let m=0;m<12;m++){
+ if(interval==='monthly'){
+ if(itemActiveInMonth(item,year,m))total+=effectiveAmount(item,year,m);
+ } else if(hasDue){
+ if(occursInMonth(item,m,year))total+=effectiveAmount(item,year,m);
+ } else if(itemActiveInMonth(item,year,m)){
+ total+=effectiveAmount(item,year,m)/(intervals[interval]?.divisor||1);
+ }
+ }
+ return total;
+}
+function statMonthlyAverage(item,year){return plannedAmountForYear(item,year)/12;}
+function statAmountMeta(item){
+ if(isOnce(item))return item.oneTimeDate?`einmalig ${formatDate(item.oneTimeDate)}`:'einmalig';
+ if(item.interval==='monthly'||!item.interval)return 'monatlich';
+ if(item.dueMonth)return `${intervals[item.interval]?.label||item.interval} · ab ${months[Number(item.dueMonth)-1]}`;
+ return `${intervals[item.interval]?.label||item.interval} · auf Monatsdurchschnitt verteilt`;
+}
+
+function transferAnalysis(data, sourceIds, targetIds, year){
+ const sources=new Set(sourceIds||[]),targets=new Set(targetIds||[]);
+ const rows=(data.transfers||[]).filter(item=>sources.has(item.fromAccountId)&&targets.has(item.toAccountId)).map(item=>{
+ const annual=plannedAmountForYear(item,year);
+ return {...item,annual,monthly:annual/12,classification:transferClassification(data,item)};
+ }).filter(item=>item.annual!==0);
+ const bySource=new Map();const byTarget=new Map();
+ for(const item of rows){
+ if(!bySource.has(item.fromAccountId))bySource.set(item.fromAccountId,{accountId:item.fromAccountId,annual:0,monthly:0,targets:new Map(),items:[]});
+ const source=bySource.get(item.fromAccountId);source.annual+=item.annual;source.monthly+=item.monthly;source.items.push(item);
+ const target=source.targets.get(item.toAccountId)||{accountId:item.toAccountId,annual:0,monthly:0,items:[]};target.annual+=item.annual;target.monthly+=item.monthly;target.items.push(item);source.targets.set(item.toAccountId,target);
+ const targetTotal=byTarget.get(item.toAccountId)||{accountId:item.toAccountId,annual:0,monthly:0,items:[]};targetTotal.annual+=item.annual;targetTotal.monthly+=item.monthly;targetTotal.items.push(item);byTarget.set(item.toAccountId,targetTotal);
+ }
+ const annual=rows.reduce((sum,item)=>sum+item.annual,0);const monthly=annual/12;
+ return {
+ rows:rows.sort((a,b)=>b.annual-a.annual||a.name.localeCompare(b.name,'de')),
+ sources:[...bySource.values()].map(source=>({...source,targets:[...source.targets.values()].sort((a,b)=>b.annual-a.annual)})).sort((a,b)=>b.annual-a.annual),
+ targets:[...byTarget.values()].sort((a,b)=>b.annual-a.annual),
+ annual,monthly,
+ };
}
@@ -345,7 +401,7 @@ function sharedOwnershipGroups(data){
}
return [...groups.values()];
}
-function personalMonthlyIncomeByPerson(data){
+function personalMonthlyIncomeByPerson(data,year){
const totals=new Map();
for(const person of data.persons||[])totals.set(person.id,0);
for(const item of data.incomes||[]){
@@ -354,33 +410,36 @@ function personalMonthlyIncomeByPerson(data){
const owners=ownerIdsOf(account);
if(owners.length!==1)continue;
const id=owners[0];
- totals.set(id,(totals.get(id)||0)+monthlyEquivalent(item));
+ totals.set(id,(totals.get(id)||0)+statMonthlyAverage(item,year));
}
return totals;
}
-function sharedFinanceStats(data, personId='all'){
- const personalIncomeMap=personalMonthlyIncomeByPerson(data);
+
+function sharedFinanceStats(data, personId='all', year=new Date().getFullYear()){
+ const personalIncomeMap=personalMonthlyIncomeByPerson(data,year);
const groups=sharedOwnershipGroups(data).map(group=>{
const accountIds=new Set(group.accounts.map(a=>a.id));
- const income=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0);
- const expenses=(data.expenses||[]).filter(x=>accountIds.has(x.accountId));
- const expense=expenses.reduce((sum,x)=>sum+monthlyEquivalent(x),0);
- const internalTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&accountIds.has(x.toAccountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0);
- const incomingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.toAccountId)&&!accountIds.has(x.fromAccountId));
- const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId));
- const fundingIn=incomingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0);
- const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0);
+ const incomeItems=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual);
+ const expenseItems=(data.expenses||[]).filter(x=>accountIds.has(x.accountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual);
+ const income=incomeItems.reduce((sum,x)=>sum+x.statAmount,0);
+ const expense=expenseItems.reduce((sum,x)=>sum+x.statAmount,0);
+ const internalTransferItems=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&accountIds.has(x.toAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual);
+ const incomingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.toAccountId)&&!accountIds.has(x.fromAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual);
+ const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual);
+ const internalTransfers=internalTransferItems.reduce((sum,x)=>sum+x.statAmount,0);
+ const fundingIn=incomingTransfers.reduce((sum,x)=>sum+x.statAmount,0);
+ const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+x.statAmount,0);
const sharedIncomeCoverage=Math.min(income,expense);
const sharedIncomeSurplus=Math.max(0,income-expense);
const costNeed=Math.max(0,expense-income);
const netAssetChange=income+fundingIn-expense-withdrawalOut;
const shares=new Map();
- const ensure=id=>{const key=id||'__unassigned__';if(!shares.has(key))shares.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),grossCost:0,targetFunding:0,actualFunding:0,difference:0});return shares.get(key);};
+ const ensure=id=>{const key=id||'__unassigned__';if(!shares.has(key))shares.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),grossCost:0,targetFunding:0,actualFunding:0,difference:0,costItems:[],fundingItems:[]});return shares.get(key);};
for(const id of group.ownerPersonIds)ensure(id);
- for(const item of expenses){
- const amount=monthlyEquivalent(item);const ids=personIdsOf(item);
- if(!ids.length){ensure('__unassigned__').grossCost+=amount;continue;}
- for(const id of ids)ensure(id).grossCost+=amount/ids.length;
+ for(const item of expenseItems){
+ const amount=item.statAmount;const ids=personIdsOf(item);
+ if(!ids.length){const row=ensure('__unassigned__');row.grossCost+=amount;row.costItems.push({...item,personStatAmount:amount});continue;}
+ for(const id of ids){const row=ensure(id);const share=amount/ids.length;row.grossCost+=share;row.costItems.push({...item,personStatAmount:share});}
}
const totalGross=[...shares.values()].reduce((sum,row)=>sum+row.grossCost,0);
for(const row of shares.values()){
@@ -390,9 +449,9 @@ function sharedFinanceStats(data, personId='all'){
}
for(const item of incomingTransfers){
const from=data.accounts.find(a=>a.id===item.fromAccountId);if(from?.ownership!=='personal')continue;
- const amount=monthlyEquivalent(item);const ids=ownerIdsOf(from);
- if(!ids.length){ensure('__unassigned__').actualFunding+=amount;continue;}
- for(const id of ids)ensure(id).actualFunding+=amount/ids.length;
+ const amount=item.statAmount;const ids=ownerIdsOf(from);
+ if(!ids.length){const row=ensure('__unassigned__');row.actualFunding+=amount;row.fundingItems.push({...item,personStatAmount:amount});continue;}
+ for(const id of ids){const row=ensure(id);const share=amount/ids.length;row.actualFunding+=share;row.fundingItems.push({...item,personStatAmount:share});}
}
const totalPersonalFunding=[...shares.values()].reduce((sum,row)=>sum+row.actualFunding,0);
for(const row of shares.values()){
@@ -403,7 +462,6 @@ function sharedFinanceStats(data, personId='all'){
let personRows=[...shares.values()];
if(personId!=='all')personRows=personRows.filter(x=>x.id===personId);
personRows=personRows.filter(x=>x.grossCost||x.targetFunding||x.actualFunding||group.ownerPersonIds.includes(x.id));
-
const personalIncomeTotal=group.ownerPersonIds.reduce((sum,id)=>sum+(personalIncomeMap.get(id)||0),0);
const incomeAllocationAvailable=personalIncomeTotal>0;
let incomeBurdenRows=group.ownerPersonIds.map(id=>{
@@ -419,12 +477,12 @@ function sharedFinanceStats(data, personId='all'){
});
if(personId!=='all')incomeBurdenRows=incomeBurdenRows.filter(x=>x.id===personId);
const householdBurdenRate=incomeAllocationAvailable?(costNeed/personalIncomeTotal)*100:null;
- return {...group,income,expense,sharedIncomeCoverage,sharedIncomeSurplus,costNeed,fundingIn,withdrawalOut,internalTransfers,netAssetChange,totalPersonalFunding,personRows,personalIncomeTotal,incomeAllocationAvailable,incomeBurdenRows,householdBurdenRate};
+ return {...group,income,expense,incomeItems,expenseItems,sharedIncomeCoverage,sharedIncomeSurplus,costNeed,fundingIn,withdrawalOut,incomingTransfers,outgoingTransfers,internalTransferItems,internalTransfers,netAssetChange,totalPersonalFunding,personRows,personalIncomeTotal,incomeAllocationAvailable,incomeBurdenRows,householdBurdenRate,year};
});
- return {groups};
+ return {groups,year};
}
-function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) {
+function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true, year = new Date().getFullYear()) {
const isAll=accountId==='all';
if(!isAll){
const selectedAccount=data.accounts.find(a=>a.id===accountId);
@@ -444,10 +502,10 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans
if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[];
return ids.map(id=>[id,1/ids.length]);
};
- for(const item of data.incomes||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).income+=amount*factor;}
- for(const item of data.expenses||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;}
+ for(const item of data.incomes||[]){if(item.accountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of splitTargets(item))ensure(id).income+=amount*factor;}
+ for(const item of data.expenses||[]){if(item.accountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;}
// Transfers gehören immer zur echten Kontoliquidität. Der Schalter steuert nur, ob die Details sichtbar sind.
- for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of transferTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}}
+ for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of transferTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}}
for(const row of buckets.values()){row.displayIn=row.income+row.transferIn;row.displayOut=row.expense+row.transferOut;row.net=row.displayIn-row.displayOut;}
let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.displayIn||x.displayOut);rows.sort((a,b)=>b.displayOut-a.displayOut||a.name.localeCompare(b.name,'de'));
const totals=rows.reduce((sum,row)=>({displayIn:sum.displayIn+row.displayIn,displayOut:sum.displayOut+row.displayOut,net:sum.net+row.net}),{displayIn:0,displayOut:0,net:0});
@@ -456,9 +514,9 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans
const buckets=new Map();const ensure=id=>{const key=id||'__unassigned__';if(!buckets.has(key))buckets.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),personalIncome:0,expenseShare:0,sharedExpenseShare:0,sharedCoverage:0,netCost:0,economicAvailable:0,liquidIncome:0,liquidExpense:0,liquidTransferIn:0,liquidTransferOut:0,liquidAvailable:0,sharedFundingTarget:0,sharedFundingActual:0,sharedFundingDifference:0,assetBuild:0});return buckets.get(key);};
for(const person of data.persons||[])ensure(person.id);
- for(const item of data.incomes||[]){const account=data.accounts.find(a=>a.id===item.accountId);if(account?.ownership==='personal'){for(const id of ownerIdsOf(account))ensure(id).personalIncome+=monthlyEquivalent(item);}}
- for(const item of data.expenses||[]){const amount=monthlyEquivalent(item),ids=personIdsOf(item);const account=data.accounts.find(a=>a.id===item.accountId);const targets=ids.length?ids:['__unassigned__'];for(const id of targets){const row=ensure(id);const share=amount/targets.length;row.expenseShare+=share;if(account?.ownership==='shared')row.sharedExpenseShare+=share;}}
- const shared=sharedFinanceStats(data,'all');
+ for(const item of data.incomes||[]){const account=data.accounts.find(a=>a.id===item.accountId);if(account?.ownership==='personal'){for(const id of ownerIdsOf(account))ensure(id).personalIncome+=statMonthlyAverage(item,year);}}
+ for(const item of data.expenses||[]){const amount=statMonthlyAverage(item,year),ids=personIdsOf(item);const account=data.accounts.find(a=>a.id===item.accountId);const targets=ids.length?ids:['__unassigned__'];for(const id of targets){const row=ensure(id);const share=amount/targets.length;row.expenseShare+=share;if(account?.ownership==='shared')row.sharedExpenseShare+=share;}}
+ const shared=sharedFinanceStats(data,'all',year);
for(const group of shared.groups){const covered=Math.min(group.income,group.expense);const totalCost=group.personRows.reduce((sum,r)=>sum+r.grossCost,0);for(const pr of group.personRows){const row=ensure(pr.id);row.sharedCoverage+=totalCost>0?covered*(pr.grossCost/totalCost):0;row.sharedFundingTarget+=pr.targetFunding;row.sharedFundingActual+=pr.actualFunding;}}
// Persönlich verfügbare Liquidität wird ausschließlich aus persönlichen Zahlungsverkehrskonten abgeleitet.
@@ -466,57 +524,93 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans
// ohne dadurch zu Konsumausgaben zu werden.
const liquidAccountOwner=new Map();
for(const account of data.accounts||[]){if(account.ownership!=='personal'||isAssetAccount(account))continue;const ids=ownerIdsOf(account);if(ids.length===1)liquidAccountOwner.set(account.id,ids[0]);}
- for(const item of data.incomes||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidIncome+=monthlyEquivalent(item);}
- for(const item of data.expenses||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidExpense+=monthlyEquivalent(item);}
+ for(const item of data.incomes||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidIncome+=statMonthlyAverage(item,year);}
+ for(const item of data.expenses||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidExpense+=statMonthlyAverage(item,year);}
for(const item of data.transfers||[]){
- const amount=monthlyEquivalent(item);const fromId=liquidAccountOwner.get(item.fromAccountId),toId=liquidAccountOwner.get(item.toAccountId);
+ const amount=statMonthlyAverage(item,year);const fromId=liquidAccountOwner.get(item.fromAccountId),toId=liquidAccountOwner.get(item.toAccountId);
if(fromId)ensure(fromId).liquidTransferOut+=amount;
if(toId)ensure(toId).liquidTransferIn+=amount;
}
// Vermögensaufbau bleibt eine separate wirtschaftliche Information und ist unabhängig davon,
// ob die Transferdetails gerade eingeblendet werden.
- for(const item of data.transfers||[]){const from=data.accounts.find(a=>a.id===item.fromAccountId),to=data.accounts.find(a=>a.id===item.toAccountId);if(from?.ownership!=='personal'||!sameOwnershipDomain(from,to))continue;const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to);const delta=!fromAsset&&toAsset?monthlyEquivalent(item):fromAsset&&!toAsset?-monthlyEquivalent(item):0;if(!delta)continue;const ids=ownerIdsOf(from);if(!ids.length){ensure('__unassigned__').assetBuild+=delta;continue;}for(const id of ids)ensure(id).assetBuild+=delta/ids.length;}
+ for(const item of data.transfers||[]){const from=data.accounts.find(a=>a.id===item.fromAccountId),to=data.accounts.find(a=>a.id===item.toAccountId);if(from?.ownership!=='personal'||!sameOwnershipDomain(from,to))continue;const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to);const delta=!fromAsset&&toAsset?statMonthlyAverage(item,year):fromAsset&&!toAsset?-statMonthlyAverage(item,year):0;if(!delta)continue;const ids=ownerIdsOf(from);if(!ids.length){ensure('__unassigned__').assetBuild+=delta;continue;}for(const id of ids)ensure(id).assetBuild+=delta/ids.length;}
for(const row of buckets.values()){row.netCost=Math.max(0,row.expenseShare-row.sharedCoverage);row.economicAvailable=row.personalIncome-row.netCost;row.liquidAvailable=row.liquidIncome-row.liquidExpense+row.liquidTransferIn-row.liquidTransferOut;row.sharedFundingDifference=row.sharedFundingActual-row.sharedFundingTarget;}
let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.personalIncome||x.expenseShare||x.sharedCoverage||x.sharedFundingActual||x.assetBuild||x.liquidIncome||x.liquidExpense||x.liquidTransferIn||x.liquidTransferOut);rows.sort((a,b)=>b.netCost-a.netCost||a.name.localeCompare(b.name,'de'));
const totals=rows.reduce((sum,row)=>({personalIncome:sum.personalIncome+row.personalIncome,expenseShare:sum.expenseShare+row.expenseShare,sharedCoverage:sum.sharedCoverage+row.sharedCoverage,netCost:sum.netCost+row.netCost,economicAvailable:sum.economicAvailable+row.economicAvailable,liquidIncome:sum.liquidIncome+row.liquidIncome,liquidExpense:sum.liquidExpense+row.liquidExpense,liquidTransferIn:sum.liquidTransferIn+row.liquidTransferIn,liquidTransferOut:sum.liquidTransferOut+row.liquidTransferOut,liquidAvailable:sum.liquidAvailable+row.liquidAvailable,sharedFundingTarget:sum.sharedFundingTarget+row.sharedFundingTarget,sharedFundingActual:sum.sharedFundingActual+row.sharedFundingActual,assetBuild:sum.assetBuild+row.assetBuild}),{personalIncome:0,expenseShare:0,sharedCoverage:0,netCost:0,economicAvailable:0,liquidIncome:0,liquidExpense:0,liquidTransferIn:0,liquidTransferOut:0,liquidAvailable:0,sharedFundingTarget:0,sharedFundingActual:0,assetBuild:0});
return {rows,totals,isAll:true,includeTransfers};
}
-function PersonFlowOverview({stats}){
- if(!stats.isAll)return
Personenbezogene Kontoflüsse
Auf einem einzelnen Konto werden tatsächliche Zu- und Abflüsse gezeigt. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur die Transferdetails ein oder aus.
Wirtschaftliche Kosten und echte persönliche Liquidität werden getrennt gezeigt. Transfers verändern die verfügbare Liquidität, bleiben aber außerhalb der Kostenstatistik.
Die Statistik ist eine Planungs- und Durchschnittssicht für das gewählte Kalenderjahr. Sie ist keine Liste tatsächlich gebuchter Bankumsätze.
Ø / Monat bedeutet immer: im Jahr eingeplanter Betrag ÷ 12. Dadurch werden jährliche, vierteljährliche, halbjährliche und einmalige Positionen vergleichbar.
Transfers bleiben wirtschaftlich getrennt von Einnahmen und Kosten. In einer einzelnen Kontosicht gehören sie trotzdem zum Liquiditäts-Nettofluss.
>}>
Alle Ø-Werte beziehen sich auf {year}. Einmalige Positionen zählen genau einmal im Zieljahr. Periodische Positionen mit Fälligkeitsmonat werden in ihren echten Monaten eingeplant.
WirtschaftlichEchte Einnahmen minus echte Ausgaben. Transfers zählen nicht als Gewinn oder Kosten.
PersonenAusgaben werden nach den Personen der Bewegung geteilt. Persönliches Einkommen folgt in der Gesamtansicht dem Eigentümer des persönlichen Kontos.
GemeinschaftGemeinsame Einnahmen decken zuerst gemeinsame Kosten und werden niemals zu persönlichem Einkommen.
LiquiditätBei einem einzelnen Konto werden Transfers rein und raus im Nettofluss vollständig berücksichtigt.
Wirtschaftlich verfügbar beantwortet: Was bleibt nach der persönlichen Kostenlast übrig? Liquid verfügbar beantwortet dagegen: Was bleibt auf persönlichen Zahlungsverkehrskonten nach echten Kontobewegungen übrig?
Diese beiden Werte dürfen unterschiedlich sein. Eine Überweisung ins Depot oder aufs Gemeinschaftskonto ist keine Ausgabe, reduziert aber die liquide Verfügbarkeit.
>;
+ if(!stats.isAll)return
Ø-Monatsfluss {year} auf dem ausgewählten Konto. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur Transferdetails aus.
Wirtschaftliche Kosten und persönliche Liquidität als Ø-Monatswerte {year}. Transfers verändern Liquidität, bleiben aber außerhalb der Kostenstatistik.
Gemeinsame Einnahmen wie Kindergeld bleiben im gemeinsamen Topf. Sie reduzieren den Finanzierungsbedarf, werden aber keiner Person als persönliche Einnahme zugerechnet.
Ist-Beitrag % = Anteil dieser Person an allen tatsächlichen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf = wie viel des verbleibenden Finanzierungsbedarfs diese Person tatsächlich deckt.
}{!showTransfers&&
Transferdetails sind ausgeblendet. Kostenanteil und Soll-Finanzierung bleiben sichtbar, tatsächliche Beiträge werden nicht angezeigt.
Soll-Finanzierung verteilt diesen Bedarf im Verhältnis der tatsächlich zugeordneten gemeinsamen Kosten. Ist-Beitrag stammt aus Transfers von persönlichen Konten in diesen Gemeinschaftsbereich und wird dem Eigentümer des Quellkontos zugerechnet.
Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} Ø / Monat. Sie sind weder Kosten noch externe Finanzierung.
Ist-Beitrag % ist der Anteil an allen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf zeigt, wie viel des verbleibenden Kostenbedarfs diese Person tatsächlich deckt.
}{!showTransfers&&
Transferdetails sind ausgeblendet. Kostenanteil und Soll bleiben sichtbar; tatsächliche Beiträge nicht.
Gemeinsame Kosten gemessen am persönlichen Einkommen
Gemeinsame Einnahmen decken zuerst gemeinsame Kosten. Nur der verbleibende Finanzierungsbedarf wird im Verhältnis der persönlichen Einkommen auf die Eigentümer des Gemeinschaftsbereichs verteilt.
Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} / Monat. Es ist keine persönliche Finanzierung nötig.
}{!group.incomeAllocationAvailable?
Keine persönlichen Einnahmen auf persönlich zugeordneten Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.
Soll nach Einkommen{row.incomeBasedFunding==null?'–':`${formatMoney(row.incomeBasedFunding)} · ${percent(row.incomeShare==null?null:row.incomeShare*100)}`}
Abweichung Soll / Ist=0?'good-text':'bad-text'}>{row.difference==null?'–':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}
>}
)}
}
{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen in den Gemeinschaftsbereich. Die %-Punkt-Abweichung macht sichtbar, wer relativ zum einkommensbasierten Soll mehr oder weniger beiträgt. ':'Diese Statistik ist eine Vergleichs- und Fairnesssicht. '}Sie verändert weder die hinterlegte Personenzuordnung der Kosten noch die tatsächlichen Kontosalden.
)}
;
+ const help=<>
Diese Sicht ist ein Fairnessvergleich, keine automatische Zahlungsregel.
Zuerst werden gemeinsame Einnahmen von gemeinsamen Kosten abgezogen. Der verbleibende Bedarf wird dann proportional zu den persönlichen Einkommen der Eigentümer verteilt. Deshalb entspricht der Soll-% dem Einkommensanteil.
Die Belastungsquote des Haushalts ist verbleibender Bedarf ÷ Summe der persönlichen Einkommen. Bei proportionaler Verteilung ist diese Quote für alle Personen mit Einkommen gleich.
>;
+ return
Ø-Monatswerte {year}. Diese Vergleichssicht zeigt, wie der verbleibende Gemeinschaftsbedarf bei einkommensproportionaler Finanzierung verteilt wäre.
Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} Ø / Monat. Es ist keine persönliche Finanzierung nötig.
}{!group.incomeAllocationAvailable?
Keine persönlichen Einnahmen auf persönlichen Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.
Abweichung Soll / Ist=0?'good-text':'bad-text'}>{row.difference==null?'–':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}
>}
)}
}Rechenweg und Einkommensquellen ⌄
Persönliche Einkommensquellen
{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen. Die %-Punkt-Abweichung zeigt Über- oder Unterbeitrag relativ zum einkommensbasierten Soll. ':'Transferdetails sind ausgeblendet. '}Diese Sicht verändert keine Bewegungen oder Kontosalden.
Transfers werden nach Eigentum und Kontofunktion automatisch klassifiziert, z. B. Gemeinschaftsfinanzierung, Rücklagenzuführung oder Transfer zwischen Personen.
Die Beträge sind Ø-Monatswerte des gewählten Jahres. Sie werden nie zu echten Einnahmen oder Kosten addiert. Bei einem einzelnen Konto verändern sie aber den Liquiditäts-Nettofluss.
>;
+ return
Separater Blick auf Umbuchungen {year}. Jede Karte lässt sich bis zu den konkreten Transfers aufklappen.
Der Kategorienwert ist die Summe der Ø-Monatswerte {year} aller enthaltenen Positionen. Jede Position wird zuerst für das ganze Jahr geplant und anschließend durch 12 geteilt.
Bei einem Personenfilter werden gemeinsam zugeordnete Ausgaben gleichmäßig auf die ausgewählten Personen aufgeteilt. In der Gesamt-Personensicht werden persönliche Einnahmen nach Kontoeigentum bestimmt.
Der Prozentwert einer Kategorie ist Kategorienwert ÷ Gesamtsumme dieses Blocks.
>;
+ return
{subtitle}
{formatMoney(stats.total)} Ø / Monat{formatMoney(stats.annual)} / Jahr
Die Gesamtkennzahlen beziehen sich immer auf alle Konten und alle Personen im ausgewählten Jahr. Die Konto- und Personenfilter darunter verändern sie bewusst nicht.
Anders als früher hängen diese Werte nicht mehr vom Dashboard-Schalter „periodische Kosten im Monats-Saldo“ ab. Für Statistik werden alle geplanten Positionen des Jahres vollständig berücksichtigt.
>;
return
-
Gesamtkennzahlen
Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.
=0} negative={totals.balance<0} featured/>
-
Statistik für{selectedName} · {selectedPerson}
+
+
Gesamthaushalt, unabhängig von den Konto-/Personenfiltern darunter. Alle Monatswerte sind Jahresdurchschnitte.
+ =0?'good':'bad'} featured help={<>
Echte Einnahmen minus echte Ausgaben. Transfers werden vollständig ignoriert, weil sie Geld nur zwischen eigenen Töpfen verschieben.
Berechnung: ({formatMoney(kpis.annualIncome)} Einnahmen im Jahr − {formatMoney(kpis.annualExpense)} Ausgaben im Jahr) ÷ 12.
>}>Einzelne Eingänge und Kosten
Eingänge
Kosten
+
Anteil der echten geplanten Kosten an den echten geplanten Einnahmen im Jahr {year}.
100 % bedeutet: alle Einnahmen werden durch echte Kosten verbraucht. Transfers spielen keine Rolle.
>}>
+
Summe aller echten Ausgaben, die im gewählten Jahr geplant sind. Einmalige Ausgaben zählen genau einmal, periodische Ausgaben entsprechend ihrer Fälligkeiten.
Transfers sind keine Kosten und fehlen deshalb hier.
>}>
+
Jährliche, halbjährliche und vierteljährliche echte Ausgaben werden auf einen durchschnittlichen Monatsbedarf umgerechnet.
Monatliche und einmalige Ausgaben gehören nicht in diesen Wert. Er sagt, wie viel rechnerisch pro Monat für periodische Kosten zurückgelegt werden müsste.
>}>
+
Monat mit der höchsten Summe echter fälliger Ausgaben in {year}. Hier werden konkrete Fälligkeiten und einmalige Positionen berücksichtigt.
Transfers bleiben außen vor.
>}>
+
Monat mit der niedrigsten Summe echter fälliger Ausgaben in {year}. Ein Monat ohne geplante Kosten kann deshalb 0 € haben.
>}>
+
+
Auswahl für Detailstatistik{selectedName} · {selectedPerson} · {year}
Diese vier Karten reagieren auf Konto, Person und Jahr.
Bei Alle Konten + Person wird eine wirtschaftliche Personensicht verwendet: persönliches Einkommen folgt Kontoeigentum, Kosten folgen der Bewegungszuordnung und gemeinsame Einnahmen reduzieren die gemeinsame Kostenlast.
Bei einem einzelnen Konto wechselt die dritte Karte bewusst in eine Liquiditätssicht. Transfers sind dann im Nettofluss enthalten, auch wenn Transferdetails ausgeblendet sind.
>}/>
+
{accountId==='all'&&personId!=='all'?'Nur echte Eingänge auf persönlichen Konten, deren Eigentümer die ausgewählte Person ist. Gemeinsame Einnahmen werden nicht als persönliches Einkommen gezählt.':accountId!=='all'&&personId!=='all'?'Echte Eingänge auf dem ausgewählten Konto, anteilig gemäß Personenzuordnung der Bewegung.':'Echte Eingänge der aktuellen Auswahl.'}
Jeder Wert ist Jahresbetrag ÷ 12.
>}>
+
Nur echte Ausgaben. Bei einer Person wird eine Bewegung mit mehreren zugeordneten Personen gleichmäßig geteilt.
Transfers bleiben vollständig getrennt.
>}>
+ =0?'good':'bad'} help={<>
{accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo: echte Einnahmen minus echte Kosten.':'Wirtschaftlich verfügbar: persönliches Einkommen minus persönliche Netto-Kostenlast nach gemeinsamer Deckung.'):'Liquiditäts-Nettofluss des Kontos: echte Eingänge + Transfers rein − echte Ausgaben − Transfers raus.'}
>}>
+
Anzahl echter Eingangs- und Ausgangspositionen der aktuellen Auswahl ohne Kategorie.
Transfers werden hier bewusst nicht mitgezählt, damit der Wert nicht vom Schalter „Transferdetails anzeigen“ abhängt.