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>
This commit is contained in:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

@@ -0,0 +1,321 @@
# 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
<!-- 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:
```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).