repaykit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repaykit/__init__.py +18 -0
- repaykit/_serialization.py +28 -0
- repaykit/account.py +98 -0
- repaykit/accruals/__init__.py +8 -0
- repaykit/accruals/base.py +25 -0
- repaykit/accruals/daily.py +18 -0
- repaykit/accruals/flat.py +14 -0
- repaykit/accruals/periodic.py +12 -0
- repaykit/backends/README.md +12 -0
- repaykit/enums.py +21 -0
- repaykit/exceptions.py +21 -0
- repaykit/exporters/__init__.py +12 -0
- repaykit/exporters/csv.py +35 -0
- repaykit/exporters/dicts.py +19 -0
- repaykit/ledger/__init__.py +7 -0
- repaykit/ledger/account_statement.py +5 -0
- repaykit/ledger/payment.py +25 -0
- repaykit/ledger/transaction.py +17 -0
- repaykit/loan.py +157 -0
- repaykit/methods/__init__.py +15 -0
- repaykit/methods/base.py +46 -0
- repaykit/methods/constant_principal.py +77 -0
- repaykit/methods/flat_rate.py +93 -0
- repaykit/methods/reducing_balance.py +85 -0
- repaykit/methods/sum_of_digits.py +96 -0
- repaykit/money.py +37 -0
- repaykit/policies/__init__.py +14 -0
- repaykit/policies/allocation.py +21 -0
- repaykit/policies/late_charge.py +63 -0
- repaykit/policies/rounding.py +36 -0
- repaykit/policies/settlement.py +66 -0
- repaykit/py.typed +1 -0
- repaykit/results.py +58 -0
- repaykit/schedules/__init__.py +21 -0
- repaykit/schedules/base.py +48 -0
- repaykit/schedules/biweekly.py +18 -0
- repaykit/schedules/custom.py +42 -0
- repaykit/schedules/daily.py +28 -0
- repaykit/schedules/monthly.py +29 -0
- repaykit/schedules/quarterly.py +20 -0
- repaykit/schedules/weekly.py +23 -0
- repaykit/schedules/yearly.py +20 -0
- repaykit/terms/__init__.py +5 -0
- repaykit/terms/term.py +85 -0
- repaykit-1.0.0.dist-info/METADATA +285 -0
- repaykit-1.0.0.dist-info/RECORD +48 -0
- repaykit-1.0.0.dist-info/WHEEL +4 -0
- repaykit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Constant-principal repayment allocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.methods.base import RepaymentMethod
|
|
11
|
+
from repaykit.money import ZERO, max_decimal
|
|
12
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
13
|
+
from repaykit.results import ScheduleRow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ConstantPrincipalAllocation(RepaymentMethod):
|
|
18
|
+
name: str = "constant_principal"
|
|
19
|
+
|
|
20
|
+
def payment_amount(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
principal: Decimal,
|
|
24
|
+
annual_rate: Decimal,
|
|
25
|
+
term_years: Decimal,
|
|
26
|
+
periods: int,
|
|
27
|
+
periods_per_year: Decimal,
|
|
28
|
+
rounding: RoundingPolicy,
|
|
29
|
+
) -> Decimal:
|
|
30
|
+
periodic_rate = annual_rate / periods_per_year
|
|
31
|
+
first_profit = principal * periodic_rate
|
|
32
|
+
return rounding.round_money(principal / Decimal(periods) + first_profit)
|
|
33
|
+
|
|
34
|
+
def iter_schedule(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
principal: Decimal,
|
|
38
|
+
annual_rate: Decimal,
|
|
39
|
+
term_years: Decimal,
|
|
40
|
+
periods: int,
|
|
41
|
+
periods_per_year: Decimal,
|
|
42
|
+
start_date: date,
|
|
43
|
+
due_dates: Iterable[date],
|
|
44
|
+
rounding: RoundingPolicy,
|
|
45
|
+
) -> Iterator[ScheduleRow]:
|
|
46
|
+
periodic_rate = annual_rate / periods_per_year
|
|
47
|
+
base_principal = rounding.round_money(principal / Decimal(periods))
|
|
48
|
+
balance = principal
|
|
49
|
+
allocated_principal = ZERO
|
|
50
|
+
|
|
51
|
+
for period, due_date in enumerate(due_dates, start=1):
|
|
52
|
+
profit_part = rounding.round_money(balance * periodic_rate)
|
|
53
|
+
if period == periods:
|
|
54
|
+
principal_part = max_decimal(principal - allocated_principal)
|
|
55
|
+
else:
|
|
56
|
+
principal_part = base_principal
|
|
57
|
+
installment = rounding.round_money(principal_part + profit_part)
|
|
58
|
+
closing = max_decimal(balance - principal_part)
|
|
59
|
+
yield ScheduleRow(
|
|
60
|
+
period=period,
|
|
61
|
+
due_date=due_date,
|
|
62
|
+
opening_balance=rounding.round_money(balance),
|
|
63
|
+
principal=rounding.round_money(principal_part),
|
|
64
|
+
profit=profit_part,
|
|
65
|
+
installment=installment,
|
|
66
|
+
closing_balance=rounding.round_money(closing),
|
|
67
|
+
metadata={
|
|
68
|
+
"method": self.name,
|
|
69
|
+
"allocation": "constant_principal",
|
|
70
|
+
"periodic_rate": periodic_rate,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
balance = closing
|
|
74
|
+
allocated_principal += principal_part
|
|
75
|
+
|
|
76
|
+
def unearned_profit_rebate(self, rows: list[ScheduleRow], as_of: date) -> Decimal:
|
|
77
|
+
return ZERO
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Flat-rate financing allocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.methods.base import RepaymentMethod
|
|
11
|
+
from repaykit.money import ZERO, max_decimal
|
|
12
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
13
|
+
from repaykit.results import ScheduleRow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class FlatRateAllocation(RepaymentMethod):
|
|
18
|
+
name: str = "flat_rate"
|
|
19
|
+
|
|
20
|
+
def total_profit(
|
|
21
|
+
self,
|
|
22
|
+
principal: Decimal,
|
|
23
|
+
annual_rate: Decimal,
|
|
24
|
+
term_years: Decimal,
|
|
25
|
+
) -> Decimal:
|
|
26
|
+
return principal * annual_rate * term_years
|
|
27
|
+
|
|
28
|
+
def payment_amount(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
principal: Decimal,
|
|
32
|
+
annual_rate: Decimal,
|
|
33
|
+
term_years: Decimal,
|
|
34
|
+
periods: int,
|
|
35
|
+
periods_per_year: Decimal,
|
|
36
|
+
rounding: RoundingPolicy,
|
|
37
|
+
) -> Decimal:
|
|
38
|
+
total = principal + self.total_profit(principal, annual_rate, term_years)
|
|
39
|
+
return rounding.round_money(total / Decimal(periods))
|
|
40
|
+
|
|
41
|
+
def iter_schedule(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
principal: Decimal,
|
|
45
|
+
annual_rate: Decimal,
|
|
46
|
+
term_years: Decimal,
|
|
47
|
+
periods: int,
|
|
48
|
+
periods_per_year: Decimal,
|
|
49
|
+
start_date: date,
|
|
50
|
+
due_dates: Iterable[date],
|
|
51
|
+
rounding: RoundingPolicy,
|
|
52
|
+
) -> Iterator[ScheduleRow]:
|
|
53
|
+
total_profit = rounding.round_money(self.total_profit(principal, annual_rate, term_years))
|
|
54
|
+
installment = self.payment_amount(
|
|
55
|
+
principal=principal,
|
|
56
|
+
annual_rate=annual_rate,
|
|
57
|
+
term_years=term_years,
|
|
58
|
+
periods=periods,
|
|
59
|
+
periods_per_year=periods_per_year,
|
|
60
|
+
rounding=rounding,
|
|
61
|
+
)
|
|
62
|
+
base_profit = rounding.round_money(total_profit / Decimal(periods))
|
|
63
|
+
balance = principal
|
|
64
|
+
allocated_principal = ZERO
|
|
65
|
+
allocated_profit = ZERO
|
|
66
|
+
|
|
67
|
+
for period, due_date in enumerate(due_dates, start=1):
|
|
68
|
+
if period == periods:
|
|
69
|
+
principal_part = max_decimal(principal - allocated_principal)
|
|
70
|
+
profit_part = max_decimal(total_profit - allocated_profit)
|
|
71
|
+
installment_part = rounding.round_money(principal_part + profit_part)
|
|
72
|
+
else:
|
|
73
|
+
profit_part = base_profit
|
|
74
|
+
principal_part = rounding.round_money(installment - profit_part)
|
|
75
|
+
installment_part = installment
|
|
76
|
+
closing = max_decimal(balance - principal_part)
|
|
77
|
+
yield ScheduleRow(
|
|
78
|
+
period=period,
|
|
79
|
+
due_date=due_date,
|
|
80
|
+
opening_balance=rounding.round_money(balance),
|
|
81
|
+
principal=rounding.round_money(principal_part),
|
|
82
|
+
profit=rounding.round_money(profit_part),
|
|
83
|
+
installment=rounding.round_money(installment_part),
|
|
84
|
+
closing_balance=rounding.round_money(closing),
|
|
85
|
+
metadata={
|
|
86
|
+
"method": self.name,
|
|
87
|
+
"allocation": "straight_line",
|
|
88
|
+
"total_profit": total_profit,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
balance = closing
|
|
92
|
+
allocated_principal += principal_part
|
|
93
|
+
allocated_profit += profit_part
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Reducing-balance amortization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.methods.base import RepaymentMethod
|
|
11
|
+
from repaykit.money import ZERO, max_decimal
|
|
12
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
13
|
+
from repaykit.results import ScheduleRow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ReducingBalanceAllocation(RepaymentMethod):
|
|
18
|
+
name: str = "reducing_balance"
|
|
19
|
+
|
|
20
|
+
def payment_amount(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
principal: Decimal,
|
|
24
|
+
annual_rate: Decimal,
|
|
25
|
+
term_years: Decimal,
|
|
26
|
+
periods: int,
|
|
27
|
+
periods_per_year: Decimal,
|
|
28
|
+
rounding: RoundingPolicy,
|
|
29
|
+
) -> Decimal:
|
|
30
|
+
periodic_rate = annual_rate / periods_per_year
|
|
31
|
+
if periodic_rate == ZERO:
|
|
32
|
+
return rounding.round_money(principal / Decimal(periods))
|
|
33
|
+
factor = (Decimal("1") + periodic_rate) ** periods
|
|
34
|
+
installment = principal * periodic_rate * factor / (factor - Decimal("1"))
|
|
35
|
+
return rounding.round_money(installment)
|
|
36
|
+
|
|
37
|
+
def iter_schedule(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
principal: Decimal,
|
|
41
|
+
annual_rate: Decimal,
|
|
42
|
+
term_years: Decimal,
|
|
43
|
+
periods: int,
|
|
44
|
+
periods_per_year: Decimal,
|
|
45
|
+
start_date: date,
|
|
46
|
+
due_dates: Iterable[date],
|
|
47
|
+
rounding: RoundingPolicy,
|
|
48
|
+
) -> Iterator[ScheduleRow]:
|
|
49
|
+
periodic_rate = annual_rate / periods_per_year
|
|
50
|
+
installment = self.payment_amount(
|
|
51
|
+
principal=principal,
|
|
52
|
+
annual_rate=annual_rate,
|
|
53
|
+
term_years=term_years,
|
|
54
|
+
periods=periods,
|
|
55
|
+
periods_per_year=periods_per_year,
|
|
56
|
+
rounding=rounding,
|
|
57
|
+
)
|
|
58
|
+
balance = principal
|
|
59
|
+
for period, due_date in enumerate(due_dates, start=1):
|
|
60
|
+
profit_part = rounding.round_money(balance * periodic_rate)
|
|
61
|
+
if period == periods:
|
|
62
|
+
principal_part = balance
|
|
63
|
+
installment_part = rounding.round_money(principal_part + profit_part)
|
|
64
|
+
else:
|
|
65
|
+
principal_part = max_decimal(installment - profit_part)
|
|
66
|
+
installment_part = installment
|
|
67
|
+
closing = max_decimal(balance - principal_part)
|
|
68
|
+
yield ScheduleRow(
|
|
69
|
+
period=period,
|
|
70
|
+
due_date=due_date,
|
|
71
|
+
opening_balance=rounding.round_money(balance),
|
|
72
|
+
principal=rounding.round_money(principal_part),
|
|
73
|
+
profit=rounding.round_money(profit_part),
|
|
74
|
+
installment=rounding.round_money(installment_part),
|
|
75
|
+
closing_balance=rounding.round_money(closing),
|
|
76
|
+
metadata={
|
|
77
|
+
"method": self.name,
|
|
78
|
+
"allocation": "amortized",
|
|
79
|
+
"periodic_rate": periodic_rate,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
balance = closing
|
|
83
|
+
|
|
84
|
+
def unearned_profit_rebate(self, rows: list[ScheduleRow], as_of: date) -> Decimal:
|
|
85
|
+
return ZERO
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Rule of 78 / sum-of-digits allocation generalized to any period count."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.exceptions import ValidationError
|
|
11
|
+
from repaykit.methods.flat_rate import FlatRateAllocation
|
|
12
|
+
from repaykit.money import ZERO, max_decimal
|
|
13
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
14
|
+
from repaykit.results import ScheduleRow
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class SumOfDigitsAllocation(FlatRateAllocation):
|
|
19
|
+
name: str = "sum_of_digits"
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def denominator(periods: int) -> int:
|
|
23
|
+
if periods <= 0:
|
|
24
|
+
raise ValidationError("periods must be positive")
|
|
25
|
+
return periods * (periods + 1) // 2
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def remaining_weight(periods: int, completed_periods: int) -> int:
|
|
29
|
+
if periods <= 0:
|
|
30
|
+
raise ValidationError("periods must be positive")
|
|
31
|
+
if completed_periods < 0:
|
|
32
|
+
raise ValidationError("completed_periods cannot be negative")
|
|
33
|
+
remaining = periods - completed_periods
|
|
34
|
+
if remaining < 0:
|
|
35
|
+
return 0
|
|
36
|
+
return remaining * (remaining + 1) // 2
|
|
37
|
+
|
|
38
|
+
def iter_schedule(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
principal: Decimal,
|
|
42
|
+
annual_rate: Decimal,
|
|
43
|
+
term_years: Decimal,
|
|
44
|
+
periods: int,
|
|
45
|
+
periods_per_year: Decimal,
|
|
46
|
+
start_date: date,
|
|
47
|
+
due_dates: Iterable[date],
|
|
48
|
+
rounding: RoundingPolicy,
|
|
49
|
+
) -> Iterator[ScheduleRow]:
|
|
50
|
+
total_profit = rounding.round_money(self.total_profit(principal, annual_rate, term_years))
|
|
51
|
+
installment = self.payment_amount(
|
|
52
|
+
principal=principal,
|
|
53
|
+
annual_rate=annual_rate,
|
|
54
|
+
term_years=term_years,
|
|
55
|
+
periods=periods,
|
|
56
|
+
periods_per_year=periods_per_year,
|
|
57
|
+
rounding=rounding,
|
|
58
|
+
)
|
|
59
|
+
denominator = Decimal(self.denominator(periods))
|
|
60
|
+
balance = principal
|
|
61
|
+
allocated_principal = ZERO
|
|
62
|
+
allocated_profit = ZERO
|
|
63
|
+
|
|
64
|
+
for period, due_date in enumerate(due_dates, start=1):
|
|
65
|
+
if period == periods:
|
|
66
|
+
profit_part = max_decimal(total_profit - allocated_profit)
|
|
67
|
+
principal_part = max_decimal(principal - allocated_principal)
|
|
68
|
+
installment_part = rounding.round_money(principal_part + profit_part)
|
|
69
|
+
else:
|
|
70
|
+
weight = Decimal(periods - period + 1)
|
|
71
|
+
profit_part = rounding.round_money(total_profit * weight / denominator)
|
|
72
|
+
principal_part = rounding.round_money(installment - profit_part)
|
|
73
|
+
installment_part = installment
|
|
74
|
+
closing = max_decimal(balance - principal_part)
|
|
75
|
+
yield ScheduleRow(
|
|
76
|
+
period=period,
|
|
77
|
+
due_date=due_date,
|
|
78
|
+
opening_balance=rounding.round_money(balance),
|
|
79
|
+
principal=rounding.round_money(principal_part),
|
|
80
|
+
profit=rounding.round_money(profit_part),
|
|
81
|
+
installment=rounding.round_money(installment_part),
|
|
82
|
+
closing_balance=rounding.round_money(closing),
|
|
83
|
+
metadata={
|
|
84
|
+
"method": self.name,
|
|
85
|
+
"allocation": "sum_of_digits",
|
|
86
|
+
"denominator": int(denominator),
|
|
87
|
+
"weight": periods - period + 1,
|
|
88
|
+
"total_profit": total_profit,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
balance = closing
|
|
92
|
+
allocated_principal += principal_part
|
|
93
|
+
allocated_profit += profit_part
|
|
94
|
+
|
|
95
|
+
def unearned_profit_rebate(self, rows: list[ScheduleRow], as_of: date) -> Decimal:
|
|
96
|
+
return sum((row.profit for row in rows if row.due_date > as_of), Decimal("0"))
|
repaykit/money.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Decimal helpers for money-safe calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from repaykit.exceptions import ValidationError
|
|
9
|
+
|
|
10
|
+
ZERO = Decimal("0")
|
|
11
|
+
ONE = Decimal("1")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def decimal(value: Any) -> Decimal:
|
|
15
|
+
"""Convert a value to ``Decimal`` without accepting binary floats."""
|
|
16
|
+
|
|
17
|
+
if isinstance(value, Decimal):
|
|
18
|
+
return value
|
|
19
|
+
if isinstance(value, bool):
|
|
20
|
+
raise ValidationError("boolean values are not accepted for Decimal fields")
|
|
21
|
+
if isinstance(value, float):
|
|
22
|
+
raise ValidationError("float values are not accepted; use Decimal or str")
|
|
23
|
+
return Decimal(str(value))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_positive(value: Decimal, name: str) -> None:
|
|
27
|
+
if value <= ZERO:
|
|
28
|
+
raise ValidationError(f"{name} must be positive")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def require_non_negative(value: Decimal, name: str) -> None:
|
|
32
|
+
if value < ZERO:
|
|
33
|
+
raise ValidationError(f"{name} cannot be negative")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def max_decimal(left: Decimal, right: Decimal = ZERO) -> Decimal:
|
|
37
|
+
return left if left >= right else right
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Policy classes for rounding, late charges, settlement, and allocation."""
|
|
2
|
+
|
|
3
|
+
from repaykit.policies.allocation import AllocationBreakdown, FifoAllocationPolicy
|
|
4
|
+
from repaykit.policies.late_charge import LateChargePolicy
|
|
5
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
6
|
+
from repaykit.policies.settlement import SettlementPolicy
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AllocationBreakdown",
|
|
10
|
+
"FifoAllocationPolicy",
|
|
11
|
+
"LateChargePolicy",
|
|
12
|
+
"RoundingPolicy",
|
|
13
|
+
"SettlementPolicy",
|
|
14
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Payment allocation policies for ledger processing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class AllocationBreakdown:
|
|
11
|
+
charges: Decimal
|
|
12
|
+
profit: Decimal
|
|
13
|
+
principal: Decimal
|
|
14
|
+
unapplied: Decimal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class FifoAllocationPolicy:
|
|
19
|
+
"""Simple FIFO allocation marker used by LoanAccount statements."""
|
|
20
|
+
|
|
21
|
+
name: str = "fifo"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Pluggable late charge policy objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from repaykit.exceptions import ValidationError
|
|
10
|
+
from repaykit.money import ZERO, max_decimal
|
|
11
|
+
from repaykit.money import decimal as to_decimal
|
|
12
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class LateChargePolicy:
|
|
17
|
+
rate: Decimal = ZERO
|
|
18
|
+
grace_days: int = 0
|
|
19
|
+
basis: str = "flat"
|
|
20
|
+
minimum_charge: Decimal = ZERO
|
|
21
|
+
maximum_charge: Decimal | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
object.__setattr__(self, "rate", to_decimal(self.rate))
|
|
25
|
+
object.__setattr__(self, "minimum_charge", to_decimal(self.minimum_charge))
|
|
26
|
+
if self.maximum_charge is not None:
|
|
27
|
+
object.__setattr__(self, "maximum_charge", to_decimal(self.maximum_charge))
|
|
28
|
+
if self.rate < ZERO:
|
|
29
|
+
raise ValidationError("late charge rate cannot be negative")
|
|
30
|
+
if self.grace_days < 0:
|
|
31
|
+
raise ValidationError("grace_days cannot be negative")
|
|
32
|
+
if self.minimum_charge < ZERO:
|
|
33
|
+
raise ValidationError("minimum_charge cannot be negative")
|
|
34
|
+
if self.maximum_charge is not None and self.maximum_charge < ZERO:
|
|
35
|
+
raise ValidationError("maximum_charge cannot be negative")
|
|
36
|
+
if self.basis not in {"flat", "daily"}:
|
|
37
|
+
raise ValidationError("late charge basis must be 'flat' or 'daily'")
|
|
38
|
+
|
|
39
|
+
def calculate(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
overdue_amount: Decimal,
|
|
43
|
+
due_date: date,
|
|
44
|
+
as_of: date,
|
|
45
|
+
rounding: RoundingPolicy,
|
|
46
|
+
) -> Decimal:
|
|
47
|
+
overdue_amount = max_decimal(overdue_amount)
|
|
48
|
+
if overdue_amount == ZERO or self.rate == ZERO:
|
|
49
|
+
return ZERO
|
|
50
|
+
|
|
51
|
+
days_late = (as_of - due_date).days - self.grace_days
|
|
52
|
+
if days_late <= 0:
|
|
53
|
+
return ZERO
|
|
54
|
+
|
|
55
|
+
if self.basis == "daily":
|
|
56
|
+
charge = overdue_amount * self.rate * Decimal(days_late)
|
|
57
|
+
elif self.basis == "flat":
|
|
58
|
+
charge = overdue_amount * self.rate
|
|
59
|
+
if charge < self.minimum_charge:
|
|
60
|
+
charge = self.minimum_charge
|
|
61
|
+
if self.maximum_charge is not None and charge > self.maximum_charge:
|
|
62
|
+
charge = self.maximum_charge
|
|
63
|
+
return rounding.round_money(charge)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Explicit rounding policies for financial results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
7
|
+
|
|
8
|
+
from repaykit.exceptions import ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RoundingPolicy:
|
|
13
|
+
currency: str = "MYR"
|
|
14
|
+
decimal_places: int = 2
|
|
15
|
+
rounding_mode: str = ROUND_HALF_UP
|
|
16
|
+
rate_decimal_places: int = 12
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if self.decimal_places < 0:
|
|
20
|
+
raise ValidationError("decimal_places cannot be negative")
|
|
21
|
+
if self.rate_decimal_places < 0:
|
|
22
|
+
raise ValidationError("rate_decimal_places cannot be negative")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def money_quantum(self) -> Decimal:
|
|
26
|
+
return Decimal("1").scaleb(-self.decimal_places)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def rate_quantum(self) -> Decimal:
|
|
30
|
+
return Decimal("1").scaleb(-self.rate_decimal_places)
|
|
31
|
+
|
|
32
|
+
def round_money(self, amount: Decimal) -> Decimal:
|
|
33
|
+
return amount.quantize(self.money_quantum, rounding=self.rounding_mode)
|
|
34
|
+
|
|
35
|
+
def round_rate(self, rate: Decimal) -> Decimal:
|
|
36
|
+
return rate.quantize(self.rate_quantum, rounding=self.rounding_mode)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Settlement policy implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from repaykit.exceptions import SettlementError
|
|
11
|
+
from repaykit.money import ZERO, decimal, max_decimal
|
|
12
|
+
from repaykit.results import SettlementQuote
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from repaykit.account import LoanAccount
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SettlementPolicy:
|
|
20
|
+
other_charges: Decimal = ZERO
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
object.__setattr__(self, "other_charges", decimal(self.other_charges))
|
|
24
|
+
if self.other_charges < ZERO:
|
|
25
|
+
raise SettlementError("other_charges cannot be negative")
|
|
26
|
+
|
|
27
|
+
def quote(self, account: LoanAccount, as_of: date) -> SettlementQuote:
|
|
28
|
+
loan = account.loan
|
|
29
|
+
if as_of < loan.start_date:
|
|
30
|
+
raise SettlementError("settlement date cannot be before loan start date")
|
|
31
|
+
|
|
32
|
+
rows = loan.schedule()
|
|
33
|
+
paid = account.total_paid(as_of=as_of)
|
|
34
|
+
late_charges = account.late_charges(as_of=as_of)
|
|
35
|
+
due_rows = [row for row in rows if row.due_date <= as_of]
|
|
36
|
+
future_rows = [row for row in rows if row.due_date > as_of]
|
|
37
|
+
|
|
38
|
+
scheduled_principal_paid = sum((row.principal for row in due_rows), ZERO)
|
|
39
|
+
outstanding_principal = max_decimal(loan.principal - scheduled_principal_paid)
|
|
40
|
+
accrued_profit = sum((row.profit for row in due_rows), ZERO)
|
|
41
|
+
rebate = loan.repayment_method.unearned_profit_rebate(rows, as_of)
|
|
42
|
+
total_contract = sum((row.installment for row in rows), ZERO)
|
|
43
|
+
amount_payable = max_decimal(
|
|
44
|
+
total_contract - rebate + late_charges + self.other_charges - paid
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return SettlementQuote(
|
|
48
|
+
as_of=as_of,
|
|
49
|
+
outstanding_principal=loan.rounding.round_money(outstanding_principal),
|
|
50
|
+
accrued_profit=loan.rounding.round_money(accrued_profit),
|
|
51
|
+
unearned_profit_rebate=loan.rounding.round_money(rebate),
|
|
52
|
+
late_charges=loan.rounding.round_money(late_charges),
|
|
53
|
+
other_charges=loan.rounding.round_money(self.other_charges),
|
|
54
|
+
amount_payable=loan.rounding.round_money(amount_payable),
|
|
55
|
+
explanation=(
|
|
56
|
+
"Amount payable = total contract installments - method-specific unearned "
|
|
57
|
+
"profit rebate + late charges + other charges - payments received."
|
|
58
|
+
),
|
|
59
|
+
metadata={
|
|
60
|
+
"method": loan.repayment_method.name,
|
|
61
|
+
"periods_due": len(due_rows),
|
|
62
|
+
"periods_remaining": len(future_rows),
|
|
63
|
+
"total_paid": paid,
|
|
64
|
+
"total_contract": loan.rounding.round_money(total_contract),
|
|
65
|
+
},
|
|
66
|
+
)
|
repaykit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
repaykit/results.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Immutable calculation result objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from repaykit._serialization import result_to_dict
|
|
11
|
+
|
|
12
|
+
Metadata = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ScheduleRow:
|
|
17
|
+
period: int
|
|
18
|
+
due_date: date
|
|
19
|
+
opening_balance: Decimal
|
|
20
|
+
principal: Decimal
|
|
21
|
+
profit: Decimal
|
|
22
|
+
installment: Decimal
|
|
23
|
+
closing_balance: Decimal
|
|
24
|
+
metadata: Metadata = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return result_to_dict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Statement:
|
|
32
|
+
as_of: date
|
|
33
|
+
total_due: Decimal
|
|
34
|
+
total_paid: Decimal
|
|
35
|
+
arrears: Decimal
|
|
36
|
+
late_charges: Decimal
|
|
37
|
+
outstanding_balance: Decimal
|
|
38
|
+
next_due_date: date | None
|
|
39
|
+
metadata: Metadata = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
return result_to_dict(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class SettlementQuote:
|
|
47
|
+
as_of: date
|
|
48
|
+
outstanding_principal: Decimal
|
|
49
|
+
accrued_profit: Decimal
|
|
50
|
+
unearned_profit_rebate: Decimal
|
|
51
|
+
late_charges: Decimal
|
|
52
|
+
other_charges: Decimal
|
|
53
|
+
amount_payable: Decimal
|
|
54
|
+
explanation: str
|
|
55
|
+
metadata: Metadata = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return result_to_dict(self)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Payment schedule generators."""
|
|
2
|
+
|
|
3
|
+
from repaykit.schedules.base import PaymentSchedule
|
|
4
|
+
from repaykit.schedules.biweekly import BiWeeklySchedule
|
|
5
|
+
from repaykit.schedules.custom import CustomSchedule
|
|
6
|
+
from repaykit.schedules.daily import DailySchedule
|
|
7
|
+
from repaykit.schedules.monthly import MonthlySchedule
|
|
8
|
+
from repaykit.schedules.quarterly import QuarterlySchedule
|
|
9
|
+
from repaykit.schedules.weekly import WeeklySchedule
|
|
10
|
+
from repaykit.schedules.yearly import YearlySchedule
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BiWeeklySchedule",
|
|
14
|
+
"CustomSchedule",
|
|
15
|
+
"DailySchedule",
|
|
16
|
+
"MonthlySchedule",
|
|
17
|
+
"PaymentSchedule",
|
|
18
|
+
"QuarterlySchedule",
|
|
19
|
+
"WeeklySchedule",
|
|
20
|
+
"YearlySchedule",
|
|
21
|
+
]
|