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>
290 lines
13 KiB
PL/PgSQL
290 lines
13 KiB
PL/PgSQL
-- =====================================================================
|
||
-- 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();
|