Netpulse_SasS/server/internal/httpapi/billing.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

243 lines
11 KiB
Go
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.

package httpapi
import (
"errors"
"net/http"
"github.com/netpulse/netpulse/server/internal/store"
)
// Тариф, стелі й ліцензія.
//
// ПРО ПРАВА
//
// Дивитись — billing:read. Це право інженера, а не бухгалтера, і межа
// проведена не там, де здається природним. Питання «чому не заводиться
// шістнадцятий хост» ставить той, хто заводить хости, і відповідь на
// нього мусить бути в нього перед очима ДО того, як він упреться. Тому
// сторінка показує стелю разом із використаним, а не рахунок.
//
// Міняти — billing:manage. Воно є лише у власника: 0010 навмисно не
// дала його навіть адміну. Це стосується й ліцензійного ключа —
// застосування ключа міняє стелі всієї інсталяції, тобто це та сама дія
// за наслідками, що й зміна тарифу.
//
// Обидва права були заведені 0010 і обидва досі значились у
// store/roles.go серед dormantPerms: ключ у базі є, коду, який його
// питає, немає. Цей файл — той код.
//
// ЧОГО ТУТ НЕМАЄ
//
// Вебхуків платіжки, інвойсів і черги usage-звітів. Не «поки що»: між
// ними й цим файлом навмисно нема зв'язку. Усе, що читає перевірка
// лімітів, — bill.entitlements; хто її заповнив, видно в колонці source
// й нікого більше не обходить. Тому платіжку можна буде дописати
// окремим файлом, не торкаючись ані цих обробників, ані тригерів у БД.
// handleBillingOverview — стан тарифу цілком.
//
// Одна відповідь на всю сторінку, а не чотири ручки, з тієї ж причини,
// що й у черг: стелі, використане й стан ліцензії — це одне питання
// («на чому я зараз і скільки лишилось»), і зібрана з трьох запитів у
// різні секунди відповідь суперечила б сама собі — «14 з 15» поруч із
// «15 з 15» на сусідній картці.
func (s *Server) handleBillingOverview(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "billing:read") {
return
}
ent, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "стелі тарифу", err)
return
}
usage, err := s.store.TenantUsage(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "використання тарифу", err)
return
}
plans, err := s.store.ListPlans(r.Context())
if err != nil {
s.writeStoreError(w, "перелік тарифів", err)
return
}
features, err := s.store.ListFeatures(r.Context())
if err != nil {
s.writeStoreError(w, "каталог можливостей", err)
return
}
// Стан ліцензії їде разом зі стелями, хоч він і рівня інсталяції.
// Розділити їх означало б показати «дозволено 15 хостів» без
// причини, чому саме 15, — а причина тут майже завжди в ліцензії.
lic, err := s.store.LicenseStatus(r.Context(), s.license)
if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, "стан ліцензії", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"entitlement": ent,
"usage": usage,
"plans": plans,
"features": features,
"license": lic,
// Чи вільно цій людині міняти тариф. Клієнт вивів би це й сам зі
// свого переліку прав, але тоді правило жило б у двох місцях і
// розійшлося б на першій же зміні. Те саме рішення, що на
// сторінці сховища.
"can_manage": p.Can("billing:manage"),
})
}
// handleSetBillingPlan переводить кабінет на тариф.
func (s *Server) handleSetBillingPlan(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "billing:manage") {
return
}
var in struct {
Plan string `json:"plan"`
}
if !decodeBody(w, r, &in) {
return
}
// Знімок ДО зміни: у журнал має поїхати «з free на pro», а не
// «змінили тариф». Читаємо до, бо після цієї відповіді вже немає.
before, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "стелі тарифу", err)
return
}
if err := s.store.SetTenantPlan(r.Context(), p.TenantID, in.Plan, p.UserID); err != nil {
s.writeStoreError(w, "зміна тарифу", err)
return
}
s.auditBilling(r, p, store.AuditActionBillingPlan, store.AuditObjectBillingPlan,
map[string]any{"from": before.PlanKey, "to": in.Plan})
s.handleBillingOverview(w, r, p)
}
// handleGetLicense — стан ліцензії й ідентифікатор інсталяції.
//
// Окрема ручка попри те, що те саме їде в загальній відповіді:
// install_id потрібен у мить замовлення ключа, а туди приходять із
// порожньої інсталяції, де перелік тарифів ще нічого не означає.
func (s *Server) handleGetLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "billing:read") {
return
}
st, err := s.store.LicenseStatus(r.Context(), s.license)
if err != nil {
s.writeStoreError(w, "стан ліцензії", err)
return
}
writeJSON(w, http.StatusOK, st)
}
// handleApplyLicense застосовує введений ключ.
//
// Відмова тут — 400 із ПРИЧИНОЮ, а не загальне «ключ недійсний».
// Причин рівно три, вони не взаємозамінні, і кожна веде до різних дій
// людини: формат (переплутали поле, обрізали при копіюванні), підпис
// (ключ не наш або збірка не має відкритого ключа), інсталяція (ключ
// правильний, але від сусіднього стенду). Звести їх до одного рядка
// означало б перетворити кожне звернення в підтримку на вгадування.
func (s *Server) handleApplyLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "billing:manage") {
return
}
var in struct {
Key string `json:"key"`
}
if !decodeBody(w, r, &in) {
return
}
st, err := s.store.ApplyLicense(r.Context(), s.license, in.Key, p.UserID)
switch {
case errors.Is(err, store.ErrNoVerifier):
// 501, а не 400: справа не в тому, що прислали, а в тому, що ця
// збірка ліцензії перевіряти не вміє. Просити людину виправити
// ключ у такій ситуації означало б послати її по колу.
writeError(w, http.StatusNotImplemented, "no_verifier",
"ця збірка не має відкритого ключа для перевірки ліцензій — "+
"задайте NETPULSE_LICENSE_PUBKEY")
return
case errors.Is(err, store.ErrLicenseFormat):
writeError(w, http.StatusBadRequest, "license_format",
"ключ не розібрався: "+err.Error())
return
case errors.Is(err, store.ErrLicenseSignature):
writeError(w, http.StatusBadRequest, "license_signature",
"підпис ключа не сходиться: "+err.Error())
return
case errors.Is(err, store.ErrLicenseInstall):
writeError(w, http.StatusBadRequest, "license_install", err.Error())
return
case err != nil:
s.writeStoreError(w, "застосування ліцензії", err)
return
}
// У журнал іде ідентифікатор ліцензії й кому вона видана, але НЕ сам
// ключ. Ключ — це секрет: той, хто його бачить, може підняти з ним
// іншу інсталяцію. Журнал аудиту читає ширше коло людей, ніж те, що
// має право керувати тарифом, і секретам там не місце — те саме
// правило, що для паролів дзеркала Git.
s.auditBilling(r, p, store.AuditActionLicenseApply, store.AuditObjectLicense,
map[string]any{
"license_id": st.LicenseID,
"issued_to": st.IssuedTo,
"plan": st.Plan,
"state": st.State,
"expires_at": st.ExpiresAt,
})
writeJSON(w, http.StatusOK, st)
}
// handleClearLicense знімає ключ із інсталяції.
func (s *Server) handleClearLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "billing:manage") {
return
}
before, _ := s.store.LicenseStatus(r.Context(), s.license)
if err := s.store.ClearLicense(r.Context(), p.UserID); err != nil {
s.writeStoreError(w, "зняття ліцензії", err)
return
}
s.auditBilling(r, p, store.AuditActionLicenseClear, store.AuditObjectLicense,
map[string]any{"license_id": before.LicenseID, "issued_to": before.IssuedTo})
st, err := s.store.LicenseStatus(r.Context(), s.license)
if err != nil {
s.writeStoreError(w, "стан ліцензії", err)
return
}
writeJSON(w, http.StatusOK, st)
}
// auditBilling — спільний хвіст трьох обробників.
//
// Аудит пишеться ПІСЛЯ успішної дії й не скасовує її при збої: тариф
// уже змінено, і мовчазне «не вдалось» тут гірше за рядок, якого немає
// в журналі. Той самий порядок, що й у строків зберігання.
func (s *Server) auditBilling(r *http.Request, p *Principal,
action, object string, meta map[string]any) {
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: action,
ObjectType: object,
Meta: meta,
}); err != nil {
s.log.Error("аудит білінгу", "дія", action, "err", err)
}
}