# Money Handling Architecture ## Overview This document describes the architecture for handling monetary values in the application. Following industry best practices (Stripe, PayPal, Shopify), all monetary values are stored as **integers representing cents**. ## Why Integer Cents? ### The Problem with Floating-Point ```python # Floating-point arithmetic is imprecise >>> 0.1 + 0.2 0.30000000000000004 >>> 105.91 * 1 105.91000000000001 ``` This causes issues in financial applications where precision is critical. ### The Solution: Integer Cents ```python # Integer arithmetic is always exact >>> 10 + 20 # 0.10 + 0.20 in cents 30 >>> 10591 * 1 # 105.91 EUR in cents 10591 ``` **€105.91** is stored as **10591** (integer) ## Conventions ### Database Schema All price/amount columns use `Integer` type with a `_cents` suffix: ```python # Good - Integer cents price_cents = Column(Integer, nullable=False, default=0) total_amount_cents = Column(Integer, nullable=False) # Bad - Never use Float for money price = Column(Float) # DON'T DO THIS ``` **Column naming convention:** - `price_cents` - Product price in cents - `sale_price_cents` - Sale price in cents - `unit_price_cents` - Per-unit price in cents - `total_price_cents` - Line total in cents - `subtotal_cents` - Order subtotal in cents - `tax_amount_cents` - Tax in cents - `shipping_amount_cents` - Shipping cost in cents - `discount_amount_cents` - Discount in cents - `total_amount_cents` - Order total in cents ### Python Code #### Using the Money Utility ```python from app.utils.money import Money, cents_to_euros, euros_to_cents # Creating money values price = Money.from_euros(105.91) # Returns 10591 price = Money.from_cents(10591) # Returns 10591 # Converting for display euros = cents_to_euros(10591) # Returns 105.91 formatted = Money.format(10591) # Returns "105.91" formatted = Money.format(10591, currency="EUR") # Returns "105.91 EUR" # Parsing user input cents = euros_to_cents("105.91") # Returns 10591 cents = euros_to_cents(105.91) # Returns 10591 # Arithmetic (always use integers) line_total = unit_price_cents * quantity order_total = subtotal_cents + shipping_cents - discount_cents ``` #### In Services ```python from app.utils.money import euros_to_cents, cents_to_euros class OrderService: def create_order(self, items: list): subtotal_cents = 0 for item in items: line_total_cents = item.unit_price_cents * item.quantity subtotal_cents += line_total_cents # All arithmetic in cents - no precision loss total_cents = subtotal_cents + shipping_cents - discount_cents order = Order( subtotal_cents=subtotal_cents, total_amount_cents=total_cents, ) ``` #### In Models (Properties) ```python class Order(Base): total_amount_cents = Column(Integer, nullable=False) @property def total_amount(self) -> float: """Get total as euros (for display/API).""" return cents_to_euros(self.total_amount_cents) @total_amount.setter def total_amount(self, value: float): """Set total from euros.""" self.total_amount_cents = euros_to_cents(value) ``` ### Pydantic Schemas #### Input Schemas (Accept Euros) ```python from pydantic import BaseModel, field_validator from app.utils.money import euros_to_cents class ProductCreate(BaseModel): price: float # Accept euros from API @field_validator('price') @classmethod def convert_to_cents(cls, v): # Validation only - actual conversion in service if v < 0: raise ValueError('Price must be non-negative') return v ``` #### Output Schemas (Return Euros) ```python from pydantic import BaseModel, computed_field from app.utils.money import cents_to_euros class ProductResponse(BaseModel): price_cents: int # Internal storage @computed_field @property def price(self) -> float: """Return price in euros for API consumers.""" return cents_to_euros(self.price_cents) ``` ### API Layer APIs accept and return values in **euros** (human-readable), while internally everything is stored in **cents**. ```json // API Request (euros) { "price": 105.91, "quantity": 2 } // API Response (euros) { "price": 105.91, "total": 211.82 } // Database (cents) price_cents: 10591 total_cents: 21182 ``` ### Frontend/Templates JavaScript helper for formatting: ```javascript // In static/shared/js/money.js const Money = { /** * Format cents as currency string * @param {number} cents - Amount in cents * @param {string} currency - Currency code (default: EUR) * @returns {string} Formatted price */ format(cents, currency = 'EUR') { const euros = cents / 100; return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format(euros); }, /** * Convert euros to cents * @param {number|string} euros - Amount in euros * @returns {number} Amount in cents */ toCents(euros) { return Math.round(parseFloat(euros) * 100); }, /** * Convert cents to euros * @param {number} cents - Amount in cents * @returns {number} Amount in euros */ toEuros(cents) { return cents / 100; } }; ``` In templates: ```html ``` ## Data Import/Export ### CSV Import When importing prices from CSV files: ```python from app.utils.money import parse_price_to_cents # Parse "19.99 EUR" or "19,99" to cents price_cents = parse_price_to_cents("19.99 EUR") # Returns 1999 price_cents = parse_price_to_cents("19,99") # Returns 1999 ``` ### Marketplace Import (Letzshop) When importing from Letzshop GraphQL API: ```python from app.utils.money import euros_to_cents # Letzshop returns prices as floats letzshop_price = 105.91 price_cents = euros_to_cents(letzshop_price) # Returns 10591 ``` ## Migration Strategy ### Database Migration 1. Add new `_cents` columns 2. Migrate data: `UPDATE table SET price_cents = ROUND(price * 100)` 3. Drop old float columns 4. Rename columns if needed ```python # Migration example def upgrade(): # Add cents columns op.add_column('products', sa.Column('price_cents', sa.Integer())) # Migrate data op.execute('UPDATE products SET price_cents = ROUND(price * 100)') # Drop old columns op.drop_column('products', 'price') ``` ## Currency Support The system is designed for EUR but supports multiple currencies: ```python class Money: # Currency decimal places (for future multi-currency support) CURRENCY_DECIMALS = { 'EUR': 2, 'USD': 2, 'GBP': 2, 'JPY': 0, # Yen has no decimals } ``` ## Testing Always test with values that expose floating-point issues: ```python def test_price_precision(): # These values cause issues with float test_prices = [0.1, 0.2, 0.3, 19.99, 105.91] for price in test_prices: cents = euros_to_cents(price) back_to_euros = cents_to_euros(cents) assert back_to_euros == price ``` ## Summary | Layer | Format | Example | |-------|--------|---------| | Database | Integer cents | `10591` | | Python services | Integer cents | `10591` | | Pydantic schemas | Float euros (I/O) | `105.91` | | API request/response | Float euros | `105.91` | | Frontend display | Formatted string | `"105,91 €"` | **Golden Rule:** All arithmetic happens with integers. Conversion to/from euros only at system boundaries (API, display).