billing.1isp.ru

1isp-billing

Собственная биллинговая система с IPAM-модулем для хостинг-провайдера. Замена связки ISPsystem BillManager 6 + IPmanager 5. Go + PostgreSQL 17. Активно разрабатывается с мая 2026 (Phase 2 — workers + бизнес-эндпоинты).

Зачем

Существующий стек ISPsystem стоит дорого, имеет закрытую базу, ограниченный API и примитивный IPAM (плоские пулы, без VRF-aware). Свой биллинг даёт:

Архитектура

                       +---------------------+
   public users  -->   |  web  (Vue/Svelte)  |  -+
                       +---------------------+   |
                                                 |  HTTPS
   admin users   -->   +---------------------+   |
                       |  admin  (Python)    |  -+
                       +---------------------+   |
                                                 |
                       +-------------------------v---------+
                       |   api  (Go, chi+sqlc+pgx)         |
                       |  - billing core (ledger)          |
                       |  - ipam                           |
                       |  - provisioning saga executor     |
                       |  - outbox publisher               |
                       |  - billing cron (renewals)        |
                       |  - reconciler (drift detector)    |
                       |  - auth                           |
                       +------+--------------+-------------+
                              |              |
                       +------v-----+   +----v------------+
                       | PG 17      |   |  Provisioning   |
                       | + ledger   |   |  adapters       |
                       | + outbox   |   |  - Proxmox      |
                       | + saga job |   |  - cPanel       |
                       +------------+   |  - Bare-metal   |
                                        |  - fake (tests) |
                                        +-----------------+

Один Go binary 1ispd, режим выбирается флагом --mode=api|worker|reconciler|all:

PG17 — единственный store. Очереди (provisioning saga, event outbox, billing cron) — embedded poller'ы поверх наших же таблиц через SELECT FOR UPDATE SKIP LOCKED. Никакого river / Redis / Kafka на текущем масштабе.

Стек

СлойТехнологияЗачем
api/Go 1.25, chi v5, pgx v5, sqlc, slog (stdlib)Single binary, типобезопасные SQL, минимум зависимостей
admin/Python, FastAPI или Django (TBD)Богатый UI, async где имеет смысл
web/TBD (Vue / Svelte / HTMX)Кабинет клиента и публичные страницы
БДPostgreSQL 17Source of truth для всего, embedded queue, advisory_lock
EdgenginxTLS termination, статика, путь к ISPmanager-совместимым vhosts
ПродVPS + systemdБез Docker и Kubernetes
Migrationsgolang-migrate, embed.FSForward-only, immutable artifact
Teststestcontainers-go + PG17-alpineИнтеграция против реальной БД, не моки
CIGitHub Actions (lint / unit / integration)Готово; включается когда репо появится на remote

Текущий статус

PhaseЧтоСтатус
0Архитектура, скелет схемы, ADR 0001-0007done
0.5Design review, ADR 0008 ledger / 0009 documents / 0010 saga, 5 P0 schema fixesdone
1Go scaffold: chi + pgx + slog, healthz, graceful shutdown, golang-migrate с embed.FSdone
1.5docker-compose для local PG, GitHub Actions CI (lint / unit / integration), ADR 0011 worker modedone
2.1Базовые worker primitives (Backoff, WorkerID, config knobs)done
2.2Provisioning saga executor (create kind end-to-end), Adapter interface, fake adapter, 4 integration тестаdone
2.3aEvent outbox publisher (log sink); fan-out saga + outbox в --mode=worker; 3 integration тестаdone
2.3bSuspend / resume / terminate saga kinds, adapter.ErrPermanent sentinel для non-retriable классификации; 4 lifecycle тестаdone
2.4Recurring billing cron + minimal double-entry ledger posting (pg_try_advisory_lock leader election); 3 cron теста. --mode=worker = 3 goroutine: saga + outbox + billing.done
2.5Reconciler drift detector (--mode=reconciler), outbox dead-letter, business CRUD endpoints (organizations / clients / tariffs / services)next
3+Реальные адаптеры (Proxmox, cPanel), admin UI, web cabinet, ОФД 54-ФЗ, ЭДО (Diadoc/SBIS/Kontur)planned

Тестовая база: 18 integration-тестов через testcontainers-go + PG17 — все зелёные, ~87 секунд на полный прогон.

Структура кода

api/
├── cmd/1ispd/main.go                  — entry, --mode switch, signal handling
├── internal/
│   ├── adapter/                       — Adapter interface (ADR 0004) + fake impl
│   ├── billing/                       — chart-of-accounts + RenewService helper
│   ├── config/                        — envconfig + validation (20+ knobs)
│   ├── db/
│   │   ├── pool.go, migrate.go        — pgxpool + embedded migrations
│   │   ├── migrations/0001..0004.sql  — initial + ledger + documents + saga
│   │   └── queries/*.sql              — sqlc input (Phase 2.5+)
│   ├── httpapi/                       — chi server, healthz, error helpers
│   ├── log/                           — slog JSON setup
│   ├── version/                       — build/commit/date через ldflags
│   └── worker/
│       ├── backoff.go, workerid.go    — общие primitives (Phase 2.1)
│       ├── saga/                      — provisioning saga executor (Phase 2.2-2.3b)
│       ├── outbox/                    — event_outbox publisher (Phase 2.3a)
│       └── billing/                   — recurring billing cron (Phase 2.4)
└── tests/integration/                 — 18 testcontainers тестов

ADR-карта

Все архитектурные решения зафиксированы как Architecture Decision Records в репозитории docs/adr/:

#РешениеStatus
0001Документировать решения как ADR (формат)accepted
0002Стек: Go (chi+sqlc+pgx) + Python admin + PG17accepted
0003Multi-tenancy: row-level через organization_idaccepted
0004Provisioning Adapter interface (Create/Suspend/Resume/Terminate/Status + idempotencyKey)accepted
0005Payment Gateway abstraction (MVP: manual + Yookassa + crypto)accepted
0006Money: numeric(20,4) в PG, shopspring/decimal в Go (modified by 0008)accepted
0007API contract: REST/JSON + OpenAPI 3.1, не gRPC для MVPaccepted
0008Double-entry ledger + chart of accounts + FX ratesaccepted (supersedes 0006 §balance_cents)
0009РФ document model: parent document + 5 child tables + next_document_number() с advisory_lockproposed
0010Provisioning saga: provisioning_job state machine + per-step idempotency + reconciler (refined by 0011)accepted
0011Worker mode: embedded poller через SELECT FOR UPDATE SKIP LOCKED (rejected river/pgmq); 4 goroutine groupsproposed

Будущие ADR (queued): 0012 OFD 54-ФЗ, 0013 ЭДО, 0014 УКЭП, 0015 tickets/support, 0016 dunning, 0017 VLAN/switch port, 0018 security hardening Phase 1.

Ключевые design-решения

Деньги — double-entry ledger

Каждая финансовая операция (issue invoice, receive payment, refund, FX) пишет минимум 2 строки в append-only ledger_entry с балансирующими debit/credit. Баланс клиента — производное значение (materialized view), а не отдельное поле. Multi-currency: один client → N балансов, по одному на валюту. Phase 2.4 шипит первый production use-case: billing cron на каждый renewal создаёт invoice + invoice_line + 2 ledger entries (debit customer_receivable_RUB, credit revenue_RUB) одной транзакцией. Аккаунты создаются lazy при первом обращении. См. ADR 0008.

Документы — РФ-совместимая иерархия

Родительская таблица document + child-таблицы (document_invoice, document_act, document_vat_invoice, document_contract, document_credit_note). Нумерация через document_series(organization_id, kind, year) + PL/pgSQL функция с pg_advisory_xact_lock — race-free, без gap'ов в пределах транзакции. tax_regime у client и organization определяет комплект документов (ОСНО / УСН / АУСН / ПСН). Phase 2 пока использует legacy invoice таблицу из initial-schema; миграция на document-модель — Phase 2.x.4. См. ADR 0009.

Provisioning — saga с явными шагами

Каждый provision (create / suspend / resume / terminate) разбит на именованные шаги, каждый идемпотентен по ключу <job.idempotency_key>:<step>. State хранится в provisioning_job; worker pull'ает row через FOR UPDATE SKIP LOCKED, выполняет step, advance state. Crash recovery через TTL-based lease (5 минут). Permanent errors через errors.Is(err, adapter.ErrPermanent) — сразу state='failed' без max_attempts retries. См. ADR 0010 и Phase 2.2/2.3b commits.

Очередь — embedded, не river

Наши доменные таблицы (provisioning_job, event_outbox) уже несут state, locked_by, locked_until, attempts. Добавление river дало бы второй источник истины для «что запускать дальше» — две миграции, два индекса, два failure mode. На нашем масштабе (десятки jobs/час) SKIP LOCKED справляется. Migration path к river зарезервирован: интерфейс Dispatcher допускает swap transport'а. См. ADR 0011.

Recurring billing — leader-locked cron

Многоинстансная безопасность через pg_try_advisory_lock(0x42494C4C00000001) session-scoped на каждый tick. Только один из N запущенных --mode=worker процессов делает renewal'ы за минуту; остальные silent skip. Per-service TX'ы внутри tick'а — failure одного service не откатывает остальные. Идемпотентность guaranteed внешним lock'ом; Phase 2.5 добавит UNIQUE (service_id, period_start) на invoice_line как defence-in-depth. См. ADR 0011 §"Cron idempotency".

Принципы

  1. Single source of truth = PG. Никакого in-memory state, который не восстанавливается из БД.
  2. Идемпотентность provisioning'а. Повторный вызов с тем же ключом не создаёт дубликатов.
  3. Audit log на каждое финансовое действие — append-only audit_event.
  4. Money = numeric(20,4) в PG и decimal.Decimal в коде. Никогда float.
  5. Multi-tenancy через organization_id в каждой бизнес-таблице с дня 1.
  6. Migrations only forward. Без DROP TABLE в проде, откаты — через compensating migrations.
  7. Tests против реального PG, не моков. testcontainers-go + PG17-alpine.
  8. Single source for time/space. Все timestamps — timestamptz UTC, все ID — uuid (gen_random_uuid()), все money — numeric(20,4) + явная валюта.

Документация в репозитории

Live deploy

Эта страница хостится на ru1.irbr.ru (92.63.192.50):

Update flow: sudo rsync -a --delete --exclude='irbr/' --exclude='README.md' web/ /var/www/1isp/data/www/billing.1isp.ru/