Skip to content

Платежи

Платежи — опциональный модуль: без настроенных провайдеров панель полностью функциональна, подписки выдаются админом вручную. Конкретный агрегатор не зашит в код: backend работает с интерфейсом, каждый агрегатор — адаптер. Включённых провайдеров может быть несколько одновременно — клиент выбирает способ оплаты в чекауте.

type PaymentProvider interface {
CreateCheckout(ctx, req CheckoutRequest) (url string, externalID string, err error)
ParseWebhook(payload []byte, headers http.Header) (PaymentEvent, error)
}
type PaymentEvent struct {
ExternalID string
Status PaymentStatus // pending | paid | failed | refunded
Amount decimal.Decimal
Currency string
}

Всё специфичное для агрегатора — формат чекаута, схема вебхука, проверка подписи (HMAC-заголовок, allowlist IP) — внутри адаптера. Остальной backend видит только PaymentEvent.

sequenceDiagram
    participant C as Клиент в кабинете
    participant BE as Backend
    participant P as Агрегатор

    C->>BE: POST /portal/v1/checkout {plan_id, provider_id} + Idempotency-Key
    BE->>P: CreateCheckout
    BE->>BE: payments: pending
    BE-->>C: checkout URL
    C->>P: оплата
    P->>BE: POST /webhooks/payments/{provider}
    BE->>BE: адаптер → PaymentEvent → транзакция активации
    BE-->>P: 200

Активация (при status = paid, provider-agnostic, одна транзакция):

  1. payments.status → paid.
  2. Подписка: указана в чекауте → продление expires_at += plan.period; не указана → создание новой из плана (снапшот условий, Клиенты и подписки).
  3. suspended/expiredactive, если причина устранена; инкремент ревизий затронутых нод.
  4. Запись в audit_log.

Два независимых уровня:

  • Чекаут: Idempotency-Key от клиента — повторный запрос (двойной клик, ретрай сети) возвращает тот же checkout URL, не создавая второй payments.
  • Вебхуки: UNIQUE (provider_id, external_id) + проверка перехода статуса. Агрегаторы ретраят вебхуки; повторное событие paid по уже обработанному платежу подтверждается (200) без повторной активации.

Вебхук с неизвестным external_id логируется и подтверждается — это события вне нашего флоу (созданные в дашборде агрегатора).

refunded фиксируется в payments; автоматического отзыва подписки нет — решение (отключить, сократить срок) принимает админ. Автоматика здесь опаснее ручной обработки: сценарии возвратов разнородны (фрод, ошибка, goodwill).