-
Notifications
You must be signed in to change notification settings - Fork 1
feat(billing): add invoice totals package #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0dea873
ed2ebb8
810c345
bb7586b
271232e
d2351ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| return total | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Half-cent amounts map to wrong cents
Want Baz to fix this for you? Activate Fixer Prompt for AI Agents |
||
|
|
||
|
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty invoices crash average calculation
Want Baz to fix this for you? Activate Fixer Prompt for AI Agents |
||
|
|
||
|
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsupported regions raise raw KeyError
Want Baz to fix this for you? Activate Fixer Prompt for AI Agents |
||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Invoice totals return fractional cents
Want Baz to fix this for you? Activate Fixer Prompt for AI Agents |
||
| 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) |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Report output permits row injection
Want Baz to fix this for you? Activate Fixer Prompt for AI Agents |
||
|
|
||
|
|
||
| def header(title: str) -> str: | ||
| """Return a padded report header.""" | ||
| return title.center(48, "-") | ||
| 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) |
Uh oh!
There was an error while loading. Please reload this page.