"""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')