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,48 @@
|
|
|
1
|
+
"""Payment schedule interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.exceptions import ScheduleGenerationError
|
|
11
|
+
from repaykit.terms import Term
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PaymentSchedule(ABC):
|
|
15
|
+
frequency_name: str
|
|
16
|
+
periods_per_year: Decimal
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
20
|
+
"""Return the due date for a 1-based period."""
|
|
21
|
+
|
|
22
|
+
def iter_due_dates(self, start_date: date, term: Term) -> Iterator[date]:
|
|
23
|
+
if term.periods is not None:
|
|
24
|
+
for period in range(1, term.periods + 1):
|
|
25
|
+
yield self._next_due_date(start_date, period)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
end_date = term.end_after(start_date)
|
|
29
|
+
period = 1
|
|
30
|
+
while True:
|
|
31
|
+
due_date = self._next_due_date(start_date, period)
|
|
32
|
+
if due_date > end_date:
|
|
33
|
+
break
|
|
34
|
+
yield due_date
|
|
35
|
+
period += 1
|
|
36
|
+
if period > 1_000_000:
|
|
37
|
+
raise ScheduleGenerationError("schedule exceeded the safety period limit")
|
|
38
|
+
|
|
39
|
+
def period_count(self, start_date: date, term: Term) -> int:
|
|
40
|
+
count = sum(1 for _ in self.iter_due_dates(start_date, term))
|
|
41
|
+
if count <= 0:
|
|
42
|
+
raise ScheduleGenerationError("term and schedule must produce at least one period")
|
|
43
|
+
return count
|
|
44
|
+
|
|
45
|
+
def term_years(self, start_date: date, term: Term) -> Decimal:
|
|
46
|
+
if term.periods is not None:
|
|
47
|
+
return Decimal(term.periods) / self.periods_per_year
|
|
48
|
+
return term.approximate_years(start_date)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Biweekly repayment schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from repaykit.schedules.base import PaymentSchedule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class BiWeeklySchedule(PaymentSchedule):
|
|
14
|
+
frequency_name: str = "biweekly"
|
|
15
|
+
periods_per_year: Decimal = Decimal("26")
|
|
16
|
+
|
|
17
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
18
|
+
return start_date + timedelta(weeks=period * 2)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Custom explicit due-date schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
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.schedules.base import PaymentSchedule
|
|
12
|
+
from repaykit.terms import Term
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class CustomSchedule(PaymentSchedule):
|
|
17
|
+
dates: Sequence[date]
|
|
18
|
+
frequency_name: str = "custom"
|
|
19
|
+
periods_per_year: Decimal = Decimal("12")
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if not self.dates:
|
|
23
|
+
raise ValidationError("custom schedule requires at least one date")
|
|
24
|
+
if list(self.dates) != sorted(self.dates):
|
|
25
|
+
raise ValidationError("custom schedule dates must be sorted")
|
|
26
|
+
if len(set(self.dates)) != len(self.dates):
|
|
27
|
+
raise ValidationError("custom schedule dates must be unique")
|
|
28
|
+
|
|
29
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
30
|
+
return self.dates[period - 1]
|
|
31
|
+
|
|
32
|
+
def iter_due_dates(self, start_date: date, term: Term) -> Iterator[date]:
|
|
33
|
+
limit = term.periods if term.periods is not None else len(self.dates)
|
|
34
|
+
for due_date in self.dates[:limit]:
|
|
35
|
+
if due_date <= start_date:
|
|
36
|
+
raise ValidationError("custom schedule dates must be after start_date")
|
|
37
|
+
if term.end_date is not None and due_date > term.end_date:
|
|
38
|
+
break
|
|
39
|
+
yield due_date
|
|
40
|
+
|
|
41
|
+
def period_count(self, start_date: date, term: Term) -> int:
|
|
42
|
+
return sum(1 for _ in self.iter_due_dates(start_date, term))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Daily repayment schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from repaykit.schedules.base import PaymentSchedule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class DailySchedule(PaymentSchedule):
|
|
14
|
+
include_weekends: bool = True
|
|
15
|
+
frequency_name: str = "daily"
|
|
16
|
+
periods_per_year: Decimal = Decimal("365")
|
|
17
|
+
|
|
18
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
19
|
+
if self.include_weekends:
|
|
20
|
+
return start_date + timedelta(days=period)
|
|
21
|
+
|
|
22
|
+
current = start_date
|
|
23
|
+
business_days = 0
|
|
24
|
+
while business_days < period:
|
|
25
|
+
current += timedelta(days=1)
|
|
26
|
+
if current.weekday() < 5:
|
|
27
|
+
business_days += 1
|
|
28
|
+
return current
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Monthly repayment schedules with month-end safety."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.schedules.base import PaymentSchedule
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def add_months(value: date, months: int, *, day: int | None = None) -> date:
|
|
14
|
+
month_index = value.month - 1 + months
|
|
15
|
+
year = value.year + month_index // 12
|
|
16
|
+
month = month_index % 12 + 1
|
|
17
|
+
target_day = value.day if day is None else day
|
|
18
|
+
last_day = calendar.monthrange(year, month)[1]
|
|
19
|
+
return date(year, month, min(target_day, last_day))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class MonthlySchedule(PaymentSchedule):
|
|
24
|
+
day: int | None = None
|
|
25
|
+
frequency_name: str = "monthly"
|
|
26
|
+
periods_per_year: Decimal = Decimal("12")
|
|
27
|
+
|
|
28
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
29
|
+
return add_months(start_date, period, day=self.day)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Quarterly repayment schedules."""
|
|
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.schedules.base import PaymentSchedule
|
|
10
|
+
from repaykit.schedules.monthly import add_months
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class QuarterlySchedule(PaymentSchedule):
|
|
15
|
+
day: int | None = None
|
|
16
|
+
frequency_name: str = "quarterly"
|
|
17
|
+
periods_per_year: Decimal = Decimal("4")
|
|
18
|
+
|
|
19
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
20
|
+
return add_months(start_date, period * 3, day=self.day)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Weekly repayment schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from repaykit.schedules.base import PaymentSchedule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class WeeklySchedule(PaymentSchedule):
|
|
14
|
+
weekday: int | None = None
|
|
15
|
+
frequency_name: str = "weekly"
|
|
16
|
+
periods_per_year: Decimal = Decimal("52")
|
|
17
|
+
|
|
18
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
19
|
+
if self.weekday is None:
|
|
20
|
+
return start_date + timedelta(weeks=period)
|
|
21
|
+
first = start_date + timedelta(days=1)
|
|
22
|
+
days_until = (self.weekday - first.weekday()) % 7
|
|
23
|
+
return first + timedelta(days=days_until + (period - 1) * 7)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Yearly repayment schedules."""
|
|
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.schedules.base import PaymentSchedule
|
|
10
|
+
from repaykit.schedules.monthly import add_months
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class YearlySchedule(PaymentSchedule):
|
|
15
|
+
day: int | None = None
|
|
16
|
+
frequency_name: str = "yearly"
|
|
17
|
+
periods_per_year: Decimal = Decimal("1")
|
|
18
|
+
|
|
19
|
+
def _next_due_date(self, start_date: date, period: int) -> date:
|
|
20
|
+
return add_months(start_date, period * 12, day=self.day)
|
repaykit/terms/term.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Loan term modeling and parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from repaykit.exceptions import ValidationError
|
|
11
|
+
|
|
12
|
+
_TERM_RE = re.compile(
|
|
13
|
+
r"^\s*(?P<count>\d+)\s*"
|
|
14
|
+
r"(?P<unit>day|days|week|weeks|month|months|year|years|period|periods)\s*$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Term:
|
|
20
|
+
days: int | None = None
|
|
21
|
+
weeks: int | None = None
|
|
22
|
+
months: int | None = None
|
|
23
|
+
years: int | None = None
|
|
24
|
+
periods: int | None = None
|
|
25
|
+
end_date: date | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
values = [self.days, self.weeks, self.months, self.years, self.periods, self.end_date]
|
|
29
|
+
if sum(value is not None for value in values) != 1:
|
|
30
|
+
raise ValidationError("exactly one term dimension must be provided")
|
|
31
|
+
for name in ("days", "weeks", "months", "years", "periods"):
|
|
32
|
+
value = getattr(self, name)
|
|
33
|
+
if value is not None and value <= 0:
|
|
34
|
+
raise ValidationError(f"term {name} must be positive")
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse(cls, value: str | Term) -> Term:
|
|
38
|
+
if isinstance(value, Term):
|
|
39
|
+
return value
|
|
40
|
+
match = _TERM_RE.match(value)
|
|
41
|
+
if not match:
|
|
42
|
+
raise ValidationError("term must look like '24 months', '52 weeks', or '10 periods'")
|
|
43
|
+
count = int(match.group("count"))
|
|
44
|
+
unit = match.group("unit")
|
|
45
|
+
if unit.startswith("day"):
|
|
46
|
+
return cls(days=count)
|
|
47
|
+
if unit.startswith("week"):
|
|
48
|
+
return cls(weeks=count)
|
|
49
|
+
if unit.startswith("month"):
|
|
50
|
+
return cls(months=count)
|
|
51
|
+
if unit.startswith("year"):
|
|
52
|
+
return cls(years=count)
|
|
53
|
+
return cls(periods=count)
|
|
54
|
+
|
|
55
|
+
def approximate_years(self, start_date: date | None = None) -> Decimal:
|
|
56
|
+
if self.days is not None:
|
|
57
|
+
return Decimal(self.days) / Decimal("365")
|
|
58
|
+
if self.weeks is not None:
|
|
59
|
+
return Decimal(self.weeks) / Decimal("52")
|
|
60
|
+
if self.months is not None:
|
|
61
|
+
return Decimal(self.months) / Decimal("12")
|
|
62
|
+
if self.years is not None:
|
|
63
|
+
return Decimal(self.years)
|
|
64
|
+
if self.end_date is not None:
|
|
65
|
+
if start_date is None:
|
|
66
|
+
raise ValidationError("start_date is required for end_date terms")
|
|
67
|
+
return Decimal((self.end_date - start_date).days) / Decimal("365")
|
|
68
|
+
raise ValidationError("period-based terms need a schedule to infer years")
|
|
69
|
+
|
|
70
|
+
def end_after(self, start_date: date) -> date:
|
|
71
|
+
if self.days is not None:
|
|
72
|
+
return start_date + timedelta(days=self.days)
|
|
73
|
+
if self.weeks is not None:
|
|
74
|
+
return start_date + timedelta(weeks=self.weeks)
|
|
75
|
+
if self.months is not None:
|
|
76
|
+
from repaykit.schedules.monthly import add_months
|
|
77
|
+
|
|
78
|
+
return add_months(start_date, self.months)
|
|
79
|
+
if self.years is not None:
|
|
80
|
+
from repaykit.schedules.monthly import add_months
|
|
81
|
+
|
|
82
|
+
return add_months(start_date, self.years * 12)
|
|
83
|
+
if self.end_date is not None:
|
|
84
|
+
return self.end_date
|
|
85
|
+
raise ValidationError("period-based terms do not have a calendar end without a schedule")
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repaykit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pure Python repayment schedule, financing, loan ledger, and settlement calculation toolkit.
|
|
5
|
+
Project-URL: Homepage, https://github.com/finsure-techlab/repaykit
|
|
6
|
+
Project-URL: Repository, https://github.com/finsure-techlab/repaykit
|
|
7
|
+
Project-URL: Issues, https://github.com/finsure-techlab/repaykit/issues
|
|
8
|
+
Author: finsure techlab
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: amortization,financing,ledger,loan,repayment,settlement
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: coverage; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# repaykit
|
|
35
|
+
|
|
36
|
+
Pure Python repayment schedule, financing, loan ledger, and settlement calculation toolkit.
|
|
37
|
+
|
|
38
|
+
`repaykit` helps software teams calculate loan and financing schedules, payment ledgers, late
|
|
39
|
+
charges, statement balances, and settlement quotes. It is framework-agnostic, uses `Decimal` for
|
|
40
|
+
money and rates, and keeps business rules policy-driven.
|
|
41
|
+
|
|
42
|
+
## What It Is Not
|
|
43
|
+
|
|
44
|
+
`repaykit` is not a loan origination system, accounting system, regulatory compliance engine, or
|
|
45
|
+
legal opinion. It does not provide legal, accounting, tax, Shariah, regulatory, lending, or
|
|
46
|
+
financial advice. Users are responsible for validating calculations, policies, disclosures, and
|
|
47
|
+
compliance requirements for their jurisdiction and product.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install repaykit
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Supported Methods
|
|
56
|
+
|
|
57
|
+
- Flat rate
|
|
58
|
+
- Sum-of-digits / Rule of 78
|
|
59
|
+
- Reducing balance amortization
|
|
60
|
+
- Constant principal repayment
|
|
61
|
+
|
|
62
|
+
## Supported Schedules
|
|
63
|
+
|
|
64
|
+
- Daily, with optional weekend skipping
|
|
65
|
+
- Weekly
|
|
66
|
+
- Biweekly
|
|
67
|
+
- Monthly, with month-end safety
|
|
68
|
+
- Quarterly
|
|
69
|
+
- Yearly
|
|
70
|
+
- Custom explicit due dates
|
|
71
|
+
|
|
72
|
+
## Quickstart
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from datetime import date
|
|
76
|
+
from decimal import Decimal
|
|
77
|
+
|
|
78
|
+
from repaykit import Loan
|
|
79
|
+
|
|
80
|
+
loan = Loan.create(
|
|
81
|
+
amount=Decimal("10000.00"),
|
|
82
|
+
annual_rate=Decimal("0.08"),
|
|
83
|
+
term="24 months",
|
|
84
|
+
payment_frequency="monthly",
|
|
85
|
+
method="flat_rate",
|
|
86
|
+
start_date=date(2026, 6, 1),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
payment = loan.payment_amount()
|
|
90
|
+
rows = loan.schedule()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use `iter_schedule()` for lazy row generation, especially for daily schedules.
|
|
94
|
+
|
|
95
|
+
## Expert Mode
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from datetime import date
|
|
99
|
+
from decimal import Decimal
|
|
100
|
+
|
|
101
|
+
from repaykit import Loan
|
|
102
|
+
from repaykit.accruals import FlatAccrual
|
|
103
|
+
from repaykit.methods import FlatRateAllocation
|
|
104
|
+
from repaykit.policies import RoundingPolicy
|
|
105
|
+
from repaykit.schedules import MonthlySchedule
|
|
106
|
+
from repaykit.terms import Term
|
|
107
|
+
|
|
108
|
+
loan = Loan(
|
|
109
|
+
principal=Decimal("10000.00"),
|
|
110
|
+
term=Term(months=24),
|
|
111
|
+
payment_schedule=MonthlySchedule(day=1),
|
|
112
|
+
accrual_policy=FlatAccrual(annual_rate=Decimal("0.08")),
|
|
113
|
+
repayment_method=FlatRateAllocation(),
|
|
114
|
+
rounding=RoundingPolicy(currency="MYR"),
|
|
115
|
+
start_date=date(2026, 6, 1),
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Flat Rate Example
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
loan = Loan.create(
|
|
123
|
+
amount=Decimal("10000.00"),
|
|
124
|
+
annual_rate=Decimal("0.08"),
|
|
125
|
+
term="24 months",
|
|
126
|
+
payment_frequency="monthly",
|
|
127
|
+
method="flat_rate",
|
|
128
|
+
start_date=date(2026, 6, 1),
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Flat-rate total profit is `principal * annual_rate * term_in_years`. Future scheduled profit is
|
|
133
|
+
treated as unearned profit rebate in the default settlement policy.
|
|
134
|
+
|
|
135
|
+
## Sum-of-Digits / Rule of 78 Example
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
loan = Loan.create(
|
|
139
|
+
amount=Decimal("10000.00"),
|
|
140
|
+
annual_rate=Decimal("0.08"),
|
|
141
|
+
term="12 months",
|
|
142
|
+
payment_frequency="monthly",
|
|
143
|
+
method="sum_of_digits",
|
|
144
|
+
start_date=date(2026, 1, 1),
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The denominator is generalized as `n * (n + 1) / 2`, so the method works with non-monthly period
|
|
149
|
+
counts such as 52 weekly periods.
|
|
150
|
+
|
|
151
|
+
## Reducing Balance Example
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
loan = Loan.create(
|
|
155
|
+
amount=Decimal("10000.00"),
|
|
156
|
+
annual_rate=Decimal("0.08"),
|
|
157
|
+
term="24 months",
|
|
158
|
+
payment_frequency="monthly",
|
|
159
|
+
method="reducing_balance",
|
|
160
|
+
start_date=date(2026, 6, 1),
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Reducing balance uses fixed-payment amortization. Profit is calculated on the outstanding balance
|
|
165
|
+
for each period.
|
|
166
|
+
|
|
167
|
+
## Constant Principal Example
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
loan = Loan.create(
|
|
171
|
+
amount=Decimal("10000.00"),
|
|
172
|
+
annual_rate=Decimal("0.08"),
|
|
173
|
+
term="24 months",
|
|
174
|
+
payment_frequency="monthly",
|
|
175
|
+
method="constant_principal",
|
|
176
|
+
start_date=date(2026, 6, 1),
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The principal component is constant except for the final rounding adjustment; installments decline
|
|
181
|
+
as profit declines.
|
|
182
|
+
|
|
183
|
+
## Weekly Payment Example
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
loan = Loan.create(
|
|
187
|
+
amount=Decimal("5000.00"),
|
|
188
|
+
annual_rate=Decimal("0.10"),
|
|
189
|
+
term="52 weeks",
|
|
190
|
+
payment_frequency="weekly",
|
|
191
|
+
method="constant_principal",
|
|
192
|
+
start_date=date(2026, 1, 1),
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Daily Payment Example
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
loan = Loan.create(
|
|
200
|
+
amount=Decimal("1000.00"),
|
|
201
|
+
annual_rate=Decimal("0.12"),
|
|
202
|
+
term="30 days",
|
|
203
|
+
payment_frequency="daily",
|
|
204
|
+
method="reducing_balance",
|
|
205
|
+
start_date=date(2026, 1, 1),
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Payment Ledger Example
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from repaykit import LoanAccount
|
|
213
|
+
|
|
214
|
+
account = LoanAccount(loan)
|
|
215
|
+
account.add_payment(
|
|
216
|
+
amount=Decimal("500.00"),
|
|
217
|
+
paid_at=date(2026, 7, 5),
|
|
218
|
+
reference="ANGKASA-JULY-2026",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
statement = account.statement(as_of=date(2026, 8, 1))
|
|
222
|
+
print(statement.total_due)
|
|
223
|
+
print(statement.total_paid)
|
|
224
|
+
print(statement.arrears)
|
|
225
|
+
print(statement.outstanding_balance)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Statements use aggregate oldest-due-first allocation for due installments and late-charge exposure.
|
|
229
|
+
They do not maintain a double-entry accounting ledger or split each payment into principal/profit
|
|
230
|
+
transactions.
|
|
231
|
+
|
|
232
|
+
## Late Charge Example
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from repaykit.policies import LateChargePolicy
|
|
236
|
+
|
|
237
|
+
loan.late_charge_policy = LateChargePolicy(
|
|
238
|
+
rate=Decimal("0.01"),
|
|
239
|
+
grace_days=5,
|
|
240
|
+
basis="flat",
|
|
241
|
+
minimum_charge=Decimal("0.00"),
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Supported bases are `flat` and `daily`. Negative overdue amounts are treated as zero.
|
|
246
|
+
|
|
247
|
+
## Settlement Example
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
settlement = account.full_settlement(as_of=date(2026, 12, 15))
|
|
251
|
+
|
|
252
|
+
print(settlement.outstanding_principal)
|
|
253
|
+
print(settlement.unearned_profit_rebate)
|
|
254
|
+
print(settlement.late_charges)
|
|
255
|
+
print(settlement.amount_payable)
|
|
256
|
+
print(settlement.explanation)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Settlement is policy-based. Flat-rate and sum-of-digits methods rebate future scheduled profit by
|
|
260
|
+
default. Reducing-balance and constant-principal methods have no unearned profit rebate by default.
|
|
261
|
+
Validate settlement behavior against your contract and regulatory requirements.
|
|
262
|
+
|
|
263
|
+
## Export Example
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from repaykit.exporters import schedule_to_csv, schedule_to_dicts
|
|
267
|
+
|
|
268
|
+
rows = loan.schedule()
|
|
269
|
+
data = schedule_to_dicts(rows)
|
|
270
|
+
schedule_to_csv(rows, "schedule.csv")
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Exporters serialize `Decimal` values as strings and dates as ISO strings. They never convert money
|
|
274
|
+
to floats.
|
|
275
|
+
|
|
276
|
+
## Rounding Warning
|
|
277
|
+
|
|
278
|
+
The default `RoundingPolicy` uses half-up money rounding with two decimal places. Different
|
|
279
|
+
institutions and jurisdictions may require different rounding points, decimal places, or settlement
|
|
280
|
+
rebate rules. Configure and test policies against the governing contract and regulation.
|
|
281
|
+
|
|
282
|
+
## Release Status
|
|
283
|
+
|
|
284
|
+
Version 1.0.0 declares the public API documented in `docs/api.md`. Breaking public API changes
|
|
285
|
+
after 1.0.0 require a major version bump.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
repaykit/__init__.py,sha256=QJaAEmjbK5g4QHON4wScHZFuDuVjh2iEo0vGinE53nw,396
|
|
2
|
+
repaykit/_serialization.py,sha256=8r_QQAUe8GzLL0s6Rd5kP2CId6UE8-TQFBvEyk_iUS4,986
|
|
3
|
+
repaykit/account.py,sha256=yeO7jpEySMwq963OuxUWMY_V2W-jVtv4xTno_AXWITg,3610
|
|
4
|
+
repaykit/enums.py,sha256=DGotvaPfhdD-F1Ew_C9f55jYYoGndjuNkmltysye9mk,486
|
|
5
|
+
repaykit/exceptions.py,sha256=yJdh4zbrqF570Jy-j1FrDsLh6RM3WZVnkTyPiWIeabg,546
|
|
6
|
+
repaykit/loan.py,sha256=jBnvmK4pIIwzY09TQ8QwsVeVchhdMv2iHr-feUDwkCU,5532
|
|
7
|
+
repaykit/money.py,sha256=x9ioaQpJ986Xvirlo-2WlOdrdNNuX7o2Qd-bzpzqfLw,1055
|
|
8
|
+
repaykit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
9
|
+
repaykit/results.py,sha256=dAAFQwxLTGyEnQ9tZHBnzwzoE6Mc9YIyk1hRD2griBQ,1396
|
|
10
|
+
repaykit/accruals/__init__.py,sha256=ljnWLm4xW-FZ6OV2l_IliYHvoeIQIRjps-uhbNRYQL8,304
|
|
11
|
+
repaykit/accruals/base.py,sha256=0dKEvwiuO_3Ly6CE8UwZaNXejt9oxzQMyWBpeq5Mmvg,829
|
|
12
|
+
repaykit/accruals/daily.py,sha256=ypiT2ozzE3363-f9ZGQACMGyVfNkqVJJ1nhtTsHRK80,517
|
|
13
|
+
repaykit/accruals/flat.py,sha256=Rxw9lGcBRSpFJFcFqOiH5ILzf5Nyn0W7cCWmjAn8Vx8,385
|
|
14
|
+
repaykit/accruals/periodic.py,sha256=79PMntB4gM3E1a9oLhry5iA3k3ubVPjygLghnaI_sHk,249
|
|
15
|
+
repaykit/backends/README.md,sha256=04oE8uy9xOphuhQWLlYRah9iwH0FvpeWDMvMQ3_vetc,576
|
|
16
|
+
repaykit/exporters/__init__.py,sha256=oQ-p5ey3KOomEGWOgMWo2Levi5Iztj8lttFfrFIfiHQ,298
|
|
17
|
+
repaykit/exporters/csv.py,sha256=8X2ZOA86nm3qiHAnQ0giZ8SxeoqRkxtZJm-XchOg6AY,829
|
|
18
|
+
repaykit/exporters/dicts.py,sha256=Xus6vhSNJ1y5Syd8tCcdfTWcTBgpyJanbIZ-hbjh-rE,414
|
|
19
|
+
repaykit/ledger/__init__.py,sha256=j4eTVRLN5BRHQV-Tlz_a11YlDxNVCKUgP-yN4uQXiwQ,234
|
|
20
|
+
repaykit/ledger/account_statement.py,sha256=Kut4diYKlC4I7bIk636n8zNSKLmvQyLylAXDGwKKIDg,90
|
|
21
|
+
repaykit/ledger/payment.py,sha256=EpIzCwIji2NyrIPmkUBtlV5Wmu74UzgDc26g0LsJMb8,763
|
|
22
|
+
repaykit/ledger/transaction.py,sha256=p7jh3HAnMdp9HjhkYPtl34vfUaGZSjWlquwzxukmbWY,395
|
|
23
|
+
repaykit/methods/__init__.py,sha256=8XDe6ulQkXFhMsE0XBgo_W4XWXd4f4DOdHvJrIOqOWQ,519
|
|
24
|
+
repaykit/methods/base.py,sha256=mFz2J8_tkO4T7KR_RBQuJuwZHbA-KRRtB7YZAtCGsGI,1232
|
|
25
|
+
repaykit/methods/constant_principal.py,sha256=LHGsWQJe3xiXTWa71uCfU7f2XDyvNibcDrS2nSXL19g,2694
|
|
26
|
+
repaykit/methods/flat_rate.py,sha256=g6ePHj6vXrDgf8MAI62X7fRwWhxqcsHd29bRf4MDLGU,3273
|
|
27
|
+
repaykit/methods/reducing_balance.py,sha256=FFl7P7_itUwMSPHajT2t-iuPTyEM-dcXDchdnTujlis,3031
|
|
28
|
+
repaykit/methods/sum_of_digits.py,sha256=lnwfjEBSFa34YgRCG89L927Cp45uJVNpFtRGJZrzq-Y,3776
|
|
29
|
+
repaykit/policies/__init__.py,sha256=9xNvFq2zaaVazYK93c0SFTRwocnYUrmJzQsdNwZ3POI,472
|
|
30
|
+
repaykit/policies/allocation.py,sha256=vEjbNFmUKmGLjvRaGlpJaiLmoDssLcAVTxuuN6UNoqk,470
|
|
31
|
+
repaykit/policies/late_charge.py,sha256=BjRY6qwI3_uS0KU_b5r6MXZo8macNJGsqc7ensrfSDM,2335
|
|
32
|
+
repaykit/policies/rounding.py,sha256=o3RvO3R-zScZYa646reTzDKkCIg2MxyTdE0d3tbyH88,1162
|
|
33
|
+
repaykit/policies/settlement.py,sha256=P59kTA8na3yN38SYlaZpszG3WgsvzIJAUvAgGH_2Qkk,2761
|
|
34
|
+
repaykit/schedules/__init__.py,sha256=-5MjkzSxjl3YzlBDPdlXAla4iy4y_JFH46b8rNHX2r0,666
|
|
35
|
+
repaykit/schedules/base.py,sha256=WGhEOBFugfoCsAATTeqxTKFZQed9QEKI_239CeRKA9M,1649
|
|
36
|
+
repaykit/schedules/biweekly.py,sha256=rveKnZWTQ2JzUtt0XJbDvbKRsnxTMh08ZrPmnnbLcTc,513
|
|
37
|
+
repaykit/schedules/custom.py,sha256=0UHfBCrRF_Mz2RtUA0hvmEhMn4Xwg99JuH-fOXQtruA,1618
|
|
38
|
+
repaykit/schedules/daily.py,sha256=M8W8_TmU0ObxLk1OeRdpIyBFaU5aK4iUHb9vN-Nj9wE,803
|
|
39
|
+
repaykit/schedules/monthly.py,sha256=mGSsa20idY6PL5L2yG_9AAjzREJ9-QLU2zHsrKoAiHo,923
|
|
40
|
+
repaykit/schedules/quarterly.py,sha256=5KM1OHwoZnExyiQqEJ0UeY52xFh-q24bImv3TuBPBo8,589
|
|
41
|
+
repaykit/schedules/weekly.py,sha256=ZOJNbQPlbzNP4JqSQq40iz4wwaLhf2iWl_NbbiEudAE,745
|
|
42
|
+
repaykit/schedules/yearly.py,sha256=TwhqKFQmAqTXZsSEf_fh1FDZsgIfsrdAvUtaDBgjfKk,581
|
|
43
|
+
repaykit/terms/__init__.py,sha256=OUHH5w461gkj2R7WC41QYuFLf8JcaACFAsXXmALsHTQ,77
|
|
44
|
+
repaykit/terms/term.py,sha256=DxV_sq-BrPoqCurM4CyPqhQcgj33vw8V8a80vq83XRg,3243
|
|
45
|
+
repaykit-1.0.0.dist-info/METADATA,sha256=poamC3RSXpbuqHi5tFYFGlXGgwMzaSznjU-nQRHzrxU,7821
|
|
46
|
+
repaykit-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
47
|
+
repaykit-1.0.0.dist-info/licenses/LICENSE,sha256=AeIppiqUtmEGOHqm3coSzkOOcT6XJipw4-BEcMfpdwk,1072
|
|
48
|
+
repaykit-1.0.0.dist-info/RECORD,,
|