Functional Weave
Code in Python

lending.early-settlement@1.0.1

impl/python.py

4,456 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Optional, Sequence

from .dates_add_days import add_days  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .lending_early_settlement_types import DatedAmount, EarlySettlement, SettlementDeferral
from .math_fractional_power import FIXED_SCALE, pow_fixed, root_fixed  ← from math.fractional-power ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune


def _settlement_date(
    advances: Sequence[DatedAmount],
    repayments: Sequence[DatedAmount],
    notice_received_iso: str,
    requested_settlement_iso: Optional[str],
    deferral: SettlementDeferral,
) -> str:
    """The settlement date: 28 days after the creditor receives the notice
    (regulation 5(1)(a)), or a later date the notice names, then deferred by a
    month or 30 days where the creditor elects and regulation 6 allows it
    (credit repaid over more than a year from the first drawdown)."""
    date = add_days(notice_received_iso, 28)
    if requested_settlement_iso is not None:
        days_between(requested_settlement_iso, requested_settlement_iso)
        if requested_settlement_iso > date:
            date = requested_settlement_iso
    if deferral == "none":
        return date
    if deferral not in ("one-month", "thirty-days"):
        raise ValueError('unknown deferral "%s": expected none, one-month or thirty-days' % (deferral,))
    first = min(a.date for a in advances)
    last = max(r.date for r in repayments)
    if last <= add_months(first, 12):
        raise ValueError("deferral is only allowed when the credit is repaid over more than a year (regulation 6)")
    return add_months(date, 1) if deferral == "one-month" else add_days(date, 30)


def early_settlement(
    advances: Sequence[DatedAmount],
    repayments: Sequence[DatedAmount],
    apr_basis_points: int,
    notice_received_iso: str,
    requested_settlement_iso: Optional[str],
    deferral: SettlementDeferral,
) -> EarlySettlement:
    """The amount a debtor pays to settle a regulated consumer credit agreement
    early, and the rebate, per the Consumer Credit (Early Settlement)
    Regulations 2004, regulation 4(1).

    The rebate is the scheduled repayments falling due after the settlement
    date less  sum A_i (1 + r)^a_i - sum B_j (1 + r)^b_j  over the advances and
    the repayments made before the settlement date, each carried forward to
    the settlement date at the APR. That difference is the settlement figure.
    Times are days / 365 of a year.
    """
    if len(advances) == 0:
        raise ValueError("advances must not be empty")
    if len(repayments) == 0:
        raise ValueError("repayments must not be empty")
    b = apr_basis_points
    if isinstance(b, bool) or not isinstance(b, int) or b < 0 or b > 1000000:
        raise ValueError("aprBasisPoints must be between 0 and 1000000, received %r" % (b,))
    currency = advances[0].amount.currency
    for flow in list(advances) + list(repayments):
        days_between(flow.date, flow.date)
        if flow.amount.currency != currency:
            raise ValueError("currency mismatch: %s and %s" % (currency, flow.amount.currency))
        if flow.amount.minor <= 0:
            raise ValueError("every amount must be greater than zero, received %r" % (flow.amount.minor,))
    date = _settlement_date(advances, repayments, notice_received_iso, requested_settlement_iso, deferral)
    # (1 + APR)^(1/365), once; each flow is then a whole power of it.
    daily = root_fixed(FIXED_SCALE * (10000 + b) // 10000, 365)

    def carried(flow: DatedAmount) -> int:
        return flow.amount.minor * pow_fixed(daily, days_between(flow.date, date))

    owed = 0
    drawn = False
    for advance in advances:
        if advance.date < date:
            owed += carried(advance)
            drawn = True
    if not drawn:
        raise ValueError("no credit was advanced before the settlement date %s" % (date,))
    remaining = 0
    for repayment in repayments:
        if repayment.date < date:
            owed -= carried(repayment)
        else:
            remaining += repayment.amount.minor
    # Rounded down: the rebate must be at least the one the formula gives.
    formula = owed // FIXED_SCALE if owed > 0 else 0
    settlement = min(formula, remaining)
    return EarlySettlement(
        settlement_date=date,
        settlement_amount=money(settlement, currency),
        remaining_repayments=money(remaining, currency),
        rebate=money(remaining - settlement, currency),
    )