fix(reconcile): fill earliest month deficit first in multi-month allocations
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Replace proportional split with a fill-first loop that allocates min(remaining, deficit) to each matched month in user-supplied order, where deficit = expected - already_paid. Prior transactions' contributions are now properly accounted for, so a second payment on overlapping months fills only what's still owed instead of splitting proportionally by total expected. Surplus after all deficits are covered goes to the credit bucket. Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -115,10 +115,11 @@ type monthExpected struct {
|
||||
expected int
|
||||
}
|
||||
|
||||
// Reconcile matches transactions to members and months using three allocation phases:
|
||||
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
||||
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
||||
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
// Reconcile matches transactions to members and months using two allocation phases:
|
||||
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||||
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||||
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
|
||||
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
//
|
||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||
@@ -317,34 +318,26 @@ func Reconcile(
|
||||
totalExpected += mw.expected
|
||||
}
|
||||
|
||||
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
||||
// Greedy: payment covers all expected fees; overflow → credit
|
||||
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
||||
for _, mw := range inWindow {
|
||||
alloc := float64(mw.expected)
|
||||
md := ledger[memberName][mw.month]
|
||||
md.Paid += alloc
|
||||
md.Transactions = append(md.Transactions, TxEntry{
|
||||
Amount: alloc,
|
||||
Date: tx.Date,
|
||||
Sender: tx.Sender,
|
||||
Message: tx.Message,
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
}
|
||||
} else if totalExpected > 0 {
|
||||
// Proportional: distribute by each month's share; last month absorbs float remainder
|
||||
if totalExpected > 0 {
|
||||
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||||
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||||
// is net of what prior transactions already paid, so a second payment on
|
||||
// the same months correctly fills only what remains due. Any surplus after
|
||||
// all deficits are covered goes to the credit bucket.
|
||||
remaining := inWindowShare
|
||||
for i, mw := range inWindow {
|
||||
var alloc float64
|
||||
if i == len(inWindow)-1 {
|
||||
alloc = remaining
|
||||
} else {
|
||||
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
||||
}
|
||||
remaining -= alloc
|
||||
for _, mw := range inWindow {
|
||||
md := ledger[memberName][mw.month]
|
||||
deficit := float64(mw.expected) - md.Paid
|
||||
if deficit < 0 {
|
||||
deficit = 0
|
||||
}
|
||||
alloc := remaining
|
||||
if deficit < alloc {
|
||||
alloc = deficit
|
||||
}
|
||||
if alloc <= 0 {
|
||||
continue
|
||||
}
|
||||
md.Paid += alloc
|
||||
md.Transactions = append(md.Transactions, TxEntry{
|
||||
Amount: alloc,
|
||||
@@ -354,6 +347,10 @@ func Reconcile(
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
remaining -= alloc
|
||||
}
|
||||
if remaining > 0 {
|
||||
credits[memberName] += int(remaining)
|
||||
}
|
||||
} else {
|
||||
// Even-split fallback: prepayment before attendance recorded
|
||||
|
||||
Reference in New Issue
Block a user