dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/domain/receipt.py

"""Receipt: money received from a customer. A fact dated by the bank - today or earlier, never later. A wrong
amount is corrected by reversing the receipt and recording the right one, never by editing it."""
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Optional

from brick_payments.domain import events
from brick_payments.domain.errors import RuleBroken
from brick_payments.domain.money import ZERO, money
from brick_payments.domain.readiness import Gate, fmt, met, refuse, unmet
from brick_payments.domain.values import ReceiptStatus


def received_not_future(received_on: date, today: date, tz: str) -> Gate:
    if received_on > today:
        return unmet("received_not_future",
                     f"Received on {received_on}, which is after today ({today}, {tz})",
                     "Record it on the day the bank shows it")
    return met("received_not_future")


def return_dated_ok(returned_on: date, received_on: date, today: date) -> Gate:
    if returned_on > today or returned_on < received_on:
        return unmet("return_dated_ok",
                     f"The return date {returned_on} is after today or before the receipt ({received_on})",
                     "Use the date on the bank's return advice")
    return met("return_dated_ok")


@dataclass
class Receipt:
    payment_id: str
    bp_id: Optional[int]
    currency: str
    amount: Decimal
    received_on: date
    method: str = ""
    bank_account_id: str = ""
    bank_reference: str = ""
    remittance_text: str = ""
    status: str = ReceiptStatus.RECEIVED.value
    batch_id: str = ""
    fx_rate: Optional[Decimal] = None
    returned_on: Optional[date] = None
    bank_return_ref: str = ""
    reversed_on: Optional[date] = None        # the business day WE recorded the return (returned_on is the bank's)
    origin: str = ""
    recorded_by: str = ""
    recorded_at: Optional[datetime] = None
    recorded_on: Optional[date] = None        # the business day WE recorded it (as-at views judge a receipt by this, like reversed_on)
    return_reason_code: str = ""              # v4: the ARUDD reason of a returned direct debit
    statement_line_id: str = ""               # v4: the statement line this receipt came from
    events: list = field(default_factory=list)

    @classmethod
    def record(cls, *, payment_id: str, bp_id: Optional[int], currency: str, amount, received_on: date, today: date,
               tz: str = "Europe/London", by: str = "", at: Optional[datetime] = None, origin: str = "",
               recorded_on: Optional[date] = None, **more) -> "Receipt":
        amount = money(amount)
        if amount <= ZERO:
            raise RuleBroken("A receipt is money that arrived: its amount is more than zero")
        refuse(received_not_future(received_on, today, tz))
        r = cls(payment_id=payment_id, bp_id=bp_id, currency=currency.upper(), amount=amount, received_on=received_on,
                origin=origin, recorded_by=by, recorded_at=at, recorded_on=recorded_on or today, **more)
        r.events.append(events.ReceiptRecorded(payment_id, amount, r.currency))
        return r

    @property
    def live(self) -> bool:
        return self.status == ReceiptStatus.RECEIVED.value

    def live_at(self, as_at: date) -> bool:
        """Whether, on that day, we had this money on the customer. Judged by the day WE recorded the receipt and the day WE
        recorded its return, not the bank's dates, so a statement sent for a past date re-computes to what it said then
        (a back-dated receipt recorded afterwards does not appear on it)."""
        known_from = self.recorded_on or self.received_on
        return self.received_on <= as_at and known_from <= as_at and (self.reversed_on is None or self.reversed_on > as_at)

    def reverse(self, *, returned_on: date, bank_return_ref: str, today: date, reason_code: str = "") -> None:
        """A returned payment (a bounced direct debit, a recalled transfer): reversed on the bank's return date."""
        if not self.live:
            raise RuleBroken(f"Receipt {self.payment_id} is already reversed")
        if not bank_return_ref.strip():
            raise RuleBroken("A returned payment needs the bank's return reference. Take it from the bank's return advice")
        refuse(return_dated_ok(returned_on, self.received_on, today))
        self.status, self.returned_on, self.bank_return_ref, self.reversed_on = (
            ReceiptStatus.REVERSED.value, returned_on, bank_return_ref.strip(), today)
        self.return_reason_code = reason_code
        self.events.append(events.ReceiptReversed(self.payment_id, returned_on.isoformat()))

    def as_dict(self) -> dict:
        return {"payment_id": self.payment_id, "bp_id": self.bp_id, "currency": self.currency,
                "amount": fmt(self.amount).replace(",", ""), "received_on": self.received_on.isoformat(),
                "method": self.method, "bank_account_id": self.bank_account_id, "bank_reference": self.bank_reference,
                "remittance_text": self.remittance_text, "status": self.status, "batch_id": self.batch_id,
                "fx_rate": str(self.fx_rate) if self.fx_rate is not None else None,
                "returned_on": self.returned_on.isoformat() if self.returned_on else None,
                "bank_return_ref": self.bank_return_ref, "return_reason_code": self.return_reason_code,
                "statement_line_id": self.statement_line_id, "origin": self.origin, "recorded_by": self.recorded_by}