"""user_profiles + plans/subscriptions/quota_usage/credit_events + invoices extension Revision ID: 0011 Revises: 0010 Create Date: 2026-08-25 Billing & profile foundation (PLAN .scratch/user-profile-billing/PLAN.md §2): - user_profiles: 1:1 passive settings row per user (lazy-created on first read) - plans: lookup catalog seeded with D8 placeholders (Free/Lite/Pro/Max) - subscriptions: one active/past_due per user (partial unique index) - quota_usage: per-document quota ledger (UNIQUE document_id = idempotency) - credit_events: append-only credit history (balance_after self-verifying) - invoices: kind/credits_purchased/subscription_id/confirmation_url columns. The table was a never-written stub since 0001, so no backfill is needed. If a dev DB somehow has stray rows, kind/credits_purchased stay NULL — those rows predate billing and are treated as legacy drafts. - users.billing_hold: refund-clawback gate for 017. All money in kopecks INT. No floats, no rollover. """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "0011" down_revision: str | None = "0010" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ── user_profiles (1:1 with users) ─────────────────────────────────────── op.create_table( "user_profiles", sa.Column("user_id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("language", sa.String(4), nullable=True), sa.Column("timezone", sa.String(64), nullable=True), sa.Column( "notif_prefs", postgresql.JSONB(), nullable=False, server_default=sa.text( """'{"report_ready": true, "security": true, "marketing": false}'""" ), ), sa.Column( "dashboard_prefs", postgresql.JSONB(), nullable=False, server_default=sa.text( """'{"severity_filter": "all", "per_page": 10, "density": "comfortable"}'""" ), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.CheckConstraint( "language IS NULL OR language IN ('ru','be','en')", name="user_profiles_language_check", ), ) # ── plans (lookup, seeded) ─────────────────────────────────────────────── op.create_table( "plans", sa.Column("code", sa.String(16), primary_key=True), sa.Column("name", sa.Text(), nullable=False), sa.Column("price_kopecks", sa.Integer(), nullable=False), sa.Column("monthly_quota", sa.Integer(), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), sa.Column("sort", sa.Integer(), nullable=False), sa.CheckConstraint("price_kopecks >= 0", name="plans_price_nonneg"), sa.CheckConstraint("monthly_quota >= 0", name="plans_quota_nonneg"), ) plans_table = sa.table( "plans", sa.column("code", sa.String), sa.column("name", sa.Text), sa.column("price_kopecks", sa.Integer), sa.column("monthly_quota", sa.Integer), sa.column("is_active", sa.Boolean), sa.column("sort", sa.Integer), ) op.bulk_insert( plans_table, [ { "code": "free", "name": "Free", "price_kopecks": 0, "monthly_quota": 0, "is_active": True, "sort": 0, }, { "code": "lite", "name": "Lite", "price_kopecks": 49000, "monthly_quota": 5, "is_active": True, "sort": 1, }, { "code": "pro", "name": "Pro", "price_kopecks": 149000, "monthly_quota": 20, "is_active": True, "sort": 2, }, { "code": "max", "name": "Max", "price_kopecks": 390000, "monthly_quota": 60, "is_active": True, "sort": 3, }, ], ) # ── subscriptions ──────────────────────────────────────────────────────── op.create_table( "subscriptions", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column( "user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, ), sa.Column( "plan_code", sa.String(16), sa.ForeignKey("plans.code"), nullable=False, ), sa.Column("status", sa.String(12), nullable=False), sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=False), sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=False), sa.Column( "auto_renew", sa.Boolean(), nullable=False, server_default=sa.text("false"), ), sa.Column( "origin_invoice_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("invoices.id"), nullable=True, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "status IN ('active','past_due','expired','cancelled')", name="subscriptions_status_check", ), ) op.create_index( "subscriptions_user_status_idx", "subscriptions", ["user_id", "status"], ) op.create_index( "uq_subscriptions_active", "subscriptions", ["user_id"], unique=True, postgresql_where=sa.text("status IN ('active','past_due')"), ) # ── quota_usage (ledger) ───────────────────────────────────────────────── op.create_table( "quota_usage", sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True), sa.Column( "user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, ), sa.Column( "subscription_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("subscriptions.id"), nullable=False, ), sa.Column( "document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("documents.id"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.UniqueConstraint("document_id", name="quota_usage_document_unique"), ) op.create_index("quota_usage_subscription_idx", "quota_usage", ["subscription_id"]) # ── credit_events (ledger) ─────────────────────────────────────────────── op.create_table( "credit_events", sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True), sa.Column( "user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, ), sa.Column("delta", sa.Integer(), nullable=False), sa.Column("kind", sa.String(16), nullable=False), sa.Column( "document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("documents.id"), nullable=True, ), sa.Column( "invoice_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("invoices.id"), nullable=True, ), sa.Column("balance_after", sa.Integer(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint("delta <> 0", name="credit_events_delta_nonzero"), sa.CheckConstraint( "kind IN ('signup','admin_grant','topup','reserve','refund_auto'," "'clawback','quota_refund','hold_clear')", name="credit_events_kind_check", ), sa.CheckConstraint( "balance_after >= 0", name="credit_events_balance_nonneg" ), ) op.create_index( "credit_events_user_created_idx", "credit_events", ["user_id", sa.text("created_at DESC")], ) op.create_index("credit_events_invoice_idx", "credit_events", ["invoice_id"]) # ── invoices extension (stub since 0001, never written) ────────────────── op.add_column( "invoices", sa.Column( "kind", sa.String(12), nullable=True, ), ) op.create_check_constraint( "invoices_kind_check", "invoices", "kind IS NULL OR kind IN ('topup','subscription','renewal')", ) op.add_column( "invoices", sa.Column("credits_purchased", sa.Integer(), nullable=True), ) op.add_column( "invoices", sa.Column("subscription_id", postgresql.UUID(as_uuid=True), nullable=True), ) op.add_column( "invoices", sa.Column("confirmation_url", sa.Text(), nullable=True), ) # ── users.billing_hold ─────────────────────────────────────────────────── op.add_column( "users", sa.Column( "billing_hold", sa.Boolean(), nullable=False, server_default=sa.text("false"), comment="Refund clawback hold: uploads blocked with 402 until admin clears", ), ) def downgrade() -> None: op.drop_column("users", "billing_hold") op.drop_column("invoices", "confirmation_url") op.drop_column("invoices", "subscription_id") op.drop_column("invoices", "credits_purchased") op.drop_constraint("invoices_kind_check", "invoices", type_="check") op.drop_column("invoices", "kind") op.drop_index("credit_events_invoice_idx", table_name="credit_events") op.drop_index("credit_events_user_created_idx", table_name="credit_events") op.drop_table("credit_events") op.drop_index("quota_usage_subscription_idx", table_name="quota_usage") op.drop_table("quota_usage") op.drop_index("uq_subscriptions_active", table_name="subscriptions") op.drop_index("subscriptions_user_status_idx", table_name="subscriptions") op.drop_table("subscriptions") op.drop_table("plans") op.drop_table("user_profiles")