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
@@ -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
+ ]