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>
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 centssale_price_cents- Sale price in centsunit_price_cents- Per-unit price in centstotal_price_cents- Line total in centssubtotal_cents- Order subtotal in centstax_amount_cents- Tax in centsshipping_amount_cents- Shipping cost in centsdiscount_amount_cents- Discount in centstotal_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
- Add new
_centscolumns - Migrate data:
UPDATE table SET price_cents = ROUND(price * 100) - Drop old float columns
- 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).