"""The Quote aggregate — the rules, and nothing else.
No database, no HTTP, no identity: a rule here is true whoever calls it and however it is stored.
That is what lets these run in milliseconds, and what lets a person who is not a programmer read
the file and check it against how their business actually works.
"""
from dataclasses import dataclass, field
from decimal import Decimal
DRAFT, SENT, ACCEPTED = "draft", "sent", "accepted"
class RuleBroken(Exception):
"""A business rule said no. The message is written for the person who tried."""
@dataclass
class Line:
line_no: int
description: str
quantity: int
unit_price: Decimal
@property
def amount(self) -> Decimal:
return self.unit_price * self.quantity
@dataclass
class Quote:
quote_id: str
customer: str
owner: str
currency: str = "EUR"
status: str = DRAFT
lines: list = field(default_factory=list)
@property
def total(self) -> Decimal:
return sum((ln.amount for ln in self.lines), Decimal("0.00"))
def add_line(self, description: str, quantity: int, unit_price: Decimal) -> Line:
self._editable()
if quantity <= 0:
raise RuleBroken("a line needs a quantity of at least 1")
if unit_price < 0:
raise RuleBroken("a price cannot be negative")
line = Line(max((ln.line_no for ln in self.lines), default=0) + 1,
description, quantity, Decimal(unit_price).quantize(Decimal("0.01")))
self.lines.append(line)
return line
def remove_line(self, line_no: int) -> None:
self._editable()
if not any(ln.line_no == line_no for ln in self.lines):
raise RuleBroken(f"there is no line {line_no}")
self.lines = [ln for ln in self.lines if ln.line_no != line_no]
def send(self) -> None:
self._editable()
if not self.lines:
raise RuleBroken("an empty quote cannot be sent")
self.status = SENT
def accept(self) -> None:
if self.status == ACCEPTED:
return # accepting twice changes nothing
if self.status != SENT:
raise RuleBroken("only a sent quote can be accepted")
self.status = ACCEPTED
def _editable(self) -> None:
if self.status != DRAFT:
raise RuleBroken(f"a {self.status} quote cannot be changed — copy it into a new one")