"""control0002 — Stripe billing columns (MT-8) Adds billing state columns to the control-plane `tenants` table and `stripe_price_id` to the `plans` table. All column additions use INFORMATION_SCHEMA existence checks so the migration is safe to re-run (CLAUDE.md Rule 14). No ENUM change to the existing `tenants.status` column — `subscription_status` is a separate nullable column tracking billing lifecycle independently of operational state. """ import sqlalchemy as sa from alembic import op revision = 'control0002_billing' down_revision = 'control0001_init' branch_labels = None depends_on = None def _column_exists(bind, table: str, column: str) -> bool: result = bind.execute(sa.text( "SELECT COUNT(*) FROM information_schema.columns " "WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c" ), {'t': table, 'c': column}) return result.scalar() > 0 def _index_exists(bind, table: str, index_name: str) -> bool: result = bind.execute(sa.text( "SELECT COUNT(*) FROM information_schema.statistics " "WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i" ), {'t': table, 'i': index_name}) return result.scalar() > 0 def upgrade(): bind = op.get_bind() # ── plans: stripe_price_id ───────────────────────────────────────────── if not _column_exists(bind, 'plans', 'stripe_price_id'): op.execute(sa.text( "ALTER TABLE plans ADD COLUMN stripe_price_id VARCHAR(100) NULL" )) # ── tenants: stripe_customer_id ──────────────────────────────────────── if not _column_exists(bind, 'tenants', 'stripe_customer_id'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN stripe_customer_id VARCHAR(64) NULL" )) if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_customer'): op.execute(sa.text( "ALTER TABLE tenants ADD INDEX ix_tenants_stripe_customer (stripe_customer_id)" )) # ── tenants: stripe_subscription_id ─────────────────────────────────── if not _column_exists(bind, 'tenants', 'stripe_subscription_id'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN stripe_subscription_id VARCHAR(64) NULL" )) if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_sub'): op.execute(sa.text( "ALTER TABLE tenants ADD INDEX ix_tenants_stripe_sub (stripe_subscription_id)" )) # ── tenants: subscription_status ────────────────────────────────────── if not _column_exists(bind, 'tenants', 'subscription_status'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN subscription_status " "ENUM('trial','active','past_due','cancelled') NULL" )) if not _index_exists(bind, 'tenants', 'ix_tenants_sub_status'): op.execute(sa.text( "ALTER TABLE tenants ADD INDEX ix_tenants_sub_status (subscription_status)" )) # ── tenants: trial_ends_at ───────────────────────────────────────────── if not _column_exists(bind, 'tenants', 'trial_ends_at'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN trial_ends_at DATETIME NULL" )) # ── tenants: current_period_end ──────────────────────────────────────── if not _column_exists(bind, 'tenants', 'current_period_end'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN current_period_end DATETIME NULL" )) # ── tenants: billing_email ───────────────────────────────────────────── if not _column_exists(bind, 'tenants', 'billing_email'): op.execute(sa.text( "ALTER TABLE tenants ADD COLUMN billing_email VARCHAR(255) NULL" )) def downgrade(): bind = op.get_bind() for col in ('billing_email', 'current_period_end', 'trial_ends_at', 'subscription_status', 'stripe_subscription_id', 'stripe_customer_id'): if _column_exists(bind, 'tenants', col): op.execute(sa.text(f"ALTER TABLE tenants DROP COLUMN {col}")) if _column_exists(bind, 'plans', 'stripe_price_id'): op.execute(sa.text("ALTER TABLE plans DROP COLUMN stripe_price_id"))