diff --git a/app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php b/app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php new file mode 100644 index 00000000..7c3b7df9 --- /dev/null +++ b/app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php @@ -0,0 +1,69 @@ +statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL'); + $supplier->statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL'); + + $supplier->statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL'); + $supplier->statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL'); + + // Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли). + $supplier->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS balance_freeze_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + event_type VARCHAR(30) NOT NULL, + triggered_by VARCHAR(30) NOT NULL, + balance_rub_at_event DECIMAL(12,2) NOT NULL, + required_leads INTEGER NOT NULL, + capacity_leads INTEGER NOT NULL, + total_daily_limit INTEGER NOT NULL, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + SQL); + $supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY'); + $supplier->statement(<<<'SQL' + CREATE POLICY tenant_isolation ON balance_freeze_log + USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint) + SQL); + $supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)'); + + // Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries). + foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) { + $supplier->statement(<<statement('DROP TABLE IF EXISTS balance_freeze_log'); + $supplier->statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at'); + $supplier->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at'); + } +}; diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 41bd21dd..280b171e 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,8 +1,19 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. +**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит записи в обратном хронологическом порядке (v8.35 → v8.34 → v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.34, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля). + +## v8.35 (2026-05-24) — Billing v2 Spec C: флаги заморозки + balance_freeze_log + +- **+колонка `tenants.frozen_by_balance_at TIMESTAMP NULL`** — флаг заморозки тенанта по балансу. Не NULL = баланс < стоимость дня лидов. Устанавливается `PreflightBalanceService`. +- **+колонка `projects.preflight_blocked_at TIMESTAMP NULL`** — флаг точечной блокировки проекта по preflight-проверке. +- **+таблица `balance_freeze_log`** (BIGSERIAL PK, FK tenants(id), INSERT-only аудит-журнал заморозок/разморозок; RLS `tenant_isolation`; fields: event_type/triggered_by/balance_rub_at_event/required_leads/capacity_leads/total_daily_limit/details/created_at). +- **+индекс `tenants_frozen_by_balance_idx`** — частичный WHERE NOT NULL (sparse). +- **+индекс `projects_preflight_blocked_idx`** — частичный WHERE NOT NULL (sparse). +- **+индекс `balance_freeze_log_tenant_idx`** — по (tenant_id, created_at DESC). +- Миграция: `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects` +- Метрики: +1 таблица, +3 индекса, +1 RLS-политика, +2 колонки. (Сверять с header `db/schema.sql`.) ## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index diff --git a/db/migrations/2026_05_24_balance_freeze_log.sql b/db/migrations/2026_05_24_balance_freeze_log.sql new file mode 100644 index 00000000..1c05cf7c --- /dev/null +++ b/db/migrations/2026_05_24_balance_freeze_log.sql @@ -0,0 +1,55 @@ +-- ============================================================================= +-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу +-- (Billing v2 Spec C). INSERT-only таблица: записи не обновляются, только +-- добавляются. Используется PreflightBalanceService для аудит-трейла. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS balance_freeze_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + event_type VARCHAR(30) NOT NULL, + triggered_by VARCHAR(30) NOT NULL, + balance_rub_at_event DECIMAL(12,2) NOT NULL, + required_leads INTEGER NOT NULL, + capacity_leads INTEGER NOT NULL, + total_daily_limit INTEGER NOT NULL, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON balance_freeze_log + USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + +CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC); + +-- Флаги заморозки на tenants и projects +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL; + +-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся через pgsql_supplier +-- (default privileges от postgres-superuser не наследуются на чужие creator-role). +-- Mirror supplier_lead_deliveries grant pattern. DO block — idempotent + dev-safe +-- (на dev ролей нет → silent skip). balance_freeze_log: SELECT+INSERT только +-- (INSERT-only по дизайну — не UPDATE, не DELETE). +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_admin_user; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_admin_user; + END IF; +END $$; diff --git a/db/schema.sql b/db/schema.sql index f552f759..655fc066 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,12 +1,13 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) +-- Версия: v8.35 (24.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at) +-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) -- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings) -- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) --- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров +-- Метрики: 76 базовые таблицы (67 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 127 индексов / 43 RLS-политика / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) @@ -674,12 +675,17 @@ CREATE TABLE tenants ( -- Метаданные created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, - deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5) + deleted_at TIMESTAMPTZ, -- soft delete (раздел 4.5) + -- v8.35 (Billing v2 Spec C): флаг заморозки по балансу. + -- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService. + frozen_by_balance_at TIMESTAMPTZ NULL ); -CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL; -CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active'; -CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL; +CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL; +CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active'; +CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL; +-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены) +CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL; -- Forward FK на tenants для SaaS-админских таблиц, объявленных выше -- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id). @@ -845,6 +851,9 @@ CREATE TABLE projects ( CHECK (ttfr_target_minutes BETWEEN 1 AND 1440), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, + -- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight. + -- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов). + preflight_blocked_at TIMESTAMPTZ NULL, UNIQUE (tenant_id, name), CONSTRAINT chk_projects_daily_limit_positive CHECK (daily_limit_target > 0), @@ -876,6 +885,8 @@ CREATE INDEX idx_projects_tenant_signal ON projects(tenant_id, signal_type, signal_identifier); -- v8.20 (Plan 6): GIN-индекс для outbound regions queries. CREATE INDEX idx_projects_regions ON projects USING GIN (regions); +-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы) +CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL; COMMENT ON COLUMN projects.daily_limit_target IS 'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на ' @@ -3348,6 +3359,49 @@ ALTER TABLE lead_charges DEFERRABLE INITIALLY DEFERRED; +-- ============================================================================= +-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу +-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS balance_freeze_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + event_type VARCHAR(30) NOT NULL, + triggered_by VARCHAR(30) NOT NULL, + balance_rub_at_event DECIMAL(12,2) NOT NULL, + required_leads INTEGER NOT NULL, + capacity_leads INTEGER NOT NULL, + total_daily_limit INTEGER NOT NULL, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON balance_freeze_log + USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + +CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN + GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin; + GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin; + END IF; +END $$; + -- ============================================================================= -- КОНЕЦ schema.sql -- =============================================================================