/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/modules/billing/models.py
392 строки
21 KB
SystemsStrategy
Initial import
02 июл 2026, 15:15
02 июл 2026, 15:15
9624ed1
Код
Авторство
О чём код?
"""ORM-модели billing-схемы. - Plan — справочный тарифный план (free / pro / trial). Бизнес-ключ — code. - Subscription — текущая подписка организации. UNIQUE(organization_id) — ровно одна подписка на org; смена плана делается через UPDATE, не INSERT. - PaymentAttempt — попытка оплаты через gateway (ЮKassa). UNIQUE(idempotency_key) защищает от двойного списания при retry. - ProcessedNotification — лог обработанных webhook'ов. UNIQUE(object_id, event) + INSERT ON CONFLICT DO NOTHING = идемпотентный handler. Без этого replay одного webhook продлевает подписку дважды. - Invoice — финансовый документ, выпущенный после успешной оплаты. UNIQUE(payment_attempt_id) — один платёж = один invoice. PDF лежит в FileStorage по ключу pdf_storage_key. Важно: FK на auth.organizations задаётся строкой (без import Organization) — это сохраняет границы модулей (правило проекта №4): «никаких импортов ORM моделей чужого модуля». SQLAlchemy резолвит FK через global metadata-registry. """ from __future__ import annotations from datetime import datetime from typing import Any from uuid import UUID from sqlalchemy import ( UUID as SAUUID, ) from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, UniqueConstraint, func, text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from api.shared.db.base import Base, TimestampMixin, UUIDMixin # CHECK-ограничение для subscriptions.status: значения должны совпадать # со списком, который PlanService и SubscriptionService считают валидными. # trial — пробный период (14 дней), active — оплачен, expired — истёк, # cancelled — отменён пользователем (доступен до конца оплаченного срока). _STATUS_CHECK = "status IN ('trial', 'active', 'expired', 'cancelled')" # payment_attempts.status переходы: # pending → succeeded (webhook от ЮKassa подтвердил) # pending → failed (карта отклонена, истёк timeout) # pending → cancelled (юзер отказался на странице ЮKassa) _PAYMENT_STATUS_CHECK = "status IN ('pending', 'succeeded', 'failed', 'cancelled')" # audit_events.actor_type: # user — действие инициировано юзером (включает actor_uid из JWT) # system — фоновая задача (Procrastinate cron, etc.); actor_uid = NULL # webhook — пришло от внешнего gateway (ЮKassa); actor_uid = NULL, # source_ip заполнен IP-исходником после X-Forwarded-For-парсинга # unknown — fallback когда контекст потерян (не должно быть на проде) _AUDIT_ACTOR_TYPE_CHECK = "actor_type IN ('user', 'system', 'webhook', 'unknown')" class Plan(TimestampMixin, Base): """Тарифный план — справочник. code — стабильный бизнес-ключ ('free', 'pro', 'trial'). На него ссылаемся в коде и API. price_kopecks в копейках (никаких float для денег — §13 ADR). limits — JSONB-словарь ресурс→максимум: {"users": 50, "spaces": 999999}. duration_days = 0 означает «бесконечный» (free); для trial — 14, pro — 30. """ __tablename__ = "plans" __table_args__ = ( CheckConstraint("price_kopecks >= 0", name="ck_plans_price_kopecks_non_negative"), CheckConstraint("duration_days >= 0", name="ck_plans_duration_days_non_negative"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(200), nullable=False) price_kopecks: Mapped[int] = mapped_column( BigInteger, nullable=False, default=0, server_default="0" ) # duration_days = 0 → план без срока (free); >0 → подписка на N дней. duration_days: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0" ) # JSONB вместо JSON — поддержка индексов и операторов в Postgres. limits: Mapped[dict[str, int]] = mapped_column( JSONB, nullable=False, default=dict, server_default="{}" ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="true" ) class Subscription(UUIDMixin, TimestampMixin, Base): """Текущая подписка организации. UNIQUE(organization_id) — инвариант домена: одна org = одна подписка. Смена плана (free→pro, продление) делается через UPDATE этой строки, не через INSERT. Это атомарно и устраняет race condition. status переходы: trial → active (после первого успешного платежа) trial → expired (14 дней прошло, не оплатили) active → expired (срок истёк, не продлили) active → cancelled (пользователь отменил, но доступ остаётся до expires_at) cancelled_at — момент отмены пользователем; expires_at не меняется (пользователь доплатил за период и должен иметь доступ до конца). """ __tablename__ = "subscriptions" __table_args__ = ( CheckConstraint(_STATUS_CHECK, name="ck_subscriptions_status"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) # FK строкой — не импортируем auth.models, сохраняем module boundary. organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, unique=True, ) plan_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("billing.plans.id", ondelete="RESTRICT"), nullable=False, index=True, ) status: Mapped[str] = mapped_column(String(20), nullable=False) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # expires_at = NULL только для free-плана без срока. Trial и active — # всегда установлен. Сравнение с now() даёт текущий статус. expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) plan: Mapped[Plan] = relationship(lazy="joined") class PaymentAttempt(UUIDMixin, TimestampMixin, Base): """Попытка оплаты через платёжный шлюз. Создаётся при POST /billing/checkout до вызова Payment.create() в gateway. Заполняется gateway_id + confirmation_url после ответа SDK. После webhook status переходит в succeeded/failed/cancelled. UNIQUE(idempotency_key) — наша защита от двойной обработки на стороне приложения. Тот же ключ передаётся в ЮKassa SDK как Idempotence-Key — у них своя защита от двойного списания. plan_id ON DELETE RESTRICT — нельзя удалить план если есть платежи (потеряем историческую ссылку). Снятие плана из листинга — через is_active=False. """ __tablename__ = "payment_attempts" __table_args__ = ( CheckConstraint(_PAYMENT_STATUS_CHECK, name="ck_payment_attempts_status"), CheckConstraint("amount_kopecks > 0", name="ck_payment_attempts_amount_positive"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, index=True, ) plan_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("billing.plans.id", ondelete="RESTRICT"), nullable=False, index=True, ) # Снэпшот цены на момент попытки — если plan.price_kopecks изменится, # история не перепишется. amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) # UUID4 от клиента (наш). Передаётся в ЮKassa SDK как Idempotence-Key. idempotency_key: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) # 'yookassa' сейчас; в будущем — 'stripe' / 'cloudpayments' если добавим. gateway: Mapped[str] = mapped_column(String(20), nullable=False) # id платежа на стороне gateway — заполняется после Payment.create(). # По нему делаем re-fetch в webhook handler (защита от подделки status). gateway_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") # URL на странице ЮKassa куда редиректим клиента — отдаётся при idempotent retry. confirmation_url: Mapped[str | None] = mapped_column(String(2000), nullable=True) # Последний raw-ответ gateway. Без секретов (ЮKassa их не возвращает в payload). response: Mapped[dict[str, object]] = mapped_column( JSONB, nullable=False, default=dict, server_default="{}" ) class ProcessedNotification(Base): """Лог обработанных webhook'ов для дедупликации. UNIQUE(object_id, event) — pair-ключ; INSERT ON CONFLICT DO NOTHING в handler даёт идемпотентность. ЮKassa может ретраить webhook при сетевых ошибках; replay не должен продлевать подписку повторно. Не наследуется от TimestampMixin: только processed_at — это immutable лог, никаких updated_at. id — для удобства, бизнес-ключ — (object_id, event). """ __tablename__ = "processed_notifications" __table_args__ = ( UniqueConstraint("object_id", "event", name="uq_processed_notifications_object_event"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) # object_id — payment.id из webhook payload. У ЮKassa max длина около 36 chars # (UUID-подобная строка), берём с запасом 64. object_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # event — "payment.succeeded", "payment.canceled", "refund.succeeded" и т.п. # Не ограничиваем enum'ом — ЮKassa может добавить новые events, нам важна # только уникальность пары (object_id, event). event: Mapped[str] = mapped_column(String(64), nullable=False) processed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) class Invoice(UUIDMixin, TimestampMixin, Base): """Финансовый документ — инвойс после успешной оплаты. Создаётся в WebhookService.process_yookassa при payment.status='succeeded'. UNIQUE(payment_attempt_id) — один платёж порождает ровно один invoice; защита от race-condition если webhook обработался дважды. pdf_storage_key — путь файла в FileStorage (`var/files/invoices/<uid>.pdf`). Заполняется после рендера. Клиенту путь не виден: отдача через nginx X-Accel-Redirect (см. /billing/invoices/{uid}/pdf endpoint). """ __tablename__ = "invoices" __table_args__ = ( CheckConstraint("amount_kopecks > 0", name="ck_invoices_amount_positive"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, index=True, ) # Cascade на org → payment_attempts → invoices: вся billing-история org # удаляется атомарно при закрытии тенанта (GDPR right-to-erasure). payment_attempt_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("billing.payment_attempts.id", ondelete="CASCADE"), nullable=False, unique=True, ) amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) paid_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # Оплаченный период подписки. period_end передаётся клиенту в инвойсе # ("оплачено до DD.MM.YYYY"); также сохраняется в Subscription.expires_at. period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # Ключ FileStorage. Nullable: invoice создаётся СНАЧАЛА, PDF рендерится # вторым шагом (или в фоне). До рендера — None. pdf_storage_key: Mapped[str | None] = mapped_column(String(200), nullable=True) class PromoCode(TimestampMixin, Base): """Промо-код для скидки на checkout. Foundation Agent создал минимальный stub (issue #122) — Billing Agent расширит business-методами (apply_to_checkout, is_valid, etc.) в своём PR. Не меняй column-объявления, иначе миграция a012 разойдётся — для schema-изменений новая миграция. Особенности: - `discount_kind`: 'percentage' (1-100) или 'fixed_amount' (копейки). - `applies_to_plan_codes` JSONB: null = universal, иначе list[str]. - `max_uses` null = неограничено; current_uses инкрементится атомарно при successful checkout. - `valid_from` / `valid_until`: окно действия (until nullable = forever). - 6 CHECK constraints защищают invariants на DB-уровне. """ __tablename__ = "promo_codes" __table_args__ = ( CheckConstraint( "discount_kind IN ('percentage', 'fixed_amount')", name="ck_promo_codes_discount_kind", ), CheckConstraint("discount_value > 0", name="ck_promo_codes_discount_value_positive"), CheckConstraint( "NOT (discount_kind = 'percentage' AND discount_value > 100)", name="ck_promo_codes_percentage_max_100", ), CheckConstraint("current_uses >= 0", name="ck_promo_codes_current_uses_non_negative"), CheckConstraint( "max_uses IS NULL OR max_uses >= current_uses", name="ck_promo_codes_max_uses_covers_current", ), CheckConstraint( "valid_until IS NULL OR valid_from < valid_until", name="ck_promo_codes_valid_window", ), UniqueConstraint("code", name="uq_promo_codes_code"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) code: Mapped[str] = mapped_column(String(32), nullable=False) discount_kind: Mapped[str] = mapped_column(String(20), nullable=False) discount_value: Mapped[int] = mapped_column(BigInteger, nullable=False) valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) max_uses: Mapped[int | None] = mapped_column(BigInteger, nullable=True) current_uses: Mapped[int] = mapped_column( BigInteger, nullable=False, default=0, server_default="0" ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="true" ) # null = универсальный код, иначе list[str] кодов планов (например ["pro"]). applies_to_plan_codes: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) class BillingAuditEvent(TimestampMixin, Base): """Append-only лог финансовых операций для compliance / forensics (issue #134). Foundation Agent создал DDL (миграция a014) — Billing Agent расширяет AuditService для записи (`record`, `record_persistent`, `list_for_org`). Не редактируй column-объявления без новой миграции — alembic check зафиксирует drift. Каждое событие — иммутабельная запись «кто что сделал когда»: - **action** в dot-notation: `webhook.processed.succeeded`, `payment.attempted`, `subscription.cancelled` — парсится observability-tools'ами по `<entity>.<verb>` - **actor_type** + **actor_uid**: user (JWT), system (cron), webhook (gateway), unknown (fallback). CHECK constraint защищает от typo на DB-уровне. - **entity_type** + **entity_uid**: над каким объектом действие (`payment_attempt`, `subscription`, `invoice`, ...) — nullable для системных событий. entity_uid — VARCHAR (не UUID): может быть UUID или плоский код (plan_code). - **source_ip**: IP-источник (после X-Forwarded-For), для webhook security trail - **payload**: JSONB, дополнительный контекст (amount_kopecks, status, promo_code и т.д.) — структурированно, чтобы query'и работали. Append-only enforcement: 1. ORM-уровень: `AuditService.record` / `record_persistent` — единственные точки записи, нет update_* или delete_* методов. 2. DB-уровень (TODO Foundation): GRANT INSERT-only для billing_role, чтобы compromise application'а не позволил переписать историю. Не FK на payment_attempts/subscriptions — иммутабельность: если entity физически удалён через GDPR-erasure, audit record остаётся (ссылка через entity_uid-строку, не FK с CASCADE). organization_id FK SET NULL по той же причине — log переживает удаление тенанта. """ __tablename__ = "audit_events" __table_args__ = ( CheckConstraint(_AUDIT_ACTOR_TYPE_CHECK, name="ck_audit_events_actor_type"), {"schema": "billing"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="SET NULL"), nullable=True, ) actor_type: Mapped[str] = mapped_column(String(20), nullable=False) actor_uid: Mapped[UUID | None] = mapped_column(SAUUID(), nullable=True) action: Mapped[str] = mapped_column(String(64), nullable=False, index=True) entity_type: Mapped[str | None] = mapped_column(String(32), nullable=True) entity_uid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) payload: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"), )