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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,13 @@ This approach ensures:
- if it is a feature do `feat/(scope):`,
- if it is others do `chore/(scope):`

### Issue Auto-Close

To auto-close issues when PRs merge, include keywords in commit messages or PR descriptions:
- `Closes #155` or `Fixes #154` - GitHub automatically closes the referenced issue on merge
- Multiple issues: `Closes #155, closes #157`
- Use when the commit/PR fully addresses an issue

### When stuck

- ask a clarifying question, propose a short plan, or open a draft PR with notes
Expand Down
55 changes: 43 additions & 12 deletions backend/internal/cpf/engine/interest.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ type InterestResult struct {
ExtraInterest *decimal.Decimal // Extra interest amount
ExtraInterestTo string // "sa" or "ra" - where extra interest is credited
TotalInterest *decimal.Decimal // Sum of all interest

// MA interest overflow when MA >= BHS
MAInterestOverflowToSA *decimal.Decimal // MA interest redirected to SA (age < 55)
MAInterestOverflowToRA *decimal.Decimal // MA interest redirected to RA (age >= 55)
}

// ApplyMonthlyInterest applies one month of base + extra interest to the state.
// Modifies the state in place and returns the interest breakdown.
// When MA balance is at or above BHS, MA interest overflows to SA (age < 55) or RA (age >= 55).
func ApplyMonthlyInterest(state *CPFState, assumptions *Assumptions) *InterestResult {
if assumptions == nil {
assumptions = DefaultAssumptions()
Expand All @@ -47,14 +52,38 @@ func ApplyMonthlyInterest(state *CPFState, assumptions *Assumptions) *InterestRe
maInterest := CalculateMonthlyInterest(state.MA, maRatePct)
raInterest := CalculateMonthlyInterest(state.RA, raRatePct)

// Get age and BHS for MA interest overflow check
age := state.AgeAt(state.AsOfDate)
bhs := assumptions.GetBHS(state.AsOfDate.Year())

// Track MA interest overflow
var maInterestOverflowToSA, maInterestOverflowToRA *decimal.Decimal

// Apply base interest to balances
state.OA = state.OA.Add(oaInterest)
state.SA = state.SA.Add(saInterest)
state.MA = state.MA.Add(maInterest)
state.RA = state.RA.Add(raInterest)

// MA interest: if MA >= BHS, redirect interest to SA/RA instead of MA
if state.MA != nil && bhs != nil && state.MA.GTE(bhs) {
// MA at or above BHS - redirect interest to SA (age < 55) or RA (age >= 55)
if age < RAFormationAge {
state.SA = state.SA.Add(maInterest)
maInterestOverflowToSA = maInterest
maInterestOverflowToRA = decimal.Zero()
} else {
state.RA = state.RA.Add(maInterest)
maInterestOverflowToSA = decimal.Zero()
maInterestOverflowToRA = maInterest
}
} else {
// MA below BHS - add interest normally to MA
state.MA = state.MA.Add(maInterest)
maInterestOverflowToSA = decimal.Zero()
maInterestOverflowToRA = decimal.Zero()
}

// Calculate extra interest
age := state.AgeAt(state.AsOfDate)
extraResult := CalculateExtraInterest(state.OA, state.SA, state.MA, state.RA, age, assumptions)

// Apply extra interest to appropriate account
Expand All @@ -77,13 +106,15 @@ func ApplyMonthlyInterest(state *CPFState, assumptions *Assumptions) *InterestRe
}

return &InterestResult{
BaseInterestOA: oaInterest,
BaseInterestSA: saInterest,
BaseInterestMA: maInterest,
BaseInterestRA: raInterest,
ExtraInterest: extraResult.Amount,
ExtraInterestTo: extraResult.CreditTo,
TotalInterest: totalInterest,
BaseInterestOA: oaInterest,
BaseInterestSA: saInterest,
BaseInterestMA: maInterest,
BaseInterestRA: raInterest,
ExtraInterest: extraResult.Amount,
ExtraInterestTo: extraResult.CreditTo,
TotalInterest: totalInterest,
MAInterestOverflowToSA: maInterestOverflowToSA,
MAInterestOverflowToRA: maInterestOverflowToRA,
}
}

Expand Down Expand Up @@ -150,15 +181,15 @@ func CalculateExtraInterest(

// Calculate extra interest on first $60k
qualifyingFor60k := combined
if combined.Cmp(ExtraInterestLimit60K) > 0 {
if combined.GT(ExtraInterestLimit60K) {
qualifyingFor60k = ExtraInterestLimit60K
}
extraInterest := CalculateMonthlyInterest(qualifyingFor60k, extraFirst60kPct)

// For members 55+, additional +1% on first $30k
if age >= 55 {
qualifyingFor30k := combined
if combined.Cmp(ExtraInterestLimit30K) > 0 {
if combined.GT(ExtraInterestLimit30K) {
qualifyingFor30k = ExtraInterestLimit30K
}
additionalExtra := CalculateMonthlyInterest(qualifyingFor30k, extraFirst30kAbove55Pct)
Expand All @@ -185,7 +216,7 @@ func convertToPercentage(rate *decimal.Decimal) *decimal.Decimal {
}
// If rate is less than 1, it's likely in decimal format (e.g., 0.025 for 2.5%)
one := decimal.NewFromInt64(1, 0)
if rate.Cmp(one) < 0 {
if rate.LT(one) {
hundred := decimal.NewFromInt64(100, 0)
return rate.Mul(hundred)
}
Expand Down
30 changes: 22 additions & 8 deletions backend/internal/cpf/engine/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type MonthlyResult struct {
// Contributions added this month
TotalContributions *decimal.Decimal
SARedirectedToRA *decimal.Decimal // SA contribution redirected to RA (age 55+)
MAOverflowToSA *decimal.Decimal // MA overflow to SA when exceeding BHS (age < 55)
MAOverflowToRA *decimal.Decimal // MA overflow to RA when exceeding BHS (age >= 55)

// State snapshot after all operations
EndOfMonthState *CPFState
Expand Down Expand Up @@ -53,10 +55,13 @@ type ProcessMonthOptions struct {
// ProcessMonth applies all monthly CPF calculations in the correct order:
// 1. Year boundary YTD reset (if applicable)
// 2. RA formation check (at age 55)
// 3. Apply contributions (with SA->RA redirect if age >= 55)
// 4. Apply interest (base + extra)
// 3. Apply interest (on opening balance, before contributions)
// 4. Apply contributions (with SA->RA redirect if age >= 55, MA->SA/RA when >= BHS)
// 5. CPF LIFE payout check and application (at/after payoutStartAge)
//
// Note: Interest is calculated on the opening balance (lowest balance during the month),
// which means it's applied BEFORE contributions are added.
//
// This is the main entry point for both projector and timeline service.
func ProcessMonth(
state *CPFState,
Expand All @@ -80,6 +85,8 @@ func ProcessMonth(
result := &MonthlyResult{
TotalContributions: decimal.Zero(),
SARedirectedToRA: decimal.Zero(),
MAOverflowToSA: decimal.Zero(),
MAOverflowToRA: decimal.Zero(),
}

// Update state's AsOfDate to current date
Expand All @@ -102,7 +109,15 @@ func ProcessMonth(
result.RAFormation = raResult
}

// Step 3: Apply contributions (if any)
// Step 3: Apply interest (on opening balance, before contributions)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to explicit that is this is an Assumption. CPF uses the lowest balance in the month so there is room for more granular implementation

// ASSUMPTION: CPF calculates interest on the lowest balance during the month.
// In practice, this is typically the opening balance (balance before any credits).
// Our implementation uses the opening balance as a simplification.
// A more granular implementation could track daily balances to find the true minimum.
interestResult := ApplyMonthlyInterest(state, opts.Assumptions)
result.Interest = interestResult

// Step 4: Apply contributions (if any)
if opts.ApplyContributions && len(opts.Contributions) > 0 {
for _, contrib := range opts.Contributions {
// Add allocations to balances
Expand All @@ -121,7 +136,10 @@ func ProcessMonth(
}
}
if contrib.Allocation.MA != nil {
state.MA = state.MA.Add(contrib.Allocation.MA)
// Apply MA contribution with BHS cap check
// Per CPF policy: once MA reaches BHS, excess overflows to SA (age<55) or RA (age>=55)
bhs := opts.Assumptions.GetBHS(date.Year())
ApplyMAContributionWithBHSCap(state, result, contrib.Allocation.MA, bhs, age)
result.TotalContributions = result.TotalContributions.Add(contrib.Allocation.MA)
}
if contrib.Allocation.RA != nil {
Expand All @@ -136,10 +154,6 @@ func ProcessMonth(
}
}

// Step 4: Apply interest (base + extra)
interestResult := ApplyMonthlyInterest(state, opts.Assumptions)
result.Interest = interestResult

// Step 5: CPF LIFE payout
if age >= opts.PayoutStartAge {
if !state.PayoutsActive && state.RA != nil && !state.RA.IsZero() {
Expand Down
Loading