Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "moneytrace",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"description": "A personal finance management application built with React, TypeScript, and Tailwind CSS.",
"license": "MIT",
Expand Down
36 changes: 35 additions & 1 deletion src/components/portfolio/PortfolioForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export const PortfolioForm: React.FC = () => {
setParams({ [field]: clamped })
} else if (field === "usdRate") {
setParams({ [field]: Math.max(0.01, num) })
} else if (field === "initialCapital" || field === "monthlyDca") {
} else if (
field === "initialCapital" ||
field === "monthlyDca" ||
field === "monthlyWithdrawal"
) {
setParams({ [field]: Math.max(0, num) })
} else {
setParams({ [field]: num })
Expand Down Expand Up @@ -359,6 +363,36 @@ export const PortfolioForm: React.FC = () => {
/>
</div>

{/* 4. Monthly Cash Withdrawal */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="monthlyWithdrawal" className="text-sm font-medium">
{t("portfolio.monthlyWithdrawal", {
currency: currencySymbol,
})}
</Label>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">
{t("portfolio.monthlyWithdrawalHelp")}
</TooltipContent>
</Tooltip>
</div>
<Input
id="monthlyWithdrawal"
type="number"
min="0"
step="50"
value={currentParams.monthlyWithdrawal || ""}
onChange={(e) =>
handleChange("monthlyWithdrawal", e.target.value)
}
placeholder="0"
/>
</div>

{/* 4. DCA Increase Rate */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
Expand Down
2 changes: 1 addition & 1 deletion src/components/projection/ProjectionSummaryCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const ProjectionSummaryCards: React.FC = () => {
id: "realProfit",
title: `${t("projection.real")} ${t("projection.realProfitLoss")}`,
value: formatLocalCurrency(summary.totalRealProfit, currencyCode, locale),
subtext: `${t("projection.nominal")}: ${formatLocalCurrency(summary.totalNominalProfit, currencyCode, locale)}`,
subtext: `${t("projection.safeWithdrawal")}: ${formatLocalCurrency(summary.totalSafeWithdrawal, currencyCode, locale)}`,
badgeColor: isRealProfitPositive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
Expand Down
22 changes: 16 additions & 6 deletions src/components/projection/ProjectionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,13 @@ export const ProjectionTable: React.FC = () => {
{t("table.realValueCol")}
</th>
<th className="py-2.5 px-3 text-right">
{t("table.usdValueCol")}
{t("table.realProfitCol")}
</th>
<th className="py-2.5 px-3 text-right">
{t("table.realProfitCol")}
{t("table.safeWithdrawalCol")}
</th>
<th className="py-2.5 px-3 text-right">
{t("table.usdValueCol")}
</th>
<th className="py-2.5 px-3 text-right">
{t("table.usdRateCol")}
Expand Down Expand Up @@ -236,9 +239,6 @@ export const ProjectionTable: React.FC = () => {
locale,
)}
</td>
<td className="py-2 px-3 text-right text-amber-600 dark:text-amber-400">
{formatUSD(row.usdValue)}
</td>
<td
className={`py-2 px-3 text-right font-medium ${
isRealProfitPos
Expand All @@ -259,6 +259,16 @@ export const ProjectionTable: React.FC = () => {
)}
</div>
</td>
<td className="py-2 px-3 text-right font-medium text-cyan-600 dark:text-cyan-400">
{formatLocalCurrency(
row.safeWithdrawal,
currencyCode,
locale,
)}
</td>
<td className="py-2 px-3 text-right text-amber-600 dark:text-amber-400">
{formatUSD(row.usdValue)}
</td>
<td className="py-2 px-3 text-right text-muted-foreground">
{formatNumber(row.usdRate, 2, locale)}
</td>
Expand All @@ -268,7 +278,7 @@ export const ProjectionTable: React.FC = () => {
) : (
<tr>
<td
colSpan={9}
colSpan={10}
className="py-6 text-center text-muted-foreground font-sans"
>
No matching records found.
Expand Down
56 changes: 53 additions & 3 deletions src/engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
calculateCompoundStep,
calculateCumulativeInflationFactor,
adjustForInflation,
calculateSafeWithdrawal,
calculateProjection,
} from "./index"
import type { ProjectionParams } from "../types"
Expand Down Expand Up @@ -52,9 +53,9 @@ function runTests() {
console.log(`Initial 100k + 10k DCA @ 50% annual: ${step1.toFixed(2)} TL`)
console.assert(step1 > 110000, "Compound step failed")

// Test 4: Inflation Adjust
console.log("\n--- Test 4: Inflation Adjust ---")
const inflMonthly = calculateMonthlyRate(30) // 30% annual inflation
// Test 4: Inflation Adjust & Safe Withdrawal
console.log("\n--- Test 4: Inflation Adjust & Safe Withdrawal ---")
const inflMonthly = calculateMonthlyRate(30) // 30% annual inflation (~2.2104% monthly)
const inflFactor12 = calculateCumulativeInflationFactor(inflMonthly, 12)
console.log(
`12 Month Cumulative Inflation Factor @ 30%: ${inflFactor12.toFixed(4)} (Expected: 1.3000)`,
Expand All @@ -73,6 +74,15 @@ function runTests() {
"Real value adjustment test failed",
)

// Safe Withdrawal Test: 3.4366% return vs 2.2104% inflation on 100k capital
const safeW = calculateSafeWithdrawal(100000, mRate, inflMonthly)
console.log(
`Safe withdrawal on 100k @ 50% return vs 30% infl: ${safeW.toFixed(2)} TL`,
)
console.assert(safeW > 0, "Safe withdrawal should be positive when return > inflation")
const zeroSafeW = calculateSafeWithdrawal(100000, 0.01, 0.02)
console.assert(zeroSafeW === 0, "Safe withdrawal should be 0 when return <= inflation")

// Test 5: Full Projection Simulation
console.log("\n--- Test 5: Full Projection Simulation ---")
const sampleParams: ProjectionParams = {
Expand All @@ -97,6 +107,12 @@ function runTests() {
console.log(
`Final Real Value: ${result.summary.finalRealValue.toLocaleString("tr-TR")} TL`,
)
console.log(
`Total Safe Withdrawal (Nominal): ${result.summary.totalSafeWithdrawal.toLocaleString("tr-TR")} TL`,
)
console.log(
`Total Safe Withdrawal (Real): ${result.summary.totalRealSafeWithdrawal.toLocaleString("tr-TR")} TL`,
)
console.log(
`Final USD Value: $${result.summary.finalUsdValue.toLocaleString("en-US")}`,
)
Expand All @@ -108,6 +124,10 @@ function runTests() {
console.log(`Final USD Rate: ${result.summary.finalUsdRate} TL`)

console.assert(result.rows.length === 36, "Rows length mismatch")
console.assert(
result.summary.totalSafeWithdrawal > 0,
"Total safe withdrawal should be > 0",
)
console.assert(
result.summary.finalNominalValue > result.summary.finalRealValue,
"Nominal should be > Real when inflation > 0",
Expand Down Expand Up @@ -196,6 +216,36 @@ function runTests() {
"Annual/Monthly mode equivalence failed",
)

// Test 9: Monthly Cash Withdrawal Simulation
console.log("\n--- Test 9: Monthly Cash Withdrawal Simulation ---")
const withdrawalParams: ProjectionParams = {
initialCapital: 10000,
monthlyDca: 0,
dcaIncreaseRate: 0,
monthlyWithdrawal: 200,
expectedReturnRate: 0,
expectedInflationRate: 0,
usdRate: 1,
expectedUsdGrowthRate: 0,
targetYears: 1,
rateInputPeriod: "monthly",
}
const withdrawalResult = calculateProjection(withdrawalParams)
console.log(
`Withdrawal result final nominal: ${withdrawalResult.summary.finalNominalValue} (Expected: 7600)`,
)
console.log(
`Total withdrawals: ${withdrawalResult.summary.totalWithdrawals} (Expected: 2400)`,
)
console.assert(
withdrawalResult.summary.finalNominalValue === 7600,
"Final nominal after withdrawal mismatch",
)
console.assert(
withdrawalResult.summary.totalWithdrawals === 2400,
"Total withdrawals mismatch",
)

console.log("\n✅ ALL ENGINE TESTS PASSED SUCCESSFULLY!")
}

Expand Down
36 changes: 36 additions & 0 deletions src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
calculateCumulativeInflationFactor,
adjustForInflation,
calculatePurchasingPowerLossRate,
calculateSafeWithdrawal,
} from "./inflation-adjust"
import { predictUsdRate, convertToUsd } from "./currency-convert"

Expand Down Expand Up @@ -49,6 +50,12 @@ export function calculateProjection(
let currentNominalValue = Math.max(0, params.initialCapital || 0)
let totalInvested = Math.max(0, params.initialCapital || 0)
let realTotalInvested = Math.max(0, params.initialCapital || 0)
let totalSafeWithdrawal = 0
let totalRealSafeWithdrawal = 0
let totalWithdrawals = 0
let totalRealWithdrawals = 0

const plannedWithdrawal = Math.max(0, params.monthlyWithdrawal || 0)

const rows: ProjectionRow[] = []

Expand All @@ -69,6 +76,20 @@ export function calculateProjection(
month,
)

// Capital base before return step
const capitalBase = currentNominalValue + monthlyDca

// Inflation-protected safe withdrawal amount for this month
const safeWithdrawal = calculateSafeWithdrawal(
capitalBase,
monthlyReturnRate,
monthlyInflationRate,
)
const realSafeWithdrawal = adjustForInflation(safeWithdrawal, cumInflation)

totalSafeWithdrawal += safeWithdrawal
totalRealSafeWithdrawal += realSafeWithdrawal

// Cumulative nominal invested capital
totalInvested += monthlyDca

Expand All @@ -83,6 +104,14 @@ export function calculateProjection(
monthlyReturnRate,
)

// Monthly cash withdrawal step
const actualWithdrawal = Math.min(currentNominalValue, plannedWithdrawal)
currentNominalValue -= actualWithdrawal

totalWithdrawals += actualWithdrawal
totalRealWithdrawals +=
cumInflation > 0 ? actualWithdrawal / cumInflation : actualWithdrawal

// Real purchasing power calculation
const realValue = adjustForInflation(currentNominalValue, cumInflation)

Expand Down Expand Up @@ -112,6 +141,9 @@ export function calculateProjection(
usdRate: Math.round(currentUsdRate * 100) / 100,
nominalProfit: Math.round(nominalProfit * 100) / 100,
realProfit: Math.round(realProfit * 100) / 100,
safeWithdrawal: Math.round(safeWithdrawal * 100) / 100,
realSafeWithdrawal: Math.round(realSafeWithdrawal * 100) / 100,
withdrawal: Math.round(actualWithdrawal * 100) / 100,
})
}

Expand Down Expand Up @@ -151,6 +183,10 @@ export function calculateProjection(
realRoi: Math.round(realRoi * 100) / 100,
purchasingPowerLossRate: Math.round(purchasingPowerLossRate * 100) / 100,
finalUsdRate: Math.round(finalUsdRate * 100) / 100,
totalSafeWithdrawal: Math.round(totalSafeWithdrawal * 100) / 100,
totalRealSafeWithdrawal: Math.round(totalRealSafeWithdrawal * 100) / 100,
totalWithdrawals: Math.round(totalWithdrawals * 100) / 100,
totalRealWithdrawals: Math.round(totalRealWithdrawals * 100) / 100,
}

return {
Expand Down
20 changes: 20 additions & 0 deletions src/engine/inflation-adjust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,23 @@ export function calculatePurchasingPowerLossRate(
if (nominalValue <= 0 || realValue >= nominalValue) return 0
return ((nominalValue - realValue) / nominalValue) * 100
}

/**
* Calculates the maximum safe withdrawal amount from monthly nominal return
* that preserves the principal's real purchasing power against inflation.
* Formula: baseCapital * max(0, monthlyReturnRate - monthlyInflationRate)
*
* @param baseCapital Capital balance before applying return (current balance + DCA)
* @param monthlyReturnRate Monthly return rate (decimal)
* @param monthlyInflationRate Monthly inflation rate (decimal)
* @returns Safe withdrawal amount (Nominal)
*/
export function calculateSafeWithdrawal(
baseCapital: number,
monthlyReturnRate: number,
monthlyInflationRate: number,
): number {
if (baseCapital <= 0 || monthlyReturnRate <= monthlyInflationRate) return 0
return baseCapital * (monthlyReturnRate - monthlyInflationRate)
}

18 changes: 18 additions & 0 deletions src/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function exportToCsv(
"Year Index",
"Month in Year",
"Monthly DCA",
"Monthly Withdrawal",
"Total Invested Capital",
"Real Invested Capital",
"Nominal Value",
Expand All @@ -45,6 +46,8 @@ export function exportToCsv(
"Exchange Rate",
"Nominal Profit/Loss",
"Real Profit/Loss",
"Safe Withdrawal (Nominal)",
"Safe Withdrawal (Real)",
]
lines.push(headers.join(delimiter))

Expand All @@ -55,6 +58,7 @@ export function exportToCsv(
r.yearIndex,
r.monthInYear,
r.monthlyDca.toFixed(2),
r.withdrawal.toFixed(2),
r.totalInvested.toFixed(2),
r.realTotalInvested.toFixed(2),
r.nominalValue.toFixed(2),
Expand All @@ -63,6 +67,8 @@ export function exportToCsv(
r.usdRate.toFixed(2),
r.nominalProfit.toFixed(2),
r.realProfit.toFixed(2),
r.safeWithdrawal.toFixed(2),
r.realSafeWithdrawal.toFixed(2),
]
lines.push(rowValues.join(delimiter))
})
Expand All @@ -89,6 +95,18 @@ export function exportToCsv(
lines.push(
`Total Real Profit/Loss${delimiter}${summary.totalRealProfit.toFixed(2)}`,
)
lines.push(
`Total Actual Withdrawals (Nominal)${delimiter}${summary.totalWithdrawals.toFixed(2)}`,
)
lines.push(
`Total Actual Withdrawals (Real)${delimiter}${summary.totalRealWithdrawals.toFixed(2)}`,
)
lines.push(
`Total Safe Withdrawal (Nominal)${delimiter}${summary.totalSafeWithdrawal.toFixed(2)}`,
)
lines.push(
`Total Safe Withdrawal (Real)${delimiter}${summary.totalRealSafeWithdrawal.toFixed(2)}`,
)
lines.push(`Nominal ROI (%)${delimiter}${summary.nominalRoi.toFixed(2)}`)
lines.push(`Real ROI (%)${delimiter}${summary.realRoi.toFixed(2)}`)
lines.push(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
* MoneyTrace - Application Version
* Bump alongside package.json when releasing a new version.
*/
export const APP_VERSION = "1.0.0"
export const APP_VERSION = "1.1.0"
Loading
Loading