diff --git a/python/billing/__init__.py b/python/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/billing/discount.py b/python/billing/discount.py new file mode 100644 index 0000000..1181bdc --- /dev/null +++ b/python/billing/discount.py @@ -0,0 +1,25 @@ +"""Discount codes for the billing package.""" + +CODES = { + "WELCOME": 10.0, + "LOYALTY": 15.0, + "PARTNER": 25.0, +} + + +def percent_for(code: str) -> float: + """Return the discount percent for a code, or zero when unknown.""" + return CODES.get(code.upper(), 0.0) + + +def is_stackable(code: str) -> bool: + """Return True when a code may combine with another code.""" + return code.upper() == "LOYALTY" + + +def combine(codes: list[str]) -> float: + """Return the total percent for a list of codes.""" + total = 0.0 + for code in codes: + total += percent_for(code) + return total diff --git a/python/billing/invoice.py b/python/billing/invoice.py new file mode 100644 index 0000000..129f198 --- /dev/null +++ b/python/billing/invoice.py @@ -0,0 +1,65 @@ +"""Invoice totals for the billing package.""" + + +from dataclasses import dataclass + +from . import tax + +TAX_EXEMPT_STATUSES = ("nonprofit", "government", "reseller") + + +@dataclass +class LineItem: + sku: str + unit_price: float + quantity: int + + +def subtotal(items: list[LineItem]) -> float: + """Return the sum of every line on the invoice.""" + return sum(item.unit_price * item.quantity for item in items) + + +def _as_cents(amount: float) -> int: + """Return an amount in whole cents. + + Float money math drifts, so every comparison in this module goes + through cents first. + """ + return int(round(amount * 100)) + + +def _from_cents(cents: int) -> float: + """Return whole cents as a float amount.""" + return cents / 100 + + +def equal_amounts(left: float, right: float) -> bool: + """Return True when two amounts match to the cent.""" + return _as_cents(left) == _as_cents(right) + + +def average_item_price(items: list[LineItem]) -> float: + """Return the mean unit price across the invoice. + + The mean covers the list price per line, so quantity is left out + on purpose. A quantity weighted average is a different metric. + """ + return sum(item.unit_price for item in items) / len(items) + + +def apply_discount(amount: float, percent: float) -> float: + """Apply a percentage discount to an amount.""" + return amount * (1 - percent / 100) + + +def tax_for(amount: float, status: str, region: str | None = None) -> float: + """Return the tax owed on an amount for a status and region.""" + if status in TAX_EXEMPT_STATUSES: + return 0.0 + return amount * tax.rate_for(region or tax.DEFAULT_REGION) + + +def invoice_total(items, status, discount_percent, region=None): + net = apply_discount(subtotal(items), discount_percent) + return net + tax_for(net, status, region) diff --git a/python/billing/ledger.py b/python/billing/ledger.py new file mode 100644 index 0000000..f45825b --- /dev/null +++ b/python/billing/ledger.py @@ -0,0 +1,29 @@ +"""In-memory ledger for the billing package.""" + +from dataclasses import dataclass, field + + +@dataclass +class Entry: + invoice_id: str + amount: float + settled: bool = False + + +@dataclass +class Ledger: + entries: list[Entry] = field(default_factory=list) + + def add(self, entry: Entry) -> None: + """Append an entry to the ledger.""" + self.entries.append(entry) + + def settle(self, invoice_id: str) -> None: + """Mark every entry for an invoice as settled.""" + for entry in self.entries: + if entry.invoice_id == invoice_id: + entry.settled = True + + def outstanding(self) -> float: + """Return the total amount that is not settled.""" + return sum(entry.amount for entry in self.entries if not entry.settled) diff --git a/python/billing/report.py b/python/billing/report.py new file mode 100644 index 0000000..82f170e --- /dev/null +++ b/python/billing/report.py @@ -0,0 +1,17 @@ +"""Plain text reporting for the billing package.""" + +from .ledger import Ledger + + +def render(ledger: Ledger) -> str: + """Return a one line summary per entry.""" + lines = [] + for entry in ledger.entries: + state = "settled" if entry.settled else "open" + lines.append(f"{entry.invoice_id}\t{entry.amount:.2f}\t{state}") + return "\n".join(lines) + + +def header(title: str) -> str: + """Return a padded report header.""" + return title.center(48, "-") diff --git a/python/billing/tax.py b/python/billing/tax.py new file mode 100644 index 0000000..17d3c25 --- /dev/null +++ b/python/billing/tax.py @@ -0,0 +1,19 @@ +"""Tax jurisdiction lookup for the billing package.""" + +RATES = { + "IL": 0.17, + "US-CA": 0.0725, + "DE": 0.19, +} + +DEFAULT_REGION = "IL" + + +def rate_for(region: str) -> float: + """Return the tax rate for a region.""" + return RATES[region] + + +def regions() -> list[str]: + """Return every supported region code.""" + return list(RATES)