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.
Files changed (48) hide show
  1. repaykit/__init__.py +18 -0
  2. repaykit/_serialization.py +28 -0
  3. repaykit/account.py +98 -0
  4. repaykit/accruals/__init__.py +8 -0
  5. repaykit/accruals/base.py +25 -0
  6. repaykit/accruals/daily.py +18 -0
  7. repaykit/accruals/flat.py +14 -0
  8. repaykit/accruals/periodic.py +12 -0
  9. repaykit/backends/README.md +12 -0
  10. repaykit/enums.py +21 -0
  11. repaykit/exceptions.py +21 -0
  12. repaykit/exporters/__init__.py +12 -0
  13. repaykit/exporters/csv.py +35 -0
  14. repaykit/exporters/dicts.py +19 -0
  15. repaykit/ledger/__init__.py +7 -0
  16. repaykit/ledger/account_statement.py +5 -0
  17. repaykit/ledger/payment.py +25 -0
  18. repaykit/ledger/transaction.py +17 -0
  19. repaykit/loan.py +157 -0
  20. repaykit/methods/__init__.py +15 -0
  21. repaykit/methods/base.py +46 -0
  22. repaykit/methods/constant_principal.py +77 -0
  23. repaykit/methods/flat_rate.py +93 -0
  24. repaykit/methods/reducing_balance.py +85 -0
  25. repaykit/methods/sum_of_digits.py +96 -0
  26. repaykit/money.py +37 -0
  27. repaykit/policies/__init__.py +14 -0
  28. repaykit/policies/allocation.py +21 -0
  29. repaykit/policies/late_charge.py +63 -0
  30. repaykit/policies/rounding.py +36 -0
  31. repaykit/policies/settlement.py +66 -0
  32. repaykit/py.typed +1 -0
  33. repaykit/results.py +58 -0
  34. repaykit/schedules/__init__.py +21 -0
  35. repaykit/schedules/base.py +48 -0
  36. repaykit/schedules/biweekly.py +18 -0
  37. repaykit/schedules/custom.py +42 -0
  38. repaykit/schedules/daily.py +28 -0
  39. repaykit/schedules/monthly.py +29 -0
  40. repaykit/schedules/quarterly.py +20 -0
  41. repaykit/schedules/weekly.py +23 -0
  42. repaykit/schedules/yearly.py +20 -0
  43. repaykit/terms/__init__.py +5 -0
  44. repaykit/terms/term.py +85 -0
  45. repaykit-1.0.0.dist-info/METADATA +285 -0
  46. repaykit-1.0.0.dist-info/RECORD +48 -0
  47. repaykit-1.0.0.dist-info/WHEEL +4 -0
  48. 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,7 @@
1
+ """Payment ledger objects."""
2
+
3
+ from repaykit.ledger.account_statement import Statement
4
+ from repaykit.ledger.payment import Payment
5
+ from repaykit.ledger.transaction import Transaction
6
+
7
+ __all__ = ["Payment", "Statement", "Transaction"]
@@ -0,0 +1,5 @@
1
+ """Statement exports."""
2
+
3
+ from repaykit.results import Statement
4
+
5
+ __all__ = ["Statement"]
@@ -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
+ ]
@@ -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"))