diff --git a/README.md b/README.md index 4ba281b..a4d49cb 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,8 @@ In der Statistik gibt es zusätzlich eine einkommensbasierte Vergleichssicht fü Die Gemeinschaftsfinanzierung zeigt je Person zusätzlich Prozentwerte. **Kostenanteil** ist der Anteil der Person an den zugeordneten gemeinsamen Kosten. **Soll-Finanzierung %** zeigt den daraus resultierenden Anteil am verbleibenden Finanzierungsbedarf nach Abzug gemeinsamer Einnahmen. **Ist-Beitrag %** zeigt den Anteil der Person an allen tatsächlichen persönlichen Einzahlungen in diesen Gemeinschaftsbereich. **Anteil am Bedarf** zeigt, wie viel des verbleibenden Finanzierungsbedarfs durch die tatsächliche Einzahlung dieser Person gedeckt wird. In der einkommensbasierten Vergleichssicht werden Soll-% (Einkommensanteil) und Ist-% direkt gegenübergestellt; die Abweichung wird zusätzlich in Prozentpunkten angezeigt. + + +### v3.27 – gerichtete Personentransfers und verfügbare Liquidität + +Transfers zwischen persönlichen Konten unterschiedlicher Eigentümer werden in personenbezogenen Auswertungen aus den Kontoeigentümern als Sender → Empfänger abgeleitet. Der globale Schalter blendet Transferdetails nur noch ein oder aus; Kontoliquidität und verfügbare Beträge bleiben immer inklusive Transfers. In der persönlichen Statistik werden wirtschaftlich verfügbar und liquid verfügbar getrennt ausgewiesen. diff --git a/public/sw.js b/public/sw.js index 556dc27..2c6a552 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.26'; +const SHELL_CACHE='fixfin-shell-v3.27'; 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/reports.js b/server/reports.js index 7d54fc6..0f29824 100644 --- a/server/reports.js +++ b/server/reports.js @@ -25,6 +25,22 @@ function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohn 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:[]})); @@ -37,7 +53,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all',includeTr 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=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; + 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});} @@ -52,11 +68,11 @@ 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:personLabel(data,item),category:categoryName(data,item.categoryId),amount}); + 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=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ + 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); }} diff --git a/src/main.jsx b/src/main.jsx index 1aee0a1..f22311b 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -137,6 +137,37 @@ function transferClassification(data,item){ return {key:'other',label:'Sonstiger Transfer',description:'Eigentumsbereich wechselt'}; } function transferClassLabel(data,item){return transferClassification(data,item).label;} +function transferDirectionalOwnerIds(data,item,direction='any'){ + const from=data.accounts.find(a=>a.id===item.fromAccountId); + const to=data.accounts.find(a=>a.id===item.toAccountId); + if(direction==='out')return ownerIdsOf(from); + if(direction==='in')return ownerIdsOf(to); + return [...new Set([...ownerIdsOf(from),...ownerIdsOf(to)])]; +} +function transferPersonFactor(data,item,personId='all',accountId='all'){ + if(personId==='all')return 1; + const cls=transferClassification(data,item); + const from=data.accounts.find(a=>a.id===item.fromAccountId); + const to=data.accounts.find(a=>a.id===item.toAccountId); + let ids=[]; + if(cls.key==='shared-funding')ids=ownerIdsOf(from); + else if(cls.key==='shared-withdrawal')ids=ownerIdsOf(to); + else if(cls.key==='person-transfer'){ + if(accountId!=='all'&&item.fromAccountId===accountId)ids=ownerIdsOf(from); + else if(accountId!=='all'&&item.toAccountId===accountId)ids=ownerIdsOf(to); + else ids=transferDirectionalOwnerIds(data,item,'any'); + } else if(sameOwnershipDomain(from,to)&&from?.ownership==='personal')ids=ownerIdsOf(from); + else return personFactor(item,personId); + return ids.includes(personId)?1:0; +} +function transferPeopleLabel(data,item){ + if(transferClassification(data,item).key!=='person-transfer')return personListLabel(data,item); + const fromIds=transferDirectionalOwnerIds(data,item,'out'); + const toIds=transferDirectionalOwnerIds(data,item,'in'); + const from=fromIds.length?fromIds.map(id=>personName(data,id)).join(', '):'Unbekannt'; + const to=toIds.length?toIds.map(id=>personName(data,id)).join(', '):'Unbekannt'; + return `${from} → ${to}`; +} function calc(data, includeTransfers = true) { const includePeriodic = data.settings?.includePeriodicInBalance !== false; @@ -199,7 +230,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() }; const relevantIncome = item => incomeFactor(item)>0 && (isAll || item.accountId === accountId); const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId); - const relevantTransfer = item => personMatches(item,personId) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); + const relevantTransfer = item => transferPersonFactor(data,item,personId,accountId)>0 && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); for (const item of data.incomes.filter(relevantIncome)) { const factor=incomeFactor(item); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } @@ -209,7 +240,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } } if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) { - const factor=personFactor(item,personId); + const factor=transferPersonFactor(data,item,personId,accountId); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount = Number(item.amount || 0) * factor; if (isAll) { @@ -272,9 +303,9 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe }; 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)})); - const transferItems=includeTransfers?(data.transfers||[]).filter(x=>defaultFactor(x)>0&&(isAll||x.fromAccountId===accountId||x.toAccountId===accountId)).map(x=>{ + const transferItems=includeTransfers?(data.transfers||[]).filter(x=>transferPersonFactor(data,x,personId,accountId)>0&&(isAll||x.fromAccountId===accountId||x.toAccountId===accountId)).map(x=>{ const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out'; - return {...x,_statType:direction==='in'?'transfer-in':direction==='out'?'transfer-out':'transfer',_statFactor:defaultFactor(x)}; + 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 }; } @@ -372,33 +403,65 @@ function sharedFinanceStats(data, personId='all'){ function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { const isAll=accountId==='all'; if(!isAll){ + const selectedAccount=data.accounts.find(a=>a.id===accountId); 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),income:0,expense:0,transferIn:0,transferOut:0,displayIn:0,displayOut:0,net:0});return buckets.get(key);}; const splitTargets=item=>{const ids=personIdsOf(item);if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[];if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[];return ids.map(id=>[id,1/ids.length]);}; + const transferTargets=item=>{ + const direction=item.fromAccountId===accountId?'out':'in'; + const cls=transferClassification(data,item); + let ids=[]; + if(selectedAccount?.ownership==='personal')ids=ownerIdsOf(selectedAccount); + else if(cls.key==='shared-funding')ids=transferDirectionalOwnerIds(data,item,'out'); + else if(cls.key==='shared-withdrawal')ids=transferDirectionalOwnerIds(data,item,'in'); + else if(cls.key==='person-transfer')ids=transferDirectionalOwnerIds(data,item,direction); + else ids=personIdsOf(item); + if(!ids.length)ids=ownerIdsOf(selectedAccount); + if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[]; + 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;} - if(includeTransfers)for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(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+(includeTransfers?row.transferIn:0);row.displayOut=row.expense+(includeTransfers?row.transferOut:0);row.net=row.displayIn-row.displayOut;} + // 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 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}); return {rows,totals,isAll:false,includeTransfers}; } - 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,available:0,sharedFundingTarget:0,sharedFundingActual:0,sharedFundingDifference:0,assetBuild:0});return buckets.get(key);}; + 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 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;}} - if(includeTransfers)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 row of buckets.values()){row.netCost=Math.max(0,row.expenseShare-row.sharedCoverage);row.available=row.personalIncome-row.netCost;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);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,available:sum.available+row.available,sharedFundingTarget:sum.sharedFundingTarget+row.sharedFundingTarget,sharedFundingActual:sum.sharedFundingActual+row.sharedFundingActual,assetBuild:sum.assetBuild+row.assetBuild}),{personalIncome:0,expenseShare:0,sharedCoverage:0,netCost:0,available:0,sharedFundingTarget:0,sharedFundingActual:0,assetBuild:0}); + + // Persönlich verfügbare Liquidität wird ausschließlich aus persönlichen Zahlungsverkehrskonten abgeleitet. + // So reduzieren Sparen, Gemeinschaftsfinanzierung und Transfers an andere Personen den verfügbaren Betrag, + // 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.transfers||[]){ + const amount=monthlyEquivalent(item);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 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 bleiben als Transfers getrennt erkennbar.

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Nettofluss auf diesem Konto
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
}
; - return

Persönliche wirtschaftliche Sicht

Eigene Einnahmen entstehen nur auf persönlich zugeordneten Konten. Gemeinsame Einnahmen erhöhen kein persönliches Einkommen, sondern reduzieren die gemeinsame Kostenlast.

=0?'good-text':'bad-text'}`}>{stats.totals.available>=0?'+':''}{formatMoney(stats.totals.available)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Persönliche Einnahmen minus wirtschaftliche Kostenlast
=0?'good-text':'bad-text'}>{row.available>=0?'+':''}{formatMoney(row.available)}
Eigene Kontoeingänge+{formatMoney(row.personalIncome)}
Kostenanteil gesamt−{formatMoney(row.expenseShare)}
Gemeinsame Deckung+{formatMoney(row.sharedCoverage)}
Netto-Kostenlast−{formatMoney(row.netCost)}
{stats.includeTransfers&&<>
Gemeinschaft finanziert{formatMoney(row.sharedFundingActual)}
Vermögensaufbau netto=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}
}
)}
}
; + 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.

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Nettofluss auf diesem Konto
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
Zuflüsse inkl. Transfers+{formatMoney(row.displayIn)}
Abflüsse inkl. Transfers−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
}
; + return

Persönliche Sicht

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.

=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} / Monat verfügbar
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Liquid verfügbar auf persönlichen Zahlungsverkehrskonten
=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}
Wirtschaftlich verfügbar=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}
Netto-Kostenlast−{formatMoney(row.netCost)}
Eigene Kontoeingänge+{formatMoney(row.personalIncome)}
Gemeinsame Deckung+{formatMoney(row.sharedCoverage)}
{stats.includeTransfers&&<>
Liquid-Transfers rein+{formatMoney(row.liquidTransferIn)}
Liquid-Transfers raus−{formatMoney(row.liquidTransferOut)}
Gemeinschaft finanziert{formatMoney(row.sharedFundingActual)}
Vermögensaufbau netto=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}
}
)}
}
; } function SharedFundingOverview({data,personId='all',showTransfers=true}){ @@ -517,8 +580,8 @@ const TRANSFER_PREF_KEY='fixfin.include-transfers.v1'; function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}} function TransferToggle({enabled,onChange,compact=false,context='display'}){ const exportMode=context==='export'; - const title=exportMode?'Transfers im Auszug':'Transfer-/Finanzierungsströme anzeigen'; - const copy=exportMode?(enabled?'Transfers werden als eigene Zeilen in PDF und Excel ausgegeben.':'PDF und Excel enthalten nur echte Eingänge und Ausgaben.'):(enabled?'Umbuchungen werden separat sichtbar. Kosten- und Einnahmenstatistiken bleiben davon unberührt.':'Transferdetails sind ausgeblendet. Kontosalden bleiben trotzdem echte Liquiditätssalden.'); + const title=exportMode?'Transfers im Auszug':'Transferdetails anzeigen'; + const copy=exportMode?(enabled?'Transfers werden als eigene Zeilen in PDF und Excel ausgegeben.':'PDF und Excel enthalten nur echte Eingänge und Ausgaben.'):(enabled?'Transfers werden separat aufgeschlüsselt. Salden und verfügbare Liquidität enthalten sie immer.':'Transferdetails sind ausgeblendet. Salden und verfügbare Liquidität bleiben unverändert korrekt.'); return
{title}{copy}
; } @@ -717,7 +780,7 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe function Transfers({data,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; - const personLabelLocal=item=>personListLabel(data,item); + const personLabelLocal=item=>transferPeopleLabel(data,item); const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; return

Fixe Transfers

Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.

{data.transfers.length===0?:<>
{data.transfers.map(x=>{const cls=transferClassification(data,x);return })}
BezeichnungTypPersonenKategorieVonNachIntervallØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{cls.label}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{formatMoney(monthlyEquivalent(x))}
@@ -735,7 +798,7 @@ function transferTypeStats(data,accountId='all',personId='all'){ const isAll=accountId==='all';const buckets=new Map(); for(const item of data.transfers||[]){ if(!isAll&&item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue; - const factor=personFactor(item,personId);if(!factor)continue; + const factor=transferPersonFactor(data,item,personId,accountId);if(!factor)continue; const cls=transferClassification(data,item);const amount=monthlyEquivalent(item)*factor; const row=buckets.get(cls.key)||{key:cls.key,label:cls.label,description:cls.description,amount:0,in:0,out:0,count:0}; row.amount+=amount;row.count+=1;if(!isAll){if(item.toAccountId===accountId)row.in+=amount;if(item.fromAccountId===accountId)row.out+=amount;}buckets.set(cls.key,row);