Netpulse_SasS/server/internal/store/billing.go
byrsapty c8b9d08c0b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 58s
CI / server (push) Successful in 2m35s
CI / agent (push) Successful in 2m1s
Білінг: запит читав старі імена колонок usage_now
Агент свідомо перейменував вихідні колонки функції на devices_used,
maps_used, agents_used, users_used — щоб `devices` поруч із таблицею
inv.devices не давало неоднозначності. Запит у Go лишився на старих
іменах.

Код збирався, локальні тести були зелені (пропускались без
NETPULSE_TEST_DSN), а перше ж звернення до бази падало з
«column "devices" does not exist». Сторінка білінгу не показала б
нічого.

Знайдено пісочницею: установка зі свіжого клону, 63 міграції на чистій
базі, потім тести з живою БД. Це той самий клас, що ловився весь день —
зелена перевірка доводить рівно те, що перевіряє, а тест, який
мовчки пропускається, не перевіряє нічого.

Заодно записано в шапці api_test.go умову, якої той набір вимагає:
база має бути порожньою. Користувачі в продукті глобальні, і на вже
поставленій системі seedUser натрапляє на власника admin.
2026-08-27 21:30:51 +03:00

850 lines
37 KiB
Go
Raw 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.

package store
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Тарифи, стелі й підписка: те, що показують людині, і те, що читає
// перевірка лімітів.
//
// ЩО ТУТ Є ДЖЕРЕЛОМ ПРАВДИ
//
// Рівно одна таблиця — bill.entitlements. Її читають тригери в БД, її
// читає сторінка, з неї беруться числа у відмові «дозволено 15, зараз
// 15». Усе інше (тариф, ліцензійний ключ, майбутня підписка Stripe) —
// це джерела, які в неї ПИШУТЬ, і жодне з них не має права бути
// прочитаним у мить перевірки.
//
// Це не стилістика. Перевірка ліміту виконується на кожній вставці
// хоста, а вставок буває тисяча за прогін автовиявлення; JOIN по
// чотирьох таблицях із розбором JSON у кожній із них там неприпустимий.
// І друге, важливіше: перевірка, яка знає про Stripe, ламається разом зі
// Stripe. Той, хто дописуватиме платіжку, не повинен мати способу
// зачепити відповідь на питання «чи можна завести хост».
//
// ЩО РОБИТЬ ЦЕЙ ФАЙЛ
//
// Читає стан для сторінки й перекладає відмову БД у щось, що можна
// показати людині. Перерахунок стель із ліцензії живе поруч, у
// billing_license.go — там, де й решта ліцензійної логіки.
// ---------------------------------------------------------------------
// Відмова через ліміт
// ---------------------------------------------------------------------
// ЧОМУ ВІДМОВА ЧЕРЕЗ СТЕЛЮ — ТИП, А НЕ РЯДОК
//
// Тому що клієнт має отримати числа, а не фразу. «Вичерпано ліміт
// пристроїв тарифу» — це те, що людина вже й так зрозуміла з того, що
// нічого не створилось; корисне починається з «дозволено 15, зараз 15»,
// бо саме воно каже, що робити далі, і саме воно доводить, що система
// не зламалась, а відмовила свідомо.
//
// Числа приходять із DETAIL відмови (див. bill.deny_limit у 0069) саме
// тому, що вони мають прийти З ТІЄЇ Ж транзакції, яка відмовила.
// Порахувати їх другим запитом означало б показати інше число, ніж те,
// на якому спрацювала перевірка.
// PlanLimitError — стеля тарифу вичерпана.
type PlanLimitError struct {
// Kind — що саме скінчилось: devices | maps | map_nodes | agents | users.
Kind string `json:"limit"`
Plan string `json:"plan"`
Allowed int `json:"allowed"`
Used int `json:"used"`
// message — фраза, яку склала сама БД, українською й з числами.
// Складати її вдруге в Go означало б мати два формулювання однієї
// відмови: одне в логах Postgres, інше на екрані.
message string
}
func (e *PlanLimitError) Error() string {
if e.message != "" {
return e.message
}
return "досягнуто ліміт тарифу"
}
// Is прив'язує тип до наявного вартового ErrPlanLimit.
//
// Щоб код, який уже питає errors.Is(err, ErrPlanLimit) (шлях правки
// мапи, де відмову перекладає mapPgError), продовжував працювати й тоді,
// коли до нього дійде вже типізована помилка.
//
// Чого це НЕ робить, і про це варто сказати вголос: сира помилка бази
// вартовому не відповідає — у її ланцюжку лежить *pgconn.PgError, а не
// цей тип. Тому спільний обробник помилок питає ще й AsPlanLimit
// окремим рядком (httpapi/maps_write.go). Спокуса покластись на одну
// перевірку тут коштувала б 500 на всіх шляхах, крім мап.
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
// AsPlanLimit впізнає відмову через стелю в будь-якій обгортці.
//
// Впізнавання йде за HINT = 'upgrade_plan', а НЕ за текстом помилки.
// Так було в maps_write.go з самого початку, і так правильно: текст
// відмови — це те, що переписують, коли він погано читається. Саме на
// тексті й зламалась попередня перевірка (httpapi/groups.go шукав слово
// «ліміт» у повідомленні, яке 0009 писала англійською), і зламалась
// мовчки — до першого спрацювання стелі ніхто б і не дізнався.
func AsPlanLimit(err error) (*PlanLimitError, bool) {
if err == nil {
return nil, false
}
var already *PlanLimitError
if errors.As(err, &already) {
return already, true
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Hint != "upgrade_plan" {
return nil, false
}
out := &PlanLimitError{message: pgErr.Message}
// Подробиця може не розібратись — наприклад, на інсталяції, де
// 0069 ще не накотилась і тригер лишився з 0009. Це не привід
// втрачати саму відмову: без чисел вона гірша, але вона є, і 402
// має поїхати замість 500 у будь-якому разі.
if pgErr.Detail != "" {
_ = json.Unmarshal([]byte(pgErr.Detail), out)
}
return out, true
}
// ---------------------------------------------------------------------
// Тарифи
// ---------------------------------------------------------------------
// Plan — тариф так, як його бачить сторінка.
type Plan struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
BasePriceCents int `json:"base_price_cents"`
PerDeviceCents int `json:"per_device_cents"`
Currency string `json:"currency"`
BillingPeriod string `json:"billing_period"`
PlanLimits
Features []string `json:"features"`
IsPublic bool `json:"is_public"`
}
// Feature — можливість із каталогу bill.features.
type Feature struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// ListPlans — усі тарифи, разом із непублічними.
//
// Непублічні теж, бо один із них (self_hosted) майже напевно й буде
// поточним: сторінка мусить уміти назвати те, що зараз діє. Ховає їх зі
// СПИСКУ ПРОПОЗИЦІЙ уже інтерфейс — за прапорцем is_public, а не за
// відсутністю рядка.
func (s *Store) ListPlans(ctx context.Context) ([]Plan, error) {
rows, err := s.pool.Query(ctx, `
SELECT key, name, COALESCE(description,''), base_price_cents, per_device_cents,
currency, billing_period,
max_devices, max_maps, max_map_nodes, max_agents, max_users,
metric_retention_days, features, is_public
FROM bill.plans ORDER BY sort_order, key
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Plan{}
for rows.Next() {
var p Plan
if err := rows.Scan(&p.Key, &p.Name, &p.Description, &p.BasePriceCents,
&p.PerDeviceCents, &p.Currency, &p.BillingPeriod,
&p.MaxDevices, &p.MaxMaps, &p.MaxMapNodes, &p.MaxAgents, &p.MaxUsers,
&p.RetentionDays, &p.Features, &p.IsPublic); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListFeatures — каталог можливостей.
func (s *Store) ListFeatures(ctx context.Context) ([]Feature, error) {
rows, err := s.pool.Query(ctx,
`SELECT key, name, COALESCE(description,'') FROM bill.features ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Feature{}
for rows.Next() {
var f Feature
if err := rows.Scan(&f.Key, &f.Name, &f.Description); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------
// Стелі кабінету
// ---------------------------------------------------------------------
// Entitlement — чинні стелі кабінету.
type Entitlement struct {
PlanKey string `json:"plan_key"`
PlanName string `json:"plan_name"`
PlanLimits
Features []string `json:"features"`
Source string `json:"source"`
Reason string `json:"reason"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
GraceUntil *time.Time `json:"grace_until,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// Has — чи входить можливість у стелі кабінету.
func (e *Entitlement) Has(feature string) bool {
for _, f := range e.Features {
if f == feature {
return true
}
}
return false
}
// ЧОМУ СТЕЛІ ЗАВОДЯТЬСЯ ЛІНИВО, А НЕ ТРИГЕРОМ НА core.tenants
//
// 0069 завела рядок кожному кабінету, який існував на мить накочування.
// Кабінети, створені ПІСЛЯ (netpulse-user, майбутня реєстрація),
// лишаються без рядка — і це нормальний стан, а не поломка: стелі там
// немає, тригери пропускають усе, продукт працює.
//
// Але сторінка тарифу в такому кабінеті мусить щось показати, і
// показати вона мусить правду: «стелі не задані». Заводити рядок звідси
// дешевше й чесніше, ніж вішати ще один тригер на створення кабінету:
// тригер спрацював би в мить, коли про тариф ще ніхто не питав, і його
// довелося б тримати в синхроні з переліком колонок ще в одному місці.
// TenantEntitlement читає стелі, заводячи їх за потреби.
func (s *Store) TenantEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
e, err := s.readEntitlement(ctx, tenantID)
if err == nil || !errors.Is(err, ErrNotFound) {
return e, err
}
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO bill.entitlements
(tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents,
max_users, metric_retention_days, features, source, reason)
SELECT $1, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents,
p.max_users, p.metric_retention_days, p.features, 'license_key', 'migration'
FROM bill.plans p WHERE p.key = 'self_hosted'
ON CONFLICT (tenant_id) DO NOTHING
`, tenantID)
return err
}); err != nil {
return nil, err
}
return s.readEntitlement(ctx, tenantID)
}
func (s *Store) readEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
var e Entitlement
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT e.plan_key, COALESCE(p.name, e.plan_key),
e.max_devices, e.max_maps, e.max_map_nodes, e.max_agents, e.max_users,
e.metric_retention_days, e.features, e.source::text, e.reason,
e.valid_until, e.grace_until, e.updated_at
FROM bill.entitlements e
LEFT JOIN bill.plans p ON p.key = e.plan_key
WHERE e.tenant_id = $1
`, tenantID).Scan(&e.PlanKey, &e.PlanName,
&e.MaxDevices, &e.MaxMaps, &e.MaxMapNodes, &e.MaxAgents, &e.MaxUsers,
&e.RetentionDays, &e.Features, &e.Source, &e.Reason,
&e.ValidUntil, &e.GraceUntil, &e.UpdatedAt)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if e.Features == nil {
e.Features = []string{}
}
return &e, nil
}
// TenantUsage — скільки слотів зайнято.
//
// Питається в БД тією самою функцією, яку викликають тригери
// (bill.usage_now), а не власним count(*). Друге визначення «зайнятого
// хоста» неминуче розійшлося б із першим — і розійшлося б саме там, де
// це найдорожче: сторінка казала б «14 з 15», а вставка відмовляла б.
func (s *Store) TenantUsage(ctx context.Context, tenantID string) (Usage, error) {
var u Usage
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
// Суфікс `_used` — не косметика: функція повертає колонки з
// цими іменами саме тому, що `devices` поруч із таблицею
// inv.devices дає неоднозначність (пояснення в 0069). Тут
// імена спершу лишились старими, і це знайшлось лише в
// пісочниці: код збирався, а перший же запит до бази падав
// з «column "devices" does not exist».
`SELECT devices_used, maps_used, agents_used, users_used
FROM bill.usage_now($1)`, tenantID).
Scan(&u.Devices, &u.Maps, &u.Agents, &u.Users)
})
return u, err
}
// SetTenantPlan переводить кабінет на тариф.
//
// Ручна дія власника — те, чим користуються, поки платіжки немає, і те,
// чим користуватимуться після неї для enterprise-домовленостей. Пише в
// bill.entitlements напряму, з source = 'manual': видно, що стелі
// поставила людина, а не ключ і не провайдер.
//
// Стеля нижча за зайняте не забороняється, і це свідомо. Власник має
// право перевести кабінет на менший тариф, маючи 40 хостів при стелі
// 15: наявні 40 лишаються під наглядом (тригер стоїть на появі нового,
// не на існуванні), нові не заводяться, а на сторінці стоїть «40 з 15»
// — тобто рівно та правда, за якою вирішують, що робити далі.
// Заборонити перехід означало б вимагати спершу видалити 25 хостів,
// щоб дізнатись, чи влаштує дешевший тариф.
func (s *Store) SetTenantPlan(ctx context.Context, tenantID, planKey, userID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE bill.entitlements e SET
plan_key = p.key,
max_devices = p.max_devices, max_maps = p.max_maps,
max_map_nodes = p.max_map_nodes, max_agents = p.max_agents,
max_users = p.max_users,
metric_retention_days = p.metric_retention_days,
features = p.features,
source = 'manual', reason = 'plan', license_id = NULL,
valid_until = NULL, grace_until = NULL,
updated_at = now()
FROM bill.plans p
WHERE e.tenant_id = $1 AND p.key = $2
`, tenantID, planKey)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: тариф %q не існує або кабінету ще не заведено стель",
ErrInvalid, planKey)
}
return nil
})
}
// ---------------------------------------------------------------------
// Ліцензія інсталяції
// ---------------------------------------------------------------------
// instanceRow — сирий рядок bill.instance.
type instanceRow struct {
InstallID string
LicenseKey string
Payload []byte
State string
InvalidReason string
ClockMaxSeen time.Time
ClockWarpedAt *time.Time
CheckedAt *time.Time
AppliedAt *time.Time
}
// readInstance читає рядок інсталяції.
//
// Пулом воркера, як і core.storage_config: таблиця рівня інсталяції,
// tenant_id у неї немає за побудовою, і RLS до неї не застосовна.
func (s *Store) readInstance(ctx context.Context) (*instanceRow, error) {
var r instanceRow
var key, reason *string
err := s.bg.QueryRow(ctx, `
SELECT install_id::text, license_key, payload, state, invalid_reason,
clock_max_seen, clock_warped_at, checked_at, applied_at
FROM bill.instance WHERE id
`).Scan(&r.InstallID, &key, &r.Payload, &r.State, &reason,
&r.ClockMaxSeen, &r.ClockWarpedAt, &r.CheckedAt, &r.AppliedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if key != nil {
r.LicenseKey = *key
}
if reason != nil {
r.InvalidReason = *reason
}
return &r, nil
}
// InstallID — ідентифікатор цієї інсталяції.
//
// Потрібен людині: саме його вона надсилає, замовляючи ключ.
func (s *Store) InstallID(ctx context.Context) (string, error) {
r, err := s.readInstance(ctx)
if err != nil {
return "", err
}
return r.InstallID, nil
}
// LicenseStatus — поточний стан без перерахунку.
//
// Дешеве читання для сторінки. Стан перераховує такт (RefreshLicense);
// сторінка показує те, що він порахував, і поруч — коли саме. Рахувати
// на кожному відкритті означало б, що стан залежить від того, чи хтось
// дивиться.
func (s *Store) LicenseStatus(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
r, err := s.readInstance(ctx)
if err != nil {
return LicenseState{}, err
}
st := LicenseState{
State: r.State,
Reason: r.InvalidReason,
InstallID: r.InstallID,
CheckedAt: r.CheckedAt,
AppliedAt: r.AppliedAt,
ClockWarped: r.ClockWarpedAt != nil,
ClockMaxSeen: &r.ClockMaxSeen,
VerifierEmpty: v.Empty(),
}
if len(r.Payload) == 0 {
return st, nil
}
var p LicensePayload
if err := json.Unmarshal(r.Payload, &p); err != nil {
return st, nil
}
live := EvaluateLicense(&p, nowFor(r))
// Стан беремо з перерахованого, а не з колонки: колонка відстає
// рівно на час до наступного такту, і в добу переходу active→grace
// сторінка показувала б учорашню правду.
//
// Виняток — invalid: він означає, що payload не пройшов перевірку
// підпису, і будувати з нього строк не можна взагалі.
if r.State != LicenseInvalid {
live.InstallID = st.InstallID
live.CheckedAt, live.AppliedAt = st.CheckedAt, st.AppliedAt
live.ClockWarped, live.ClockMaxSeen = st.ClockWarped, st.ClockMaxSeen
live.VerifierEmpty = st.VerifierEmpty
return live, nil
}
st.LicenseID, st.IssuedTo, st.Plan = p.LicenseID, p.IssuedTo, p.Plan
return st, nil
}
// nowFor — час, за яким рахувати строк для цього рядка інсталяції.
func nowFor(r *instanceRow) time.Time {
t, _ := effectiveNow(time.Now().UTC(), r.ClockMaxSeen)
return t
}
// ApplyLicense застосовує введений ключ.
//
// Порядок: перевірити підпис → перевірити прив'язку до інсталяції →
// записати → перерахувати стелі. Перший крок, що не вдався, лишає все
// як було: інсталяція з чинним ключем не має втратити його від того,
// що хтось вставив у поле сторонній рядок.
func (s *Store) ApplyLicense(ctx context.Context, v *LicenseVerifier, key, userID string) (LicenseState, error) {
inst, err := s.readInstance(ctx)
if err != nil {
return LicenseState{}, err
}
p, perr := v.ParseLicense(key)
if perr != nil {
return LicenseState{}, perr
}
// Прив'язка. Порожній InstallID у ключі — ключ «на пред'явника»:
// його випустили до розгортання, і забирає його той, хто ввів
// першим. Непорожній мусить збігтися дослівно.
if p.InstallID != "" && p.InstallID != inst.InstallID {
return LicenseState{}, fmt.Errorf("%w: ключ виданий для %s, ця інсталяція — %s",
ErrLicenseInstall, p.InstallID, inst.InstallID)
}
body, err := json.Marshal(p)
if err != nil {
return LicenseState{}, err
}
now := time.Now().UTC()
st := EvaluateLicense(p, now)
if _, err := s.bg.Exec(ctx, `
UPDATE bill.instance SET
license_key = $1, payload = $2::jsonb,
license_id = NULLIF($3,'')::uuid, issued_to = NULLIF($4,''),
state = $5, invalid_reason = NULL,
expires_at = $6, grace_until = $7,
applied_at = now(), applied_by = $8, checked_at = now(),
clock_max_seen = GREATEST(clock_max_seen, now())
WHERE id
`, key, string(body), licenseUUID(p.LicenseID), p.IssuedTo, st.State,
st.ExpiresAt, st.GraceUntil, nullUUID(userID)); err != nil {
return LicenseState{}, err
}
// Слід у bill.license_keys — не дублювання, а відповідь на питання
// «які ключі тут бували». Ключ, який замінили місяць тому, з
// bill.instance зникає безслідно, і з'ясувати, чи був він взагалі,
// стає ніяк. tenant_id лишається порожнім навмисно: ліцензія
// належить інсталяції, а не кабінету (див. 0069).
if err := s.recordLicenseKey(ctx, p, key, inst.InstallID); err != nil {
return LicenseState{}, err
}
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
return LicenseState{}, err
}
return s.LicenseStatus(ctx, v)
}
// ClearLicense знімає ключ.
//
// Не «видаляє ліцензію»: рядок у bill.license_keys лишається, зникає
// лише те, що діє. Кабінети повертаються до self_hosted, тобто до
// стану «стелі не задані», а не до найменшого тарифу — зняття ключа не
// має бути дією, після якої система перестає приймати хости.
func (s *Store) ClearLicense(ctx context.Context, userID string) error {
if _, err := s.bg.Exec(ctx, `
UPDATE bill.instance SET
license_key = NULL, payload = NULL, license_id = NULL, issued_to = NULL,
state = 'unlicensed', invalid_reason = NULL,
expires_at = NULL, grace_until = NULL,
applied_at = now(), applied_by = $1, checked_at = now()
WHERE id
`, nullUUID(userID)); err != nil {
return err
}
return s.applyEntitlements(ctx, nil, LicenseUnlicensed)
}
// ЧОМУ СТАН ЛІЦЕНЗІЇ ПЕРЕРАХОВУЄТЬСЯ ТАКТОМ, А НЕ РАЗ ПРИ СТАРТІ
//
// Бо стан ліцензії міняється БЕЗ ЖОДНОЇ ДІЇ — просто від того, що минув
// час. Інсталяція, яку не перезавантажували чотири місяці (а це норма),
// перейшла б із active в expired і не помітила б цього до наступного
// перезапуску. Тобто попередження «лишилось 5 діб» людина побачила б
// через півроку після того, як вони скінчились.
//
// Такт же робить три речі: рухає монотонний годинник, перечитує підпис
// (ключ міг стати невалідним через ротацію ключів підпису у збірці) і
// переписує стелі, якщо стан змінився.
// RefreshLicense — такт перевірки в рантаймі.
func (s *Store) RefreshLicense(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
inst, err := s.readInstance(ctx)
if err != nil {
return LicenseState{}, err
}
now := time.Now().UTC()
eff, warped := effectiveNow(now, inst.ClockMaxSeen)
if inst.LicenseKey == "" {
if _, err := s.bg.Exec(ctx, `
UPDATE bill.instance SET checked_at = now(),
clock_max_seen = GREATEST(clock_max_seen, now()) WHERE id`); err != nil {
return LicenseState{}, err
}
return s.LicenseStatus(ctx, v)
}
p, perr := v.ParseLicense(inst.LicenseKey)
if perr != nil {
// Ключ був прийнятий раніше, а тепер не перевіряється. Найчастіша
// причина — збірку оновили, і ключ підпису, яким його видали,
// з неї прибрали.
//
// Стан invalid, стелі НЕ чіпаємо. Це принципово: ми не знаємо,
// чи справді ключ підроблений, чи це ми загубили відкритий ключ,
// — і в другому випадку відібрати в клієнта стелі означало б
// покарати його за нашу помилку розгортання. Видно це на
// сторінці, і полагодити має людина, а не тиха дія коду.
if _, err := s.bg.Exec(ctx, `
UPDATE bill.instance SET state = 'invalid', invalid_reason = $1,
checked_at = now(), clock_max_seen = GREATEST(clock_max_seen, now())
WHERE id`, perr.Error()); err != nil {
return LicenseState{}, err
}
return s.LicenseStatus(ctx, v)
}
st := EvaluateLicense(p, eff)
if _, err := s.bg.Exec(ctx, `
UPDATE bill.instance SET
state = $1, invalid_reason = NULL,
expires_at = $2, grace_until = $3,
checked_at = now(),
clock_max_seen = GREATEST(clock_max_seen, now()),
clock_warped_at = CASE WHEN $4 THEN now() ELSE clock_warped_at END
WHERE id
`, st.State, st.ExpiresAt, st.GraceUntil, warped); err != nil {
return LicenseState{}, err
}
// Стелі переписуються щотакту, а не лише на зміні стану.
//
// Дорожче на один UPDATE на кабінет за годину — і рівно це закриває
// найтихішу з поломок: рядок entitlements, зіпсований чужою правкою
// руками в базі, інакше лишався б чинним нескінченно, бо «стан не
// змінився».
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
return LicenseState{}, err
}
return s.LicenseStatus(ctx, v)
}
// recordLicenseKey лишає слід про застосований ключ.
func (s *Store) recordLicenseKey(ctx context.Context, p *LicensePayload, key, installID string) error {
body, err := json.Marshal(p)
if err != nil {
return err
}
prefix := key
if len(prefix) > 16 {
prefix = prefix[:16]
}
// Підпис лягає окремою колонкою, хоч він і є всередині key.
//
// Не заради місця, а заради відповіді на питання, яке ставлять раз
// на кілька років і завжди в поганий день: «яким саме ключем підпису
// це підписано і чи сходиться воно досі». Дістати підпис регулярним
// виразом із текстового поля посеред розслідування — не те, чим
// хочеться займатись; колонка bytea для цього в 0009 і заведена.
// Первинний ключ рядка генерує база, а не lid із payload.
//
// Спокуса взяти lid очевидна — «нехай ідентифікатор ліцензії буде
// ідентифікатором рядка». Але lid пише той, хто випускає ключ, і
// продовження ліцензії з тим самим lid (звичайна практика) уперлось
// би в первинний ключ. Наслідок був би найгіршим із можливих:
// дійсний оплачений ключ не застосовується, а людина бачить
// «внутрішню помилку». Тотожність рядка тримає хеш самого ключа —
// саме він і є тим, що не повторюється.
_, err = s.bg.Exec(ctx, `
INSERT INTO bill.license_keys
(plan_key, key_hash, key_prefix, payload, signature, signing_key_id,
status, max_devices, max_map_nodes, features, bound_install_id, issued_to,
activated_at, expires_at)
VALUES (COALESCE((SELECT key FROM bill.plans WHERE key = $1), 'self_hosted'),
digest($2, 'sha256'), $3, $4::jsonb, $5, $6,
'active', $7, $8, $9, $10::uuid, NULLIF($11,''),
now(), $12)
ON CONFLICT (key_hash) DO UPDATE SET
status = 'active', activated_at = now(),
bound_install_id = EXCLUDED.bound_install_id
`, p.Plan, key, prefix, string(body),
licenseSignatureBytes(key), p.KeyID,
p.MaxDevices, p.MaxMapNodes, featuresOrEmpty(p.Features), installID,
p.IssuedTo, timeOrNil(p.ExpiresAt))
return err
}
// licenseSignatureBytes дістає підпис із уже перевіреного ключа.
//
// Порожній зріз замість помилки: сюди потрапляє лише ключ, який щойно
// пройшов ParseLicense, тобто розкодувався напевно. Але падати на
// збереженні СЛІДУ через несподіванку в розборі не можна — ліцензія при
// цьому вже застосована, і відкотити її частково гірше, ніж лишити
// колонку порожньою.
func licenseSignatureBytes(key string) []byte {
parts := strings.Split(strings.TrimSpace(key), ".")
if len(parts) != 3 {
return []byte{}
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return []byte{}
}
return sig
}
// licenseUUID пропускає далі лише те, що справді схоже на uuid.
//
// lid у payload пише той, хто випускає ключ, і поле це вільне. Рядок,
// який не є uuid, доїхав би до `::uuid` у запиті й перетворився б на
// 500 «внутрішня помилка» — тобто дійсний за підписом ключ не
// застосувався б через косметичне поле. Порожній рядок означає «нехай
// база згенерує свій», і це нормальний стан.
func licenseUUID(v string) string {
if len(v) != 36 {
return ""
}
for i, c := range v {
switch i {
case 8, 13, 18, 23:
if c != '-' {
return ""
}
default:
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
if !isHex {
return ""
}
}
}
return v
}
func featuresOrEmpty(f []string) []string {
if f == nil {
return []string{}
}
return f
}
// timeOrNil — вказівник, а не any з nil усередині.
//
// Порожній інтерфейс із nil доїжджає до драйвера як «значення
// невідомого типу», і колонка timestamptz на нього реагує по-різному
// залежно від версії. Типізований nil-вказівник однозначний завжди.
func timeOrNil(unix int64) *time.Time {
if unix == 0 {
return nil
}
t := time.Unix(unix, 0).UTC()
return &t
}
// applyEntitlements переписує стелі всім кабінетам, які живуть із
// ліцензії.
//
// САМЕ «ЯКІ ЖИВУТЬ ІЗ ЛІЦЕНЗІЇ» — це та межа, за яку не протікає
// платіжка. Кабінет, стелі якого прийшли з підписки (source = 'stripe')
// або поставлені руками (source = 'manual'), ключ інсталяції не чіпає.
// Без цієї умови перший же ключ, застосований на спільному хостингу,
// переписав би чужі оплачені стелі своїми.
func (s *Store) applyEntitlements(ctx context.Context, p *LicensePayload, state string) error {
planKey := "self_hosted"
if p != nil && p.Plan != "" {
planKey = p.Plan
}
base, features, err := s.planLimits(ctx, planKey)
if err != nil {
return err
}
limits := LimitsFromPayload(base, p)
features = FeaturesFromPayload(features, p)
reason := "license"
switch state {
case LicenseUnlicensed, LicenseInvalid:
reason = "migration"
case LicenseExpired:
reason = "license_expired"
}
ids, err := s.licensedTenants(ctx)
if err != nil {
return err
}
for _, id := range ids {
// Стелі рахуються ОКРЕМО для кожного кабінету, бо «не нижче
// зайнятого» — це про конкретні числа конкретного кабінету, а
// не про інсталяцію загалом.
used, err := s.TenantUsage(ctx, id)
if err != nil {
return err
}
eff := EffectiveLimits(limits, state, used)
var validUntil, graceUntil *time.Time
if p != nil {
v, g := p.Expires(), p.GraceUntil()
validUntil, graceUntil = &v, &g
}
if err := s.InTenantTx(ctx, id, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
UPDATE bill.entitlements SET
plan_key = $2,
max_devices = $3, max_maps = $4, max_map_nodes = $5,
max_agents = $6, max_users = $7,
metric_retention_days = $8, features = $9,
source = 'license_key', reason = $10,
valid_until = $11, grace_until = $12,
updated_at = now()
WHERE tenant_id = $1 AND source = 'license_key'
`, id, planKey, eff.MaxDevices, eff.MaxMaps, eff.MaxMapNodes,
eff.MaxAgents, eff.MaxUsers, eff.RetentionDays, features,
reason, validUntil, graceUntil)
return err
}); err != nil {
return err
}
}
return nil
}
// planLimits — стелі й фічі тарифу.
func (s *Store) planLimits(ctx context.Context, key string) (PlanLimits, []string, error) {
var l PlanLimits
var f []string
err := s.bg.QueryRow(ctx, `
SELECT max_devices, max_maps, max_map_nodes, max_agents, max_users,
metric_retention_days, features
FROM bill.plans WHERE key = $1
`, key).Scan(&l.MaxDevices, &l.MaxMaps, &l.MaxMapNodes, &l.MaxAgents,
&l.MaxUsers, &l.RetentionDays, &f)
if errors.Is(err, pgx.ErrNoRows) {
// Ключ називає тариф, якого в цій збірці немає. Не помилка
// застосування: ключ дійсний, підпис сходиться, просто план
// новіший за інсталяцію. Стелі беремо з самого ключа, а базою
// стає «без обмежень» — бо клієнт заплатив, і відмовляти йому
// через незнайоме слово в полі plan ми не маємо права.
return PlanLimits{RetentionDays: 400}, []string{}, nil
}
if err != nil {
return l, nil, err
}
if f == nil {
f = []string{}
}
return l, f, nil
}
// licensedTenants — кабінети, стелі яких задає ліцензія інсталяції.
func (s *Store) licensedTenants(ctx context.Context) ([]string, error) {
rows, err := s.bg.Query(ctx, `
SELECT e.tenant_id::text
FROM bill.entitlements e
JOIN core.tenants t ON t.id = e.tenant_id AND t.deleted_at IS NULL
WHERE e.source = 'license_key'
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}