Files
orion/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py
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

224 lines
11 KiB
Python

"""convert_prices_to_integer_cents
Revision ID: e1f2a3b4c5d6
Revises: c00d2985701f
Create Date: 2025-12-20 21:30:00.000000
Converts all price/amount columns from Float to Integer cents.
This follows e-commerce best practices (Stripe, PayPal, Shopify) for
precise monetary calculations.
Example: €105.91 is stored as 10591 (integer cents)
Affected tables:
- products: price, sale_price, supplier_cost, margin_percent
- orders: subtotal, tax_amount, shipping_amount, discount_amount, total_amount
- order_items: unit_price, total_price
- cart_items: price_at_add
- marketplace_products: price_numeric, sale_price_numeric
See docs/architecture/money-handling.md for full documentation.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1f2a3b4c5d6'
down_revision: Union[str, None] = 'c00d2985701f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite requires batch mode for column alterations
# Strategy: Add new _cents columns, migrate data, drop old columns
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
# Add new cents columns
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('margin_percent_x100', sa.Integer(), nullable=True))
# Migrate data for products
op.execute('UPDATE products SET price_cents = ROUND(COALESCE(price, 0) * 100)')
op.execute('UPDATE products SET sale_price_cents = ROUND(sale_price * 100) WHERE sale_price IS NOT NULL')
op.execute('UPDATE products SET supplier_cost_cents = ROUND(supplier_cost * 100) WHERE supplier_cost IS NOT NULL')
op.execute('UPDATE products SET margin_percent_x100 = ROUND(margin_percent * 100) WHERE margin_percent IS NOT NULL')
# Drop old columns
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price')
batch_op.drop_column('sale_price')
batch_op.drop_column('supplier_cost')
batch_op.drop_column('margin_percent')
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('tax_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('discount_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_amount_cents', sa.Integer(), nullable=True))
# Migrate data for orders
op.execute('UPDATE orders SET subtotal_cents = ROUND(COALESCE(subtotal, 0) * 100)')
op.execute('UPDATE orders SET tax_amount_cents = ROUND(COALESCE(tax_amount, 0) * 100)')
op.execute('UPDATE orders SET shipping_amount_cents = ROUND(COALESCE(shipping_amount, 0) * 100)')
op.execute('UPDATE orders SET discount_amount_cents = ROUND(COALESCE(discount_amount, 0) * 100)')
op.execute('UPDATE orders SET total_amount_cents = ROUND(COALESCE(total_amount, 0) * 100)')
# Make total_amount_cents NOT NULL after migration
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal')
batch_op.drop_column('tax_amount')
batch_op.drop_column('shipping_amount')
batch_op.drop_column('discount_amount')
batch_op.drop_column('total_amount')
# Alter total_amount_cents to be NOT NULL
batch_op.alter_column('total_amount_cents',
existing_type=sa.Integer(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_price_cents', sa.Integer(), nullable=True))
# Migrate data for order_items
op.execute('UPDATE order_items SET unit_price_cents = ROUND(COALESCE(unit_price, 0) * 100)')
op.execute('UPDATE order_items SET total_price_cents = ROUND(COALESCE(total_price, 0) * 100)')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price')
batch_op.drop_column('total_price')
batch_op.alter_column('unit_price_cents',
existing_type=sa.Integer(),
nullable=False)
batch_op.alter_column('total_price_cents',
existing_type=sa.Integer(),
nullable=False)
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add_cents', sa.Integer(), nullable=True))
# Migrate data for cart_items
op.execute('UPDATE cart_items SET price_at_add_cents = ROUND(COALESCE(price_at_add, 0) * 100)')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add')
batch_op.alter_column('price_at_add_cents',
existing_type=sa.Integer(),
nullable=False)
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('weight_grams', sa.Integer(), nullable=True))
# Migrate data for marketplace_products
op.execute('UPDATE marketplace_products SET price_cents = ROUND(price_numeric * 100) WHERE price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_cents = ROUND(sale_price_numeric * 100) WHERE sale_price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight_grams = ROUND(weight * 1000) WHERE weight IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_numeric')
batch_op.drop_column('sale_price_numeric')
batch_op.drop_column('weight')
def downgrade() -> None:
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('weight', sa.Float(), nullable=True))
op.execute('UPDATE marketplace_products SET price_numeric = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_numeric = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight = weight_grams / 1000.0 WHERE weight_grams IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('weight_grams')
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add', sa.Float(), nullable=True))
op.execute('UPDATE cart_items SET price_at_add = price_at_add_cents / 100.0')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add_cents')
batch_op.alter_column('price_at_add',
existing_type=sa.Float(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_price', sa.Float(), nullable=True))
op.execute('UPDATE order_items SET unit_price = unit_price_cents / 100.0')
op.execute('UPDATE order_items SET total_price = total_price_cents / 100.0')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price_cents')
batch_op.drop_column('total_price_cents')
batch_op.alter_column('unit_price',
existing_type=sa.Float(),
nullable=False)
batch_op.alter_column('total_price',
existing_type=sa.Float(),
nullable=False)
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('tax_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('discount_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_amount', sa.Float(), nullable=True))
op.execute('UPDATE orders SET subtotal = subtotal_cents / 100.0')
op.execute('UPDATE orders SET tax_amount = tax_amount_cents / 100.0')
op.execute('UPDATE orders SET shipping_amount = shipping_amount_cents / 100.0')
op.execute('UPDATE orders SET discount_amount = discount_amount_cents / 100.0')
op.execute('UPDATE orders SET total_amount = total_amount_cents / 100.0')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal_cents')
batch_op.drop_column('tax_amount_cents')
batch_op.drop_column('shipping_amount_cents')
batch_op.drop_column('discount_amount_cents')
batch_op.drop_column('total_amount_cents')
batch_op.alter_column('total_amount',
existing_type=sa.Float(),
nullable=False)
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('margin_percent', sa.Float(), nullable=True))
op.execute('UPDATE products SET price = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE products SET sale_price = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE products SET supplier_cost = supplier_cost_cents / 100.0 WHERE supplier_cost_cents IS NOT NULL')
op.execute('UPDATE products SET margin_percent = margin_percent_x100 / 100.0 WHERE margin_percent_x100 IS NOT NULL')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('supplier_cost_cents')
batch_op.drop_column('margin_percent_x100')