DealDocumentScreening/migrations/versions/0011_user_profiles_billing.py

329 lines
12 KiB
Python

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