dodil/erp-brick-pos

DODIL ERP brick: point of sale

src/brick_pos/domain/voucher.py

"""Gift vouchers (POS-13): money we owe until it is spent or expires.

A voucher is SOLD as a receipt line of kind voucher_sale (outside the scope of VAT for a multi-purpose voucher: the VAT comes when it is
spent, on the goods) and becomes live when that receipt is final: the issue is claim 1. Every later movement of its balance is its own
claim row on (voucher_code, claim_no), a plain INSERT whose operation is part of what is claimed - a redemption, or the balance given
back when the receipt that spent it is voided. The balance is DERIVED from the claims, never stored and never incremented, so two tills
spending the same voucher at once cannot both succeed: the loser's insert is a duplicate key, it re-reads, and finds less left."""
import calendar
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_pos.domain.errors import RuleBroken
from brick_pos.domain.values import VoucherOperation

ZERO = Decimal("0.00")


def add_months(day: date, months: int) -> date:
    """The same day `months` later (a day the month does not have is the month's last)."""
    index = day.year * 12 + day.month - 1 + months
    year, month = divmod(index, 12)
    return date(year, month + 1, min(day.day, calendar.monthrange(year, month + 1)[1]))


@dataclass
class VoucherClaim:
    voucher_code: str
    claim_no: int
    operation: str
    receipt_id: str
    pay_no: Optional[int]
    amount: Decimal                  # signed: the issue and a give-back add to the balance, a redemption takes from it
    balance_after: Decimal

    @property
    def key(self) -> str:
        return f"voucher:{self.voucher_code}:{self.claim_no}"


@dataclass
class Voucher:
    voucher_code: str
    company_code: str = ""
    currency: str = ""
    face_value: Decimal = ZERO
    profile: str = "multi_purpose"
    issued_receipt_id: str = ""
    expires_on: Optional[date] = None
    status: str = "active"
    events: list = field(default_factory=list, repr=False, compare=False)

    @classmethod
    def issue(cls, *, voucher_code: str, company_code: str, currency: str, face_value: Decimal, receipt_id: str, on: date,
              validity_months: int) -> tuple:
        """The voucher and its first claim (operation issue, claim 1), when the receipt that sold it is final."""
        v = cls(voucher_code=voucher_code, company_code=company_code, currency=currency, face_value=face_value, issued_receipt_id=receipt_id,
                expires_on=add_months(on, validity_months))
        return v, VoucherClaim(voucher_code, 1, VoucherOperation.ISSUE.value, receipt_id, None, face_value, face_value)

    def state(self, balance: Decimal, on: date) -> str:
        """active | exhausted | expired, as of a business date: derived, never stored."""
        if self.expires_on is not None and on > self.expires_on:
            return "expired"
        return "exhausted" if balance <= 0 else "active"

    def redeem(self, *, balance: Decimal, last_claim: int, amount: Decimal, currency: str, company_code: str, on: date, receipt_id: str,
               pay_no: int) -> VoucherClaim:
        """Spend `amount` of the voucher on a receipt: the voucher is live, in this currency and company, and holds at least the
        amount. Raises with the balance named."""
        if self.state(balance, on) == "expired":
            raise RuleBroken(f"voucher {self.voucher_code} expired on {self.expires_on.isoformat()}", gate="voucher_expired",
                             fix="pay another way; an accountant recognises the breakage")
        if self.currency != currency:
            raise RuleBroken(f"voucher {self.voucher_code} is in {self.currency}, the receipt in {currency}", gate="voucher_currency",
                             fix="pay another way")
        if self.company_code != company_code:
            raise RuleBroken(f"voucher {self.voucher_code} was sold by company {self.company_code}, this shop belongs to {company_code}",
                             gate="voucher_company", fix="pay another way")
        if amount > balance:
            raise RuleBroken(f"voucher {self.voucher_code} holds {balance}, not {amount}", gate="voucher_balance",
                             fix=f"redeem at most {balance} and take the rest another way")
        return VoucherClaim(self.voucher_code, last_claim + 1, VoucherOperation.REDEEM.value, receipt_id, pay_no, -amount, balance - amount)

    def give_back(self, *, balance: Decimal, last_claim: int, amount: Decimal, receipt_id: str, pay_no: int) -> VoucherClaim:
        """The balance a voided receipt had spent, returned to the voucher (operation refund_to_voucher)."""
        return VoucherClaim(self.voucher_code, last_claim + 1, VoucherOperation.REFUND_TO_VOUCHER.value, receipt_id, pay_no, amount,
                            balance + amount)