# 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", ]