Files
orion/docs/architecture/money-handling.md
Samir Boulahtit 8a2a955c92 feat: add VAT tax rate, cost, and Letzshop feed settings
Product Model:
- Add tax_rate_percent (NOT NULL, default 17) for Luxembourg VAT
- Add cost_cents for profit calculation
- Add profit calculation properties: net_price, vat_amount, profit, margin
- Rename supplier_cost_cents to cost_cents

MarketplaceProduct Model:
- Add tax_rate_percent (NOT NULL, default 17)

Vendor Model (Letzshop feed settings):
- letzshop_default_tax_rate: Default VAT for new products (0, 3, 8, 14, 17)
- letzshop_boost_sort: Product sort priority (0.0-10.0)
- letzshop_delivery_method: nationwide, package_delivery, self_collect
- letzshop_preorder_days: Pre-order shipping delay

VAT Strategy:
- Store prices as gross (VAT-inclusive) for B2C
- Calculate net from gross when needed for profit
- Luxembourg VAT rates: 0%, 3%, 8%, 14%, 17%

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

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

10 KiB

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

# 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

# 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:

# 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

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

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)

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)

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)

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.

// 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:

// 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:

<!-- Display price from cents -->
<span x-text="Money.format(product.price_cents)"></span>

<!-- Or using computed euro value from API -->
<span x-text="formatPrice(product.price, product.currency)"></span>

Data Import/Export

CSV Import

When importing prices from CSV files:

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:

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
# 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:

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:

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

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

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:

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.