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>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# app/utils/__init__.py
|
||||
"""Utility modules for the application."""
|
||||
|
||||
from app.utils.money import (
|
||||
Money,
|
||||
cents_to_euros,
|
||||
euros_to_cents,
|
||||
parse_price_to_cents,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Money",
|
||||
"cents_to_euros",
|
||||
"euros_to_cents",
|
||||
"parse_price_to_cents",
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ import requests
|
||||
from sqlalchemy import literal
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.money import euros_to_cents
|
||||
from models.database.marketplace_import_job import MarketplaceImportError
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.marketplace_product_translation import (
|
||||
@@ -154,8 +155,18 @@ class CSVProcessor:
|
||||
logger.info(f"Normalized columns: {list(df.columns)}")
|
||||
return df
|
||||
|
||||
def _parse_price_to_numeric(self, price_str: str | None) -> float | None:
|
||||
"""Parse price string like '19.99 EUR' to float."""
|
||||
def _parse_price_to_cents(self, price_str: str | None) -> int | None:
|
||||
"""Parse price string like '19.99 EUR' to integer cents.
|
||||
|
||||
Uses the money utility for precise conversion.
|
||||
Example: '19.99 EUR' -> 1999
|
||||
|
||||
Args:
|
||||
price_str: Price string with optional currency
|
||||
|
||||
Returns:
|
||||
Price in integer cents, or None if parsing fails
|
||||
"""
|
||||
if not price_str:
|
||||
return None
|
||||
|
||||
@@ -164,8 +175,9 @@ class CSVProcessor:
|
||||
if numbers:
|
||||
num_str = numbers[0].replace(",", ".")
|
||||
try:
|
||||
return float(num_str)
|
||||
except ValueError:
|
||||
# Convert euros to cents using money utility
|
||||
return euros_to_cents(num_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -185,10 +197,10 @@ class CSVProcessor:
|
||||
parsed_price, currency = self.price_processor.parse_price_currency(
|
||||
processed_data["price"]
|
||||
)
|
||||
# Store both raw price string and numeric value
|
||||
# Store both raw price string and numeric value in cents
|
||||
raw_price = processed_data["price"]
|
||||
processed_data["price"] = parsed_price
|
||||
processed_data["price_numeric"] = self._parse_price_to_numeric(raw_price)
|
||||
processed_data["price_cents"] = self._parse_price_to_cents(raw_price)
|
||||
processed_data["currency"] = currency
|
||||
|
||||
# Process sale_price
|
||||
@@ -198,7 +210,7 @@ class CSVProcessor:
|
||||
processed_data["sale_price"]
|
||||
)
|
||||
processed_data["sale_price"] = parsed_sale_price
|
||||
processed_data["sale_price_numeric"] = self._parse_price_to_numeric(
|
||||
processed_data["sale_price_cents"] = self._parse_price_to_cents(
|
||||
raw_sale_price
|
||||
)
|
||||
|
||||
|
||||
364
app/utils/money.py
Normal file
364
app/utils/money.py
Normal file
@@ -0,0 +1,364 @@
|
||||
# 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",
|
||||
]
|
||||
Reference in New Issue
Block a user