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
repaykit/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pure Python repayment schedule, ledger, and settlement toolkit."""
|
|
2
|
+
|
|
3
|
+
from repaykit.account import LoanAccount
|
|
4
|
+
from repaykit.loan import Loan
|
|
5
|
+
from repaykit.results import ScheduleRow, SettlementQuote, Statement
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
version = __version__
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Loan",
|
|
12
|
+
"LoanAccount",
|
|
13
|
+
"ScheduleRow",
|
|
14
|
+
"SettlementQuote",
|
|
15
|
+
"Statement",
|
|
16
|
+
"__version__",
|
|
17
|
+
"version",
|
|
18
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Serialization helpers that avoid lossy float conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import fields, is_dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serialize_value(value: Any) -> Any:
|
|
12
|
+
if isinstance(value, Decimal):
|
|
13
|
+
return str(value)
|
|
14
|
+
if isinstance(value, date):
|
|
15
|
+
return value.isoformat()
|
|
16
|
+
if isinstance(value, dict):
|
|
17
|
+
return {key: serialize_value(item) for key, item in value.items()}
|
|
18
|
+
if isinstance(value, list):
|
|
19
|
+
return [serialize_value(item) for item in value]
|
|
20
|
+
if isinstance(value, tuple):
|
|
21
|
+
return tuple(serialize_value(item) for item in value)
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def result_to_dict(result: Any) -> dict[str, Any]:
|
|
26
|
+
if not is_dataclass(result) or isinstance(result, type):
|
|
27
|
+
raise TypeError("result_to_dict expects a dataclass result object")
|
|
28
|
+
return {field.name: serialize_value(getattr(result, field.name)) for field in fields(result)}
|
repaykit/account.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Loan account ledger and statements."""
|
|
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 TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from repaykit.exceptions import ValidationError
|
|
11
|
+
from repaykit.ledger import Payment
|
|
12
|
+
from repaykit.money import ZERO, max_decimal
|
|
13
|
+
from repaykit.results import SettlementQuote, Statement
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from repaykit.loan import Loan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class LoanAccount:
|
|
21
|
+
loan: Loan
|
|
22
|
+
payments: list[Payment] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def add_payment(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
amount: Decimal,
|
|
28
|
+
paid_at: date,
|
|
29
|
+
reference: str | None = None,
|
|
30
|
+
metadata: dict[str, Any] | None = None,
|
|
31
|
+
) -> Payment:
|
|
32
|
+
if paid_at < self.loan.start_date:
|
|
33
|
+
raise ValidationError("payment date cannot be before loan start_date")
|
|
34
|
+
payment = Payment(
|
|
35
|
+
amount=amount,
|
|
36
|
+
paid_at=paid_at,
|
|
37
|
+
reference=reference,
|
|
38
|
+
metadata=metadata or {},
|
|
39
|
+
)
|
|
40
|
+
self.payments.append(payment)
|
|
41
|
+
self.payments.sort(key=lambda item: (item.paid_at, item.reference or ""))
|
|
42
|
+
return payment
|
|
43
|
+
|
|
44
|
+
def total_paid(self, *, as_of: date) -> Decimal:
|
|
45
|
+
return self.loan.rounding.round_money(
|
|
46
|
+
sum((payment.amount for payment in self.payments if payment.paid_at <= as_of), ZERO)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def late_charges(self, *, as_of: date) -> Decimal:
|
|
50
|
+
remaining_paid = self.total_paid(as_of=as_of)
|
|
51
|
+
charges = ZERO
|
|
52
|
+
for row in self.loan.iter_schedule():
|
|
53
|
+
if row.due_date > as_of:
|
|
54
|
+
break
|
|
55
|
+
applied = min(remaining_paid, row.installment)
|
|
56
|
+
remaining_paid -= applied
|
|
57
|
+
overdue = max_decimal(row.installment - applied)
|
|
58
|
+
charges += self.loan.late_charge_policy.calculate(
|
|
59
|
+
overdue_amount=overdue,
|
|
60
|
+
due_date=row.due_date,
|
|
61
|
+
as_of=as_of,
|
|
62
|
+
rounding=self.loan.rounding,
|
|
63
|
+
)
|
|
64
|
+
return self.loan.rounding.round_money(charges)
|
|
65
|
+
|
|
66
|
+
def statement(self, *, as_of: date) -> Statement:
|
|
67
|
+
rows = self.loan.schedule()
|
|
68
|
+
due_rows = [row for row in rows if row.due_date <= as_of]
|
|
69
|
+
next_row = next((row for row in rows if row.due_date > as_of), None)
|
|
70
|
+
total_due = self.loan.rounding.round_money(sum((row.installment for row in due_rows), ZERO))
|
|
71
|
+
total_paid = self.total_paid(as_of=as_of)
|
|
72
|
+
late_charges = self.late_charges(as_of=as_of)
|
|
73
|
+
arrears = self.loan.rounding.round_money(max_decimal(total_due + late_charges - total_paid))
|
|
74
|
+
contract_total = self.loan.rounding.round_money(
|
|
75
|
+
sum((row.installment for row in rows), ZERO)
|
|
76
|
+
)
|
|
77
|
+
outstanding = self.loan.rounding.round_money(
|
|
78
|
+
max_decimal(contract_total + late_charges - total_paid)
|
|
79
|
+
)
|
|
80
|
+
return Statement(
|
|
81
|
+
as_of=as_of,
|
|
82
|
+
total_due=total_due,
|
|
83
|
+
total_paid=total_paid,
|
|
84
|
+
arrears=arrears,
|
|
85
|
+
late_charges=late_charges,
|
|
86
|
+
outstanding_balance=outstanding,
|
|
87
|
+
next_due_date=None if next_row is None else next_row.due_date,
|
|
88
|
+
metadata={
|
|
89
|
+
"payments_count": len(
|
|
90
|
+
[payment for payment in self.payments if payment.paid_at <= as_of]
|
|
91
|
+
),
|
|
92
|
+
"periods_due": len(due_rows),
|
|
93
|
+
"contract_total": contract_total,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def full_settlement(self, *, as_of: date) -> SettlementQuote:
|
|
98
|
+
return self.loan.settlement_policy.quote(self, as_of)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Accrual policies."""
|
|
2
|
+
|
|
3
|
+
from repaykit.accruals.base import AccrualPolicy
|
|
4
|
+
from repaykit.accruals.daily import DailyAccrual
|
|
5
|
+
from repaykit.accruals.flat import FlatAccrual
|
|
6
|
+
from repaykit.accruals.periodic import PeriodicAccrual
|
|
7
|
+
|
|
8
|
+
__all__ = ["AccrualPolicy", "DailyAccrual", "FlatAccrual", "PeriodicAccrual"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Accrual policy interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from repaykit.exceptions import ValidationError
|
|
10
|
+
from repaykit.money import decimal, require_non_negative
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class AccrualPolicy(ABC):
|
|
15
|
+
annual_rate: Decimal
|
|
16
|
+
allow_negative_rate: bool = False
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
object.__setattr__(self, "annual_rate", decimal(self.annual_rate))
|
|
20
|
+
if self.annual_rate < Decimal("0") and not self.allow_negative_rate:
|
|
21
|
+
raise ValidationError("annual_rate cannot be negative")
|
|
22
|
+
|
|
23
|
+
def periodic_rate(self, periods_per_year: Decimal) -> Decimal:
|
|
24
|
+
require_non_negative(periods_per_year, "periods_per_year")
|
|
25
|
+
return self.annual_rate / periods_per_year
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Daily accrual policy."""
|
|
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.accruals.base import AccrualPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class DailyAccrual(AccrualPolicy):
|
|
14
|
+
day_count_basis: Decimal = Decimal("365")
|
|
15
|
+
|
|
16
|
+
def accrue(self, principal: Decimal, start: date, end: date) -> Decimal:
|
|
17
|
+
days = Decimal((end - start).days)
|
|
18
|
+
return principal * self.annual_rate * days / self.day_count_basis
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Flat accrual policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from repaykit.accruals.base import AccrualPolicy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class FlatAccrual(AccrualPolicy):
|
|
13
|
+
def total_profit(self, principal: Decimal, term_years: Decimal) -> Decimal:
|
|
14
|
+
return principal * self.annual_rate * term_years
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Periodic simple-rate accrual policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from repaykit.accruals.base import AccrualPolicy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class PeriodicAccrual(AccrualPolicy):
|
|
12
|
+
pass
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Backend Design Note
|
|
2
|
+
|
|
3
|
+
The public API is intentionally pure Python and policy-oriented. A future optional Rust/PyO3
|
|
4
|
+
backend can accelerate bulk schedule generation by implementing the same repayment method
|
|
5
|
+
interfaces behind a feature flag or backend registry.
|
|
6
|
+
|
|
7
|
+
Compatibility rules for a compiled backend:
|
|
8
|
+
|
|
9
|
+
- Accept and return `Decimal`-compatible values at the Python boundary.
|
|
10
|
+
- Preserve `ScheduleRow`, `Statement`, and `SettlementQuote` public shapes.
|
|
11
|
+
- Preserve rounding policy behavior exactly.
|
|
12
|
+
- Keep the pure Python backend as the reference implementation and fallback.
|
repaykit/enums.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Small enum types used by repaykit."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PaymentFrequency(StrEnum):
|
|
7
|
+
DAILY = "daily"
|
|
8
|
+
WEEKLY = "weekly"
|
|
9
|
+
BIWEEKLY = "biweekly"
|
|
10
|
+
MONTHLY = "monthly"
|
|
11
|
+
QUARTERLY = "quarterly"
|
|
12
|
+
YEARLY = "yearly"
|
|
13
|
+
CUSTOM = "custom"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepaymentMethodName(StrEnum):
|
|
17
|
+
FLAT_RATE = "flat_rate"
|
|
18
|
+
SUM_OF_DIGITS = "sum_of_digits"
|
|
19
|
+
RULE_OF_78 = "rule_of_78"
|
|
20
|
+
REDUCING_BALANCE = "reducing_balance"
|
|
21
|
+
CONSTANT_PRINCIPAL = "constant_principal"
|
repaykit/exceptions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom exceptions raised by repaykit."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RepayKitError(Exception):
|
|
5
|
+
"""Base exception for repaykit."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(RepayKitError, ValueError):
|
|
9
|
+
"""Raised when user input fails validation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsupportedMethodError(RepayKitError):
|
|
13
|
+
"""Raised when a requested repayment method is not supported."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScheduleGenerationError(RepayKitError):
|
|
17
|
+
"""Raised when a schedule cannot be generated safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SettlementError(RepayKitError):
|
|
21
|
+
"""Raised when a settlement quote cannot be produced."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Export helpers."""
|
|
2
|
+
|
|
3
|
+
from repaykit.exporters.csv import schedule_to_csv, write_schedule_csv
|
|
4
|
+
from repaykit.exporters.dicts import rows_to_dicts, schedule_to_dicts, to_dict
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"rows_to_dicts",
|
|
8
|
+
"schedule_to_csv",
|
|
9
|
+
"schedule_to_dicts",
|
|
10
|
+
"to_dict",
|
|
11
|
+
"write_schedule_csv",
|
|
12
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CSV exporters for schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from repaykit.exporters.dicts import rows_to_dicts
|
|
10
|
+
from repaykit.results import ScheduleRow
|
|
11
|
+
|
|
12
|
+
CSV_HEADERS = [
|
|
13
|
+
"period",
|
|
14
|
+
"due_date",
|
|
15
|
+
"opening_balance",
|
|
16
|
+
"principal",
|
|
17
|
+
"profit",
|
|
18
|
+
"installment",
|
|
19
|
+
"closing_balance",
|
|
20
|
+
"metadata",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_schedule_csv(rows: Iterable[ScheduleRow], path: str | Path) -> None:
|
|
25
|
+
data = rows_to_dicts(rows)
|
|
26
|
+
if not data:
|
|
27
|
+
Path(path).write_text("", encoding="utf-8")
|
|
28
|
+
return
|
|
29
|
+
with Path(path).open("w", newline="", encoding="utf-8") as handle:
|
|
30
|
+
writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS)
|
|
31
|
+
writer.writeheader()
|
|
32
|
+
writer.writerows(data)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
schedule_to_csv = write_schedule_csv
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Dictionary exporters for result objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from repaykit._serialization import result_to_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_dict(row: Any) -> dict[str, Any]:
|
|
12
|
+
return result_to_dict(row)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def rows_to_dicts(rows: Iterable[Any]) -> list[dict[str, Any]]:
|
|
16
|
+
return [to_dict(row) for row in rows]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
schedule_to_dicts = rows_to_dicts
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Payment records."""
|
|
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.exceptions import ValidationError
|
|
11
|
+
from repaykit.money import decimal, require_positive
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Payment:
|
|
16
|
+
amount: Decimal
|
|
17
|
+
paid_at: date
|
|
18
|
+
reference: str | None = None
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
object.__setattr__(self, "amount", decimal(self.amount))
|
|
23
|
+
require_positive(self.amount, "payment amount")
|
|
24
|
+
if self.reference is not None and not self.reference.strip():
|
|
25
|
+
raise ValidationError("payment reference cannot be blank")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Ledger transaction primitives."""
|
|
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
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Transaction:
|
|
13
|
+
amount: Decimal
|
|
14
|
+
posted_at: date
|
|
15
|
+
kind: str
|
|
16
|
+
reference: str | None = None
|
|
17
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
repaykit/loan.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Public loan contract model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from repaykit.accruals import AccrualPolicy, DailyAccrual, FlatAccrual, PeriodicAccrual
|
|
12
|
+
from repaykit.enums import RepaymentMethodName
|
|
13
|
+
from repaykit.exceptions import UnsupportedMethodError, ValidationError
|
|
14
|
+
from repaykit.methods import (
|
|
15
|
+
ConstantPrincipalAllocation,
|
|
16
|
+
FlatRateAllocation,
|
|
17
|
+
ReducingBalanceAllocation,
|
|
18
|
+
RepaymentMethod,
|
|
19
|
+
SumOfDigitsAllocation,
|
|
20
|
+
)
|
|
21
|
+
from repaykit.money import decimal, require_positive
|
|
22
|
+
from repaykit.policies import LateChargePolicy, RoundingPolicy, SettlementPolicy
|
|
23
|
+
from repaykit.results import ScheduleRow
|
|
24
|
+
from repaykit.schedules import (
|
|
25
|
+
BiWeeklySchedule,
|
|
26
|
+
DailySchedule,
|
|
27
|
+
MonthlySchedule,
|
|
28
|
+
PaymentSchedule,
|
|
29
|
+
QuarterlySchedule,
|
|
30
|
+
WeeklySchedule,
|
|
31
|
+
YearlySchedule,
|
|
32
|
+
)
|
|
33
|
+
from repaykit.terms import Term
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class Loan:
|
|
38
|
+
principal: Decimal
|
|
39
|
+
term: Term
|
|
40
|
+
payment_schedule: PaymentSchedule
|
|
41
|
+
accrual_policy: AccrualPolicy
|
|
42
|
+
repayment_method: RepaymentMethod
|
|
43
|
+
rounding: RoundingPolicy = field(default_factory=RoundingPolicy)
|
|
44
|
+
start_date: date = field(default_factory=date.today)
|
|
45
|
+
late_charge_policy: LateChargePolicy = field(default_factory=LateChargePolicy)
|
|
46
|
+
settlement_policy: SettlementPolicy = field(default_factory=SettlementPolicy)
|
|
47
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
self.principal = decimal(self.principal)
|
|
51
|
+
require_positive(self.principal, "principal")
|
|
52
|
+
if self.period_count() <= 0:
|
|
53
|
+
raise ValidationError("term must produce at least one period")
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(
|
|
57
|
+
cls,
|
|
58
|
+
*,
|
|
59
|
+
amount: Decimal,
|
|
60
|
+
annual_rate: Decimal,
|
|
61
|
+
term: str | Term,
|
|
62
|
+
payment_frequency: str,
|
|
63
|
+
method: str,
|
|
64
|
+
start_date: date,
|
|
65
|
+
currency: str = "MYR",
|
|
66
|
+
**metadata: Any,
|
|
67
|
+
) -> Loan:
|
|
68
|
+
term_obj = Term.parse(term)
|
|
69
|
+
schedule = _schedule_from_frequency(payment_frequency)
|
|
70
|
+
method_obj = _method_from_name(method)
|
|
71
|
+
accrual: AccrualPolicy
|
|
72
|
+
if isinstance(method_obj, FlatRateAllocation | SumOfDigitsAllocation):
|
|
73
|
+
accrual = FlatAccrual(annual_rate=decimal(annual_rate))
|
|
74
|
+
elif payment_frequency == "daily":
|
|
75
|
+
accrual = DailyAccrual(annual_rate=decimal(annual_rate))
|
|
76
|
+
else:
|
|
77
|
+
accrual = PeriodicAccrual(annual_rate=decimal(annual_rate))
|
|
78
|
+
return cls(
|
|
79
|
+
principal=decimal(amount),
|
|
80
|
+
term=term_obj,
|
|
81
|
+
payment_schedule=schedule,
|
|
82
|
+
accrual_policy=accrual,
|
|
83
|
+
repayment_method=method_obj,
|
|
84
|
+
rounding=RoundingPolicy(currency=currency),
|
|
85
|
+
start_date=start_date,
|
|
86
|
+
metadata=metadata,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def annual_rate(self) -> Decimal:
|
|
91
|
+
return self.accrual_policy.annual_rate
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def periods_per_year(self) -> Decimal:
|
|
95
|
+
return self.payment_schedule.periods_per_year
|
|
96
|
+
|
|
97
|
+
def period_count(self) -> int:
|
|
98
|
+
return self.payment_schedule.period_count(self.start_date, self.term)
|
|
99
|
+
|
|
100
|
+
def term_years(self) -> Decimal:
|
|
101
|
+
return self.payment_schedule.term_years(self.start_date, self.term)
|
|
102
|
+
|
|
103
|
+
def payment_amount(self) -> Decimal:
|
|
104
|
+
return self.repayment_method.payment_amount(
|
|
105
|
+
principal=self.principal,
|
|
106
|
+
annual_rate=self.annual_rate,
|
|
107
|
+
term_years=self.term_years(),
|
|
108
|
+
periods=self.period_count(),
|
|
109
|
+
periods_per_year=self.periods_per_year,
|
|
110
|
+
rounding=self.rounding,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def iter_schedule(self) -> Iterator[ScheduleRow]:
|
|
114
|
+
return self.repayment_method.iter_schedule(
|
|
115
|
+
principal=self.principal,
|
|
116
|
+
annual_rate=self.annual_rate,
|
|
117
|
+
term_years=self.term_years(),
|
|
118
|
+
periods=self.period_count(),
|
|
119
|
+
periods_per_year=self.periods_per_year,
|
|
120
|
+
start_date=self.start_date,
|
|
121
|
+
due_dates=self.payment_schedule.iter_due_dates(self.start_date, self.term),
|
|
122
|
+
rounding=self.rounding,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def schedule(self) -> list[ScheduleRow]:
|
|
126
|
+
return list(self.iter_schedule())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _schedule_from_frequency(value: str) -> PaymentSchedule:
|
|
130
|
+
normalized = value.lower().replace("-", "_")
|
|
131
|
+
schedules: dict[str, PaymentSchedule] = {
|
|
132
|
+
"daily": DailySchedule(),
|
|
133
|
+
"weekly": WeeklySchedule(),
|
|
134
|
+
"biweekly": BiWeeklySchedule(),
|
|
135
|
+
"bi_weekly": BiWeeklySchedule(),
|
|
136
|
+
"monthly": MonthlySchedule(),
|
|
137
|
+
"quarterly": QuarterlySchedule(),
|
|
138
|
+
"yearly": YearlySchedule(),
|
|
139
|
+
"annual": YearlySchedule(),
|
|
140
|
+
}
|
|
141
|
+
try:
|
|
142
|
+
return schedules[normalized]
|
|
143
|
+
except KeyError as exc:
|
|
144
|
+
raise ValidationError(f"unsupported payment frequency: {value}") from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _method_from_name(value: str) -> RepaymentMethod:
|
|
148
|
+
normalized = value.lower().replace("-", "_")
|
|
149
|
+
if normalized == RepaymentMethodName.FLAT_RATE:
|
|
150
|
+
return FlatRateAllocation()
|
|
151
|
+
if normalized in {RepaymentMethodName.SUM_OF_DIGITS, RepaymentMethodName.RULE_OF_78}:
|
|
152
|
+
return SumOfDigitsAllocation()
|
|
153
|
+
if normalized == RepaymentMethodName.REDUCING_BALANCE:
|
|
154
|
+
return ReducingBalanceAllocation()
|
|
155
|
+
if normalized == RepaymentMethodName.CONSTANT_PRINCIPAL:
|
|
156
|
+
return ConstantPrincipalAllocation()
|
|
157
|
+
raise UnsupportedMethodError(f"unsupported repayment method: {value}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Repayment allocation methods."""
|
|
2
|
+
|
|
3
|
+
from repaykit.methods.base import RepaymentMethod
|
|
4
|
+
from repaykit.methods.constant_principal import ConstantPrincipalAllocation
|
|
5
|
+
from repaykit.methods.flat_rate import FlatRateAllocation
|
|
6
|
+
from repaykit.methods.reducing_balance import ReducingBalanceAllocation
|
|
7
|
+
from repaykit.methods.sum_of_digits import SumOfDigitsAllocation
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConstantPrincipalAllocation",
|
|
11
|
+
"FlatRateAllocation",
|
|
12
|
+
"ReducingBalanceAllocation",
|
|
13
|
+
"RepaymentMethod",
|
|
14
|
+
"SumOfDigitsAllocation",
|
|
15
|
+
]
|
repaykit/methods/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Repayment method interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.policies.rounding import RoundingPolicy
|
|
11
|
+
from repaykit.results import ScheduleRow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RepaymentMethod(ABC):
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def payment_amount(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
principal: Decimal,
|
|
22
|
+
annual_rate: Decimal,
|
|
23
|
+
term_years: Decimal,
|
|
24
|
+
periods: int,
|
|
25
|
+
periods_per_year: Decimal,
|
|
26
|
+
rounding: RoundingPolicy,
|
|
27
|
+
) -> Decimal:
|
|
28
|
+
"""Return a representative installment amount."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def iter_schedule(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
principal: Decimal,
|
|
35
|
+
annual_rate: Decimal,
|
|
36
|
+
term_years: Decimal,
|
|
37
|
+
periods: int,
|
|
38
|
+
periods_per_year: Decimal,
|
|
39
|
+
start_date: date,
|
|
40
|
+
due_dates: Iterable[date],
|
|
41
|
+
rounding: RoundingPolicy,
|
|
42
|
+
) -> Iterator[ScheduleRow]:
|
|
43
|
+
"""Yield immutable schedule rows."""
|
|
44
|
+
|
|
45
|
+
def unearned_profit_rebate(self, rows: list[ScheduleRow], as_of: date) -> Decimal:
|
|
46
|
+
return sum((row.profit for row in rows if row.due_date > as_of), Decimal("0"))
|