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>
224 lines
11 KiB
Python
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')
|