Files
orion/docs/architecture/money-handling.md
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

7.6 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

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).