- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.) - Added ignore rules for patterns intentional in this codebase: E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from), SIM108/SIM105/SIM117 (readability preferences) - Added per-file ignores for tests and scripts - Excluded broken scripts/rename_terminology.py (has curly quotes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
364 lines
9.1 KiB
Python
364 lines
9.1 KiB
Python
# app/utils/money.py
|
|
"""
|
|
Money handling utilities using integer cents.
|
|
|
|
All monetary values are stored as integers representing cents.
|
|
This eliminates floating-point precision issues common in financial applications.
|
|
|
|
Example:
|
|
€105.91 is stored as 10591 (integer cents)
|
|
|
|
Usage:
|
|
from app.utils.money import euros_to_cents, cents_to_euros, Money
|
|
|
|
# Convert euros to cents for storage
|
|
price_cents = euros_to_cents(105.91) # Returns 10591
|
|
|
|
# Convert cents to euros for display
|
|
price_euros = cents_to_euros(10591) # Returns 105.91
|
|
|
|
# Format for display
|
|
formatted = Money.format(10591) # Returns "105.91"
|
|
formatted = Money.format(10591, "EUR") # Returns "105.91 EUR"
|
|
|
|
# Parse price string from CSV/API
|
|
cents = parse_price_to_cents("19.99 EUR") # Returns 1999
|
|
|
|
See docs/architecture/money-handling.md for full documentation.
|
|
"""
|
|
|
|
import re
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
|
|
# Type alias for clarity
|
|
Cents = int
|
|
Euros = float
|
|
|
|
# Currency configurations
|
|
CURRENCY_DECIMALS = {
|
|
"EUR": 2,
|
|
"USD": 2,
|
|
"GBP": 2,
|
|
"CHF": 2,
|
|
"JPY": 0, # Japanese Yen has no decimal places
|
|
}
|
|
|
|
DEFAULT_CURRENCY = "EUR"
|
|
|
|
|
|
def euros_to_cents(euros: float | str | Decimal | int | None) -> Cents:
|
|
"""
|
|
Convert a euro amount to cents.
|
|
|
|
Uses Decimal internally for precise conversion, avoiding floating-point errors.
|
|
|
|
Args:
|
|
euros: Amount in euros (float, string, Decimal, int, or None)
|
|
|
|
Returns:
|
|
Amount in cents as integer
|
|
|
|
Examples:
|
|
>>> euros_to_cents(105.91)
|
|
10591
|
|
>>> euros_to_cents("19.99")
|
|
1999
|
|
>>> euros_to_cents(None)
|
|
0
|
|
"""
|
|
if euros is None:
|
|
return 0
|
|
|
|
if isinstance(euros, int):
|
|
# Assume already an integer euro amount (rare)
|
|
return euros * 100
|
|
|
|
if isinstance(euros, str):
|
|
# Clean the string: remove currency symbols, spaces, handle comma decimals
|
|
cleaned = euros.strip()
|
|
cleaned = re.sub(r"[€$£\s]", "", cleaned)
|
|
cleaned = cleaned.replace(",", ".")
|
|
euros = cleaned
|
|
|
|
# Use Decimal for precise conversion
|
|
if isinstance(euros, Decimal):
|
|
d = euros
|
|
else:
|
|
# Convert via string to avoid float precision issues
|
|
d = Decimal(str(euros))
|
|
|
|
# Multiply by 100 and round to nearest integer
|
|
cents = d * Decimal("100")
|
|
return int(cents.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def cents_to_euros(cents: int | None) -> Euros:
|
|
"""
|
|
Convert cents to euros.
|
|
|
|
Args:
|
|
cents: Amount in cents (integer or None)
|
|
|
|
Returns:
|
|
Amount in euros as float, rounded to 2 decimal places
|
|
|
|
Examples:
|
|
>>> cents_to_euros(10591)
|
|
105.91
|
|
>>> cents_to_euros(None)
|
|
0.0
|
|
"""
|
|
if cents is None:
|
|
return 0.0
|
|
|
|
# Simple integer division, then convert to float
|
|
# Using Decimal to ensure exact representation
|
|
euros = Decimal(cents) / Decimal("100")
|
|
return float(euros.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def parse_price_to_cents(
|
|
price_str: str | float | int | None,
|
|
default_currency: str = DEFAULT_CURRENCY,
|
|
) -> tuple[Cents, str]:
|
|
"""
|
|
Parse a price string and return cents and currency.
|
|
|
|
Handles various formats:
|
|
- "19.99 EUR"
|
|
- "19,99 €"
|
|
- "19.99"
|
|
- 19.99 (float)
|
|
|
|
Args:
|
|
price_str: Price string or numeric value
|
|
default_currency: Currency to use if not specified in string
|
|
|
|
Returns:
|
|
Tuple of (cents, currency_code)
|
|
|
|
Examples:
|
|
>>> parse_price_to_cents("19.99 EUR")
|
|
(1999, "EUR")
|
|
>>> parse_price_to_cents("19,99 €")
|
|
(1999, "EUR")
|
|
>>> parse_price_to_cents(19.99)
|
|
(1999, "EUR")
|
|
"""
|
|
if price_str is None:
|
|
return (0, default_currency)
|
|
|
|
if isinstance(price_str, int | float):
|
|
return (euros_to_cents(price_str), default_currency)
|
|
|
|
# Parse string
|
|
price_str = str(price_str).strip()
|
|
|
|
# Extract currency if present
|
|
currency = default_currency
|
|
currency_patterns = [
|
|
(r"\bEUR\b", "EUR"),
|
|
(r"€", "EUR"),
|
|
(r"\bUSD\b", "USD"),
|
|
(r"\$", "USD"),
|
|
(r"\bGBP\b", "GBP"),
|
|
(r"£", "GBP"),
|
|
(r"\bCHF\b", "CHF"),
|
|
]
|
|
|
|
for pattern, curr in currency_patterns:
|
|
if re.search(pattern, price_str, re.IGNORECASE):
|
|
currency = curr
|
|
break
|
|
|
|
# Remove currency symbols and extract numeric part
|
|
numeric_str = re.sub(r"[€$£a-zA-Z\s]", "", price_str)
|
|
|
|
# Handle European decimal comma
|
|
numeric_str = numeric_str.replace(",", ".")
|
|
|
|
# Handle thousand separators (e.g., "1.000,00" -> "1000.00")
|
|
# If there are multiple dots, assume the last one is decimal
|
|
parts = numeric_str.split(".")
|
|
if len(parts) > 2:
|
|
# Multiple dots: join all but last as thousands, last as decimal
|
|
numeric_str = "".join(parts[:-1]) + "." + parts[-1]
|
|
|
|
try:
|
|
cents = euros_to_cents(numeric_str)
|
|
except (ValueError, TypeError):
|
|
cents = 0
|
|
|
|
return (cents, currency)
|
|
|
|
|
|
class Money:
|
|
"""
|
|
Money utility class for formatting and operations.
|
|
|
|
This class provides static methods for money operations.
|
|
All internal values are in cents (integers).
|
|
"""
|
|
|
|
@staticmethod
|
|
def from_euros(euros: float | str | Decimal | None) -> Cents:
|
|
"""Create cents from euro amount."""
|
|
return euros_to_cents(euros)
|
|
|
|
@staticmethod
|
|
def from_cents(cents: int | None) -> Cents:
|
|
"""Passthrough for cents (for consistency)."""
|
|
return cents if cents is not None else 0
|
|
|
|
@staticmethod
|
|
def to_euros(cents: int | None) -> Euros:
|
|
"""Convert cents to euros."""
|
|
return cents_to_euros(cents)
|
|
|
|
@staticmethod
|
|
def format(
|
|
cents: int | None,
|
|
currency: str = "",
|
|
locale: str = "en",
|
|
) -> str:
|
|
"""
|
|
Format cents as a currency string.
|
|
|
|
Args:
|
|
cents: Amount in cents
|
|
currency: Currency code to append (optional)
|
|
locale: Locale for formatting (en, de, fr)
|
|
|
|
Returns:
|
|
Formatted price string
|
|
|
|
Examples:
|
|
>>> Money.format(10591)
|
|
"105.91"
|
|
>>> Money.format(10591, "EUR")
|
|
"105.91 EUR"
|
|
>>> Money.format(10591, "EUR", "de")
|
|
"105,91 EUR"
|
|
"""
|
|
if cents is None:
|
|
cents = 0
|
|
|
|
euros = cents_to_euros(cents)
|
|
|
|
# Format based on locale
|
|
if locale in ("de", "fr", "lb"):
|
|
# European format: comma decimal, dot thousands
|
|
formatted = f"{euros:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
else:
|
|
# English format: dot decimal, comma thousands
|
|
formatted = f"{euros:,.2f}"
|
|
|
|
if currency:
|
|
return f"{formatted} {currency}"
|
|
return formatted
|
|
|
|
@staticmethod
|
|
def parse(price_str: str | float | int | None) -> Cents:
|
|
"""
|
|
Parse a price value to cents.
|
|
|
|
Args:
|
|
price_str: Price string or numeric value
|
|
|
|
Returns:
|
|
Amount in cents
|
|
"""
|
|
cents, _ = parse_price_to_cents(price_str)
|
|
return cents
|
|
|
|
@staticmethod
|
|
def add(*amounts: int | None) -> Cents:
|
|
"""
|
|
Add multiple cent amounts.
|
|
|
|
Args:
|
|
*amounts: Cent amounts to add
|
|
|
|
Returns:
|
|
Sum in cents
|
|
"""
|
|
return sum(a for a in amounts if a is not None)
|
|
|
|
@staticmethod
|
|
def subtract(amount: int, *deductions: int | None) -> Cents:
|
|
"""
|
|
Subtract amounts from a base amount.
|
|
|
|
Args:
|
|
amount: Base amount in cents
|
|
*deductions: Amounts to subtract
|
|
|
|
Returns:
|
|
Result in cents
|
|
"""
|
|
result = amount
|
|
for d in deductions:
|
|
if d is not None:
|
|
result -= d
|
|
return result
|
|
|
|
@staticmethod
|
|
def multiply(cents: int, quantity: int) -> Cents:
|
|
"""
|
|
Multiply cents by quantity.
|
|
|
|
Args:
|
|
cents: Unit price in cents
|
|
quantity: Number of units
|
|
|
|
Returns:
|
|
Total in cents
|
|
"""
|
|
return cents * quantity
|
|
|
|
@staticmethod
|
|
def calculate_line_total(unit_price_cents: int, quantity: int) -> Cents:
|
|
"""
|
|
Calculate line total for an order item.
|
|
|
|
Args:
|
|
unit_price_cents: Price per unit in cents
|
|
quantity: Number of units
|
|
|
|
Returns:
|
|
Line total in cents
|
|
"""
|
|
return unit_price_cents * quantity
|
|
|
|
@staticmethod
|
|
def calculate_order_total(
|
|
subtotal_cents: int,
|
|
tax_cents: int = 0,
|
|
shipping_cents: int = 0,
|
|
discount_cents: int = 0,
|
|
) -> Cents:
|
|
"""
|
|
Calculate order total.
|
|
|
|
Args:
|
|
subtotal_cents: Sum of line items in cents
|
|
tax_cents: Tax amount in cents
|
|
shipping_cents: Shipping cost in cents
|
|
discount_cents: Discount amount in cents
|
|
|
|
Returns:
|
|
Total in cents
|
|
"""
|
|
return subtotal_cents + tax_cents + shipping_cents - discount_cents
|
|
|
|
|
|
# Convenience exports
|
|
__all__ = [
|
|
"euros_to_cents",
|
|
"cents_to_euros",
|
|
"parse_price_to_cents",
|
|
"Money",
|
|
"Cents",
|
|
"Euros",
|
|
]
|