329 lines
12 KiB
Python
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")
|