Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added python/billing/__init__.py
Empty file.
25 changes: 25 additions & 0 deletions python/billing/discount.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
tsah-baz marked this conversation as resolved.
return total
65 changes: 65 additions & 0 deletions python/billing/invoice.py
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +23 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half-cent amounts map to wrong cents

_as_cents uses float arithmetic with ties-to-even rounding, so 1.005 silently rounds to 1.00 and equal_amounts(1.005, 1.00) returns True instead of applying an explicit currency rounding rule — should we convert to Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) or similar?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`python/billing/invoice.py` around lines 23-29, fix `_as_cents` so currency conversion
does not depend on binary float representation or Python’s ties-to-even rounding.
Convert the amount through `Decimal(str(amount))`, quantize to `Decimal("0.01")` with an
explicitly chosen policy such as `ROUND_HALF_UP`, and then return whole cents; add the
required decimal imports and keep `equal_amounts` operating on integer cents.



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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty invoices crash average calculation

average_item_price([]) divides by zero because subtotal([]) is valid and invoice_total accepts empty collections without validation — should we define explicit empty-invoice behavior (return 0.0/None or raise a descriptive exception)?

Severity

Want Baz to fix this for you? Activate Fixer

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
python/billing/invoice.py around lines 42-48, update `average_item_price` so it
explicitly handles an empty `items` collection instead of dividing by zero. Raise a
descriptive `ValueError` for an empty invoice, document this behavior in the function
docstring, and add or update tests covering both empty and non-empty inputs.



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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsupported regions raise raw KeyError

tax_for passes unchecked region values to tax.rate_for's RATES[region] lookup, so invalid regions raise KeyError — should we validate the region against tax.regions() first?

Severity

Want Baz to fix this for you? Activate Fixer

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`python/billing/invoice.py` around lines 56-60, update `tax_for` so externally supplied
regions are validated before calling `tax.rate_for`; currently unsupported truthy
strings cause an uncaught `KeyError`. Check membership using `tax.regions()` (or the
module’s supported-region contract), preserve `None` as the default region, and raise
the project-standard clear validation error for invalid regions.



def invoice_total(items, status, discount_percent, region=None):
net = apply_discount(subtotal(items), discount_percent)
return net + tax_for(net, status, region)
Comment on lines +63 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invoice totals return fractional cents

invoice_total returns the raw net + tax_for(...) result, so it produces 23.3883 instead of the chargeable whole-cent amount 23.39 — should we wrap it in _from_cents(_as_cents(net + tax_for(...)))?

Severity

Want Baz to fix this for you? Activate Fixer

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`python/billing/invoice.py` around lines 63-65, update `invoice_total` so its final
discounted subtotal plus tax is normalized to a whole-cent amount before returning. Use
the existing `_as_cents` and `_from_cents` helpers on the combined total, preserving the
current discount and tax calculation order.

29 changes: 29 additions & 0 deletions python/billing/ledger.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions python/billing/report.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +9 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Report output permits row injection

Entry.invoice_id and titles flow unconstrained into render() and header(), so \t and \n characters can forge extra rows and corrupt the TSV output — should we reject or escape control characters at this output boundary?

Severity

Want Baz to fix this for you? Activate Fixer

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`python/billing/report.py` around lines 9-12, harden `render()` so unconstrained
`invoice_id` values cannot inject tabs, newlines, or other control characters into the
one-line TSV-like output. Add a shared escaping or validation helper and apply the same
policy to `header()` around lines 15-17, then add tests confirming control characters
cannot forge columns, rows, or terminal/log output.



def header(title: str) -> str:
"""Return a padded report header."""
return title.center(48, "-")
19 changes: 19 additions & 0 deletions python/billing/tax.py
Original file line number Diff line number Diff line change
@@ -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)