Netpulse_SasS/server/migrations/0009_billing_licensing.sql
byrsapty 205dd5e079 Пакування: runner міграцій і вшитий у бінарник фронтенд
netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані
psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного
запуску — це половина дистрибутива на порожньому місці.

Міграції вшиті через embed і переїхали в server/migrations: embed не
бачить нічого за межами кореня свого модуля, а міграції поруч із
бінарником, який їх накочує, не можуть розійтися версіями.

Накочування під advisory-блокуванням: два інстанси при rolling update
інакше застосували б ту саму міграцію двічі. Кожен файл в одній
транзакції разом із записом у schema_migrations; виняток — continuous
aggregates, які TimescaleDB забороняє в транзакції. Змінена вже
застосована міграція зупиняє запуск: у різних інсталяціях інакше
опиниться різна схема під одним номером.

Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск
каже «схема актуальна».

Веб віддає сам API через embed: на self-hosted це прибирає з інструкції
встановлення цілий компонент. Три політики кешування — назавжди для
assets із хешем у імені, ніколи для index.html, коротко для решти.

Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з
index.html, і клієнт падав на розборі HTML як JSON замість чесного 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 01:01:55 +03:00

290 lines
13 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- =====================================================================
-- NetPulse :: 0009_billing_licensing.sql
-- Тарифи, entitlements, підписки (Stripe/Paddle), per-device usage,
-- інвойси, ліцензійні ключі RSA-4096 для self-hosted.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Тарифні плани та ліміти
-- ---------------------------------------------------------------------
CREATE TABLE bill.plans (
key core.slug PRIMARY KEY, -- free, pro, enterprise
name text NOT NULL,
description text,
-- Базова абонплата (може бути 0 при чистому per-device)
base_price_cents int NOT NULL DEFAULT 0,
-- Ціна за пристрій/місяць у центах: Pro 50..150
per_device_cents int NOT NULL DEFAULT 0,
currency char(3) NOT NULL DEFAULT 'USD',
billing_period text NOT NULL DEFAULT 'monthly', -- monthly | yearly
-- Обмеження. NULL = без обмежень.
max_devices int,
max_maps int,
max_map_nodes int,
max_agents int,
max_users int,
metric_retention_days int NOT NULL DEFAULT 35,
-- Доступні плагіни/можливості
features text[] NOT NULL DEFAULT '{}',
sort_order int NOT NULL DEFAULT 0,
is_public boolean NOT NULL DEFAULT true,
stripe_price_id text,
paddle_price_id text
);
-- Каталог фіч, на які посилаються plans.features та перевірки в API
CREATE TABLE bill.features (
key core.slug PRIMARY KEY, -- snmp, lldp_discovery, ncm_git, floor_plans,
name text NOT NULL, -- white_label, telegram, custom_roles, netflow
description text,
plugin_key core.slug REFERENCES core.plugins(key) ON DELETE SET NULL
);
-- ---------------------------------------------------------------------
-- Підписки
-- ---------------------------------------------------------------------
CREATE TYPE bill.provider AS ENUM ('stripe','paddle','manual','license_key');
CREATE TYPE bill.sub_status AS ENUM ('trialing','active','past_due','canceled','unpaid','paused','incomplete');
CREATE TABLE bill.subscriptions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
provider bill.provider NOT NULL DEFAULT 'stripe',
status bill.sub_status NOT NULL DEFAULT 'trialing',
external_customer_id text, -- cus_...
external_subscription_id text, -- sub_...
external_item_id text, -- si_... (для usage-based reporting)
quantity int NOT NULL DEFAULT 0, -- поточна кількість оплачених пристроїв
currency char(3) NOT NULL DEFAULT 'USD',
current_period_start timestamptz,
current_period_end timestamptz,
trial_end timestamptz,
cancel_at_period_end boolean NOT NULL DEFAULT false,
canceled_at timestamptz,
-- Персональні перевизначення лімітів (Enterprise-домовленості)
overrides jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX subs_tenant_active_uniq ON bill.subscriptions (tenant_id)
WHERE status IN ('trialing','active','past_due','paused');
CREATE INDEX subs_external_idx ON bill.subscriptions (external_subscription_id);
-- Матеріалізовані права доступу тенанта — те, що читає API на кожному запиті.
-- Перераховується при зміні підписки/ліцензії. Кеш у Redis, істина тут.
CREATE TABLE bill.entitlements (
tenant_id uuid PRIMARY KEY REFERENCES core.tenants(id) ON DELETE CASCADE,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
max_devices int,
max_maps int,
max_map_nodes int,
max_agents int,
max_users int,
metric_retention_days int NOT NULL DEFAULT 35,
features text[] NOT NULL DEFAULT '{}',
source bill.provider NOT NULL DEFAULT 'stripe',
valid_until timestamptz,
-- Grace-період після несплати: доступ read-only, опитування зупинено
grace_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------
-- Облік використання (per-device billing)
-- ---------------------------------------------------------------------
-- Щоденний зріз: скільки пристроїв реально було увімкнено.
-- Тарифікація за піком або середнім — рішення застосунку.
CREATE TABLE bill.usage_daily (
day date NOT NULL,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
devices_total int NOT NULL DEFAULT 0,
devices_billable int NOT NULL DEFAULT 0,
devices_peak int NOT NULL DEFAULT 0,
agents_active int NOT NULL DEFAULT 0,
map_nodes int NOT NULL DEFAULT 0,
maps_total int NOT NULL DEFAULT 0,
ncm_configs int NOT NULL DEFAULT 0,
samples_ingested bigint NOT NULL DEFAULT 0,
storage_bytes bigint NOT NULL DEFAULT 0,
computed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (day, tenant_id)
);
CREATE INDEX usage_daily_tenant_idx ON bill.usage_daily (tenant_id, day DESC);
-- Позиції, відправлені провайдеру як usage records (ідемпотентність)
CREATE TABLE bill.usage_reports (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
subscription_id uuid NOT NULL REFERENCES bill.subscriptions(id) ON DELETE CASCADE,
period daterange NOT NULL,
quantity int NOT NULL,
idempotency_key text NOT NULL UNIQUE,
reported_at timestamptz,
external_id text,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (subscription_id, period)
);
-- ---------------------------------------------------------------------
-- Інвойси (B2B PDF)
-- ---------------------------------------------------------------------
CREATE TYPE bill.invoice_status AS ENUM ('draft','open','paid','void','uncollectible','refunded');
CREATE TABLE bill.invoices (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
subscription_id uuid REFERENCES bill.subscriptions(id) ON DELETE SET NULL,
number text NOT NULL, -- NP-2026-000123
status bill.invoice_status NOT NULL DEFAULT 'draft',
currency char(3) NOT NULL DEFAULT 'USD',
subtotal_cents int NOT NULL DEFAULT 0,
tax_cents int NOT NULL DEFAULT 0,
total_cents int NOT NULL DEFAULT 0,
amount_paid_cents int NOT NULL DEFAULT 0,
period daterange,
-- Реквізити покупця на момент виписки (юр. вимога — не FK, а знімок)
bill_to jsonb NOT NULL DEFAULT '{}'::jsonb,
vat_number text,
pdf_storage_key text,
external_id text, -- in_... / Paddle txn id
issued_at timestamptz,
due_at timestamptz,
paid_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, number)
);
CREATE INDEX invoices_tenant_idx ON bill.invoices (tenant_id, created_at DESC);
CREATE TABLE bill.invoice_lines (
id uuid PRIMARY KEY DEFAULT core.new_id(),
invoice_id uuid NOT NULL REFERENCES bill.invoices(id) ON DELETE CASCADE,
description text NOT NULL,
quantity numeric(12,3) NOT NULL DEFAULT 1,
unit_price_cents int NOT NULL DEFAULT 0,
amount_cents int NOT NULL DEFAULT 0,
period daterange,
meta jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Вебхуки платіжних провайдерів: спершу сирий запис, потім обробка.
CREATE TABLE bill.payment_events (
id uuid PRIMARY KEY DEFAULT core.new_id(),
provider bill.provider NOT NULL,
external_id text NOT NULL, -- evt_...
type text NOT NULL,
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
payload jsonb NOT NULL,
signature_ok boolean NOT NULL DEFAULT false,
processed_at timestamptz,
error text,
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, external_id)
);
CREATE INDEX payment_events_pending_idx ON bill.payment_events (received_at)
WHERE processed_at IS NULL;
-- ---------------------------------------------------------------------
-- Ліцензійні ключі для self-hosted (підпис RSA-4096)
-- ---------------------------------------------------------------------
CREATE TYPE bill.license_status AS ENUM ('issued','active','expired','revoked','suspended');
CREATE TABLE bill.license_keys (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
-- Ключ, який бачить клієнт (NP-XXXX-...), у БД — лише хеш
key_hash bytea NOT NULL UNIQUE,
key_prefix text NOT NULL,
-- Підписаний payload: {install_id, plan, max_devices, max_map_nodes, features, exp}
payload jsonb NOT NULL,
signature bytea NOT NULL, -- RSA-4096 PSS SHA-256 над canonical(payload)
signing_key_id text NOT NULL, -- ротація ключів підпису
status bill.license_status NOT NULL DEFAULT 'issued',
-- Обмеження, продубльовані для швидких SQL-перевірок
max_devices int,
max_map_nodes int,
features text[] NOT NULL DEFAULT '{}',
-- Прив'язка до інсталяції (перший активований install_id фіксується)
bound_install_id uuid,
issued_to text,
issued_at timestamptz NOT NULL DEFAULT now(),
activated_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz,
revoke_reason text
);
CREATE INDEX license_keys_tenant_idx ON bill.license_keys (tenant_id);
CREATE INDEX license_keys_install_idx ON bill.license_keys (bound_install_id);
-- Пінги self-hosted інсталяцій (online-перевірка ліцензії, опційна)
CREATE TABLE bill.license_checkins (
ts timestamptz NOT NULL DEFAULT now(),
license_id uuid NOT NULL,
install_id uuid NOT NULL,
version text,
device_count int,
map_node_count int,
ip inet,
PRIMARY KEY (ts, license_id)
);
SELECT create_hypertable('bill.license_checkins', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('bill.license_checkins', INTERVAL '400 days', if_not_exists => TRUE);
-- ---------------------------------------------------------------------
-- Перевірка лімітів на рівні БД (друга лінія оборони після API)
-- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
cnt int;
BEGIN
SELECT max_devices INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW; -- без ліміту або entitlements ще не створені
END IF;
SELECT count(*) INTO cnt FROM inv.devices
WHERE tenant_id = NEW.tenant_id AND deleted_at IS NULL AND enabled;
IF cnt >= lim THEN
RAISE EXCEPTION 'device limit reached for tenant % (limit %)', NEW.tenant_id, lim
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_devices_limit BEFORE INSERT ON inv.devices
FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit();
CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
cnt int;
BEGIN
SELECT max_map_nodes INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW;
END IF;
SELECT count(*) INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id;
IF cnt >= lim THEN
RAISE EXCEPTION 'map node limit reached (limit %)', lim
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_map_nodes_limit BEFORE INSERT ON topo.map_nodes
FOR EACH ROW EXECUTE FUNCTION bill.assert_map_node_limit();