# 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 ``` ## VAT Handling ### Storage Strategy: Gross Prices (VAT-Inclusive) The platform stores all prices as **gross** (VAT-inclusive) for B2C simplicity: - Customers see the final price immediately (EU legal requirement) - No calculation needed for display - Profit is calculated by extracting net from gross ### Luxembourg VAT Rates | Rate | Percentage | Applies To | |------|------------|------------| | Standard | **17%** | Most products (electronics, furniture, cosmetics) | | Intermediate | **14%** | Wines, printed materials, heating oils | | Reduced | **8%** | Utilities, hairdressing, small repairs | | Super-reduced | **3%** | Food, books, children's clothing, medicine | | Zero | **0%** | Exports, certain financial services | ### Database Fields ```python class Product(Base): # Tax rate (0, 3, 8, 14, or 17 for Luxembourg) tax_rate_percent = Column(Integer, default=17, nullable=False) # Cost for profit calculation (what vendor pays to acquire) cost_cents = Column(Integer, nullable=True) ``` ### Profit Calculation ```python class Product(Base): @property def net_price_cents(self) -> int: """Calculate net price (excluding VAT) from gross price. Formula: Net = Gross * 100 / (100 + rate) Example: €119 gross at 17% VAT = €119 * 100 / 117 = €101.71 net """ return int(self.effective_price_cents * 100 / (100 + self.tax_rate_percent)) @property def vat_amount_cents(self) -> int: """VAT = Gross - Net""" return self.effective_price_cents - self.net_price_cents @property def profit_cents(self) -> int: """Profit = Net Revenue - Cost""" return self.net_price_cents - self.cost_cents @property def profit_margin_percent(self) -> float: """Margin% = (Profit / Net) * 100""" return round((self.profit_cents / self.net_price_cents) * 100, 2) ``` ### Example Calculation | Field | Value | |-------|-------| | Gross Price | €119.00 (11900 cents) | | Tax Rate | 17% | | Net Price | €101.71 (10171 cents) | | VAT Amount | €17.29 (1729 cents) | | Cost | €60.00 (6000 cents) | | **Profit** | €41.71 (4171 cents) | | **Margin** | 41.0% | ### Vendor Letzshop Settings Vendors have default settings for the Letzshop feed: ```python class Vendor(Base): # Default VAT rate for new products letzshop_default_tax_rate = Column(Integer, default=17) # Product sort priority (0.0-10.0, higher = displayed first) letzshop_boost_sort = Column(String(10), default="5.0") # Delivery method: 'nationwide', 'package_delivery', 'self_collect' letzshop_delivery_method = Column(String(100), default="package_delivery") # Pre-order days before shipping (default 1 day) letzshop_preorder_days = Column(Integer, default=1) ``` ## 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 Rules:** 1. All arithmetic happens with integers. Conversion to/from euros only at system boundaries. 2. Prices are stored as gross (VAT-inclusive). Net is calculated when needed. 3. Tax rate is stored per product, with vendor defaults for new products.