Files
orion/app/utils/money.py
Samir Boulahtit a19c84ea4e feat: integer cents money handling, order page fixes, and vendor filter persistence
Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:33:48 +01:00

365 lines
9.2 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
from typing import Union
# 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: Union[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: Union[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: Union[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: Union[float, str, Decimal, None]) -> Cents:
"""Create cents from euro amount."""
return euros_to_cents(euros)
@staticmethod
def from_cents(cents: Union[int, None]) -> Cents:
"""Passthrough for cents (for consistency)."""
return cents if cents is not None else 0
@staticmethod
def to_euros(cents: Union[int, None]) -> Euros:
"""Convert cents to euros."""
return cents_to_euros(cents)
@staticmethod
def format(
cents: Union[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: Union[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: Union[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: Union[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",
]