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>
416 lines
10 KiB
Markdown
416 lines
10 KiB
Markdown
# 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
|
|
```
|
|
|
|
## 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.
|