dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/domain/values.py
"""Value objects: the states, the kinds of allocation source, and the settings the customer edits
(module.yaml config_rows). Stored as plain strings; naming them here makes a typo a NameError."""
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from enum import Enum
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
class ReceiptStatus(str, Enum):
RECEIVED = "received"
REVERSED = "reversed"
class RefundStatus(str, Enum):
REQUESTED = "requested"
APPROVED = "approved"
REJECTED = "rejected"
PAID = "paid"
class SourceKind(str, Enum):
"""allocation.source_kind: what reduced the balance."""
RECEIPT = "receipt"
CREDIT_NOTE = "credit_note"
WRITEOFF = "writeoff"
REFUND = "refund"
CIS_DEDUCTION = "cis_deduction" # v4: the deduction a contractor customer made, accepted by tax (PAY-45)
SETTLEMENT_DISCOUNT = "settlement_discount" # v4: under settlement_discount_vat invoice_wording (PAY-16)
class DecidedHow(str, Enum):
"""allocation.decided_how: who decided what an AI suggestion proposed."""
MANUAL = "manual"
ACCEPTED = "accepted"
CHANGED = "changed"
AUTO = "auto" # auto-with-review: the system allocated an EXACT match; a person reviews it
REMITTANCE = "remittance" # v4: accepted from the customer's own remittance advice (PAY-17)
DIRECT_DEBIT = "direct_debit" # v4: a direct-debit collection allocated to the invoices it collected (PAY-19)
class SuggestionStatus(str, Enum):
OPEN = "open"
ACCEPTED = "accepted"
CHANGED = "changed"
REJECTED = "rejected"
SUPERSEDED = "superseded"
class OverrideKind(str, Enum):
HOLD = "hold"
RELEASE = "release"
# ── settings (module.yaml config_rows -> pay_settings) ───────────────────────────────────────────────────
MODES = {"ai_suggest_match": ("off", "suggest", "auto-with-review"),
"ai_draft_reminder": ("off", "suggest", "auto"),
"ai_suggest_statement_line": ("off", "suggest"),
"settlement_discount_vat": ("credit_note", "invoice_wording"),
"late_payment_risk": ("off", "suggest"),
"reminder_mode": ("off", "suggest", "auto"),
"payout_file_format": ("pain.001", "bacs18")}
# key -> (kind, default, controls, changes). `controls`: pay:settings:controls alone may change it, never the
# cashier it controls (PAY-13). ai_suggest_match is a control only when it is set to auto-with-review.
SETTING_DEFS = {
"aging_buckets": ("str", "0-30,31-60,61-90,90+", False, "the aging buckets of the overdue list, in days"),
"age_from": ("str", "due_date", False, "what age runs from: due_date or invoice_date"),
"match_tolerance": ("decimal", "0.00", True, "the difference a person may close as a write-off proposal (PAY-04); 0.00 = off"),
"credit_hold_days_overdue": ("int", 60, True, "a customer is on hold once an invoice is this many days overdue (PAY-12)"),
"credit_hold_over_limit": ("bool", True, True, "whether exceeding the credit limit also puts a customer on hold"),
"credit_hold_mode": ("str", "warn", True, "warn | block: what sales-order does on confirm for a customer on hold"),
"business_timezone": ("str", "Europe/London", True, "the IANA time zone in which 'today' is counted"),
"statement_import_from": ("str", "", True, "a date (YYYY-MM-DD): statement lines booked before it are skipped as already migrated"),
"reminder_mode": ("str", "suggest", False, "off | suggest | auto: reminders overall"),
"ai_suggest_match": ("str", "suggest", False, "ai_suggest_match: off | suggest | auto-with-review (exact matches only; auto-with-review needs pay:settings:controls)"),
"ai_draft_reminder": ("str", "suggest", False, "ai_draft_reminder: off | suggest | auto"),
"late_payment_risk": ("str", "suggest", False, "late_payment_risk: off | suggest"),
"ai_suggest_statement_line": ("str", "suggest", False, "ai_suggest_statement_line: off | suggest (the AI never posts)"),
"dd_notice_days": ("int", 10, True, "working days of advance notice before a direct debit (3 where the DDI terms agree it)"),
"settlement_discount_vat": ("str", "credit_note", True, "credit_note: invoicing credits the discount taken at the invoice's rates (AR-38) | invoice_wording: the discount is an allocation payments records for tax"),
"vat_control_account_id": ("str", "", True, "the ledger account an HMRC payment is posted against (the VAT control account)"),
"transfer_window_days": ("int", 5, False, "how many days apart the two sides of a transfer between our accounts may be booked (PAY-25)"),
"cash_difference_threshold": ("decimal", "5.00", True, "a cash-count difference above this needs an approver who did not count (PAY-26)"),
"forecast_weeks": ("int", 13, False, "how many weeks the cash forecast looks ahead (PAY-29)"),
"card_fee_max_pct": ("decimal", "3.00", True, "v6: the card fees, as a percent of the gross, a provider's credit may imply and still be matched without an approver (PAY-50)"),
"payout_file_format": ("str", "pain.001", True, "v6: pain.001 | bacs18, the payout run's file (PAY-51)"),
"card_providers": ("str", "", False, "v6: the card providers (comma-separated codes) a card settlement may name (PAY-50, gate provider_known)"),
"suspense_bp_id": ("int", 0, False, "the customer that holds money no one has named yet (0 = none); set by the migration or the cashier"),
}
CONTROL_KEYS = tuple(k for k, d in SETTING_DEFS.items() if d[2])
BUCKET_PATTERN = re.compile(r"^\d+-\d+(,\d+-\d+)*,\d+\+$")
@dataclass(frozen=True)
class Settings:
"""How this business takes and chases money. A stored null is the default."""
aging_buckets: str = "0-30,31-60,61-90,90+"
age_from: str = "due_date"
match_tolerance: Decimal = Decimal("0.00")
credit_hold_days_overdue: int = 60
credit_hold_over_limit: bool = True
credit_hold_mode: str = "warn"
business_timezone: str = "Europe/London"
statement_import_from: str = ""
reminder_mode: str = "suggest"
ai_suggest_match: str = "suggest"
ai_draft_reminder: str = "suggest"
late_payment_risk: str = "suggest"
ai_suggest_statement_line: str = "suggest"
dd_notice_days: int = 10
settlement_discount_vat: str = "credit_note"
vat_control_account_id: str = ""
suspense_bp_id: int = 0
transfer_window_days: int = 5
cash_difference_threshold: Decimal = Decimal("5.00")
forecast_weeks: int = 13
card_fee_max_pct: Decimal = Decimal("3.00")
payout_file_format: str = "pain.001"
card_providers: str = ""
@classmethod
def from_values(cls, values: dict) -> "Settings":
out = {}
for key, (kind, default, _c, _w) in SETTING_DEFS.items():
v = values.get(key)
if v is None:
v = default
out[key] = Decimal(str(v)) if kind == "decimal" else v
return cls(**out)
def mode(self, step: str) -> str:
return getattr(self, step)
def import_from(self) -> Optional[date]:
return date.fromisoformat(self.statement_import_from) if self.statement_import_from else None
def check_setting(key: str, value: Any) -> Optional[str]:
"""Why `value` is not valid for this setting; None if fine (None puts the default back)."""
if value is None:
return None
kind = SETTING_DEFS[key][0]
if kind == "bool" and not isinstance(value, bool):
return "true or false"
if kind == "int" and (isinstance(value, bool) or not isinstance(value, int)):
return "a whole number"
if kind in ("str", "decimal") and not isinstance(value, (str, int, float)) or isinstance(value, bool) and kind != "bool":
return "text" if kind == "str" else "an amount such as \"2.00\""
if key in MODES and value not in MODES[key]:
return " | ".join(MODES[key])
if key == "age_from" and value not in ("due_date", "invoice_date"):
return "due_date or invoice_date"
if key == "credit_hold_mode" and value not in ("warn", "block"):
return "warn or block"
if key == "aging_buckets" and not BUCKET_PATTERN.match(str(value)):
return "buckets in days such as 0-30,31-60,61-90,90+"
if key == "business_timezone":
try:
ZoneInfo(str(value))
except (ZoneInfoNotFoundError, ValueError, OSError):
return "an IANA time zone such as Europe/London"
if key == "transfer_window_days" and not (0 <= int(value) <= 30):
return "between 0 and 30 days"
if key == "forecast_weeks" and not (1 <= int(value) <= 52):
return "between 1 and 52 weeks"
if key in ("match_tolerance", "cash_difference_threshold"):
try:
d = Decimal(str(value))
except InvalidOperation:
return "an amount such as \"2.00\""
if not d.is_finite() or d < 0 or d.as_tuple().exponent < -2:
return "zero or more, with at most two decimal places"
if key == "dd_notice_days" and not (1 <= int(value) <= 30):
return "between 1 and 30 working days"
if key == "credit_hold_days_overdue" and not (1 <= int(value) <= 3650):
return "between 1 and 3650 days"
if key == "suspense_bp_id" and int(value) < 0:
return "a partner id, or 0 for none"
if key == "statement_import_from" and value != "":
try:
date.fromisoformat(str(value))
except ValueError:
return "a date like 2026-11-01, or empty"
return None
def needs_controls(key: str, value: Any) -> bool:
"""Only pay:settings:controls changes these (PAY-13); the cashier's pay:settings:write is refused on them."""
return key in CONTROL_KEYS or (key == "ai_suggest_match" and value == "auto-with-review")