Содержание
- Почему биллинг — самая сложная часть SaaS
- Доменная модель: сущности и связи
- State machine подписки
- Архитектура тарифов: feature flags, seats, usage
- Интеграция с платёжным шлюзом: абстракция + реализации
- Webhook: идемпотентность, ретраи, порядок событий
- Генерация инвойсов: пропорция, кредиты, НДС, 54-ФЗ
- Dunning: что происходит, когда платёж не прошёл
- Схема базы данных (PostgreSQL)
- Тестирование биллинга
- Metered billing: трекинг потребления и агрегация
- Типичные ошибки
- FAQ
Почему биллинг — самая сложная часть SaaS
Три SaaS-платформы. Три разных клиента. Каждый раз мы в Новаком думали: «Ну биллинг — это же просто. Принимаешь деньги, включаешь доступ.» Каждый раз горели.
Первый проект. HR-платформа. Три тарифа, месячная подписка. Казалось бы — вообще тривиально. Через два месяца после запуска клиент просит: «Добавьте возможность апгрейда посередине периода. И верните пользователю разницу. И пересчитайте НДС. И отправьте корректирующий чек.» Четыре предложения. Две недели переписывания.
Второй проект. Маркетплейс B2B. Usage-based billing, оплата за количество API-вызовов. Звучит просто: считай запросы, выставляй счёт раз в месяц. А потом обнаруживаешь, что у тебя таймзоны (клиент в Новосибирске, сервер в Москве, биллинговый период — по UTC), что месяцы разной длины, что usage-данные приходят с задержкой, и что клиент оспаривает 40% инвойсов, потому что его дашборд показывает другие числа.
Третий проект. EdTech. Seat-based модель. Вроде понятно: 5 мест — 5 000 рублей. 10 мест — 10 000. Но: один пользователь добавился 15-го числа, другой удалился 22-го. Считать полный месяц? Пропорционально? А если удалили и тут же добавили обратно — это один seat или два?
Проблема биллинга — не в платежах. Подключить ЮKassa или Stripe — дело двух дней. Проблема в state machine. Подписка — это не статус «активна/неактивна». Это конечный автомат с десятком состояний, переходами по таймерам и внешним событиям, гонками между webhook и пользовательскими действиями, edge case-ами на стыке месяцев и валют.
Эта статья — результат всех трёх проектов. Архитектура биллинга на Spring Boot 3, которая покрывает 80% SaaS-сценариев. Код на Kotlin. PostgreSQL. Без привязки к конкретному платёжному шлюзу. Имена клиентов обезличены по NDA.
Доменная модель: сущности и связи
Первая ошибка, которую мы видим на аудитах: биллинг моделируют как одну таблицу subscriptions с двадцатью nullable-колонками. Начните с правильных сущностей.
Граф зависимостей
Tenant ──1:N──► Subscription ──N:1──► Plan
│ │
│ ├── PlanFeature (N:M)
│ └── PlanTier
│
├──1:N──► Invoice ──1:N──► InvoiceLineItem
│ │
│ └──1:N──► Payment
│
└──1:N──► UsageRecord
JPA-сущности (ключевые)
@Entity
@Table(name = "plans")
class Plan(
@Id
val id: UUID = UUID.randomUUID(),
val name: String, // "Starter", "Business", "Enterprise"
val slug: String, // "starter", "business", "enterprise"
@Enumerated(EnumType.STRING)
val billingModel: BillingModel, // FLAT, PER_SEAT, USAGE_BASED, HYBRID
val basePrice: BigDecimal, // Базовая цена (без usage)
val currency: String = "RUB",
@Enumerated(EnumType.STRING)
val billingInterval: BillingInterval, // MONTHLY, QUARTERLY, YEARLY
val trialDays: Int = 14,
val active: Boolean = true,
@OneToMany(mappedBy = "plan", cascade = [CascadeType.ALL])
val features: MutableList<PlanFeature> = mutableListOf(),
val createdAt: Instant = Instant.now()
)
enum class BillingModel { FLAT, PER_SEAT, USAGE_BASED, HYBRID }
enum class BillingInterval { MONTHLY, QUARTERLY, YEARLY }
@Entity
@Table(name = "subscriptions")
class Subscription(
@Id
val id: UUID = UUID.randomUUID(),
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id")
val tenant: Tenant,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id")
var plan: Plan,
@Enumerated(EnumType.STRING)
var status: SubscriptionStatus = SubscriptionStatus.TRIALING,
var currentPeriodStart: Instant,
var currentPeriodEnd: Instant,
var trialEnd: Instant? = null,
var canceledAt: Instant? = null,
var cancelReason: String? = null,
var seats: Int = 1, // для PER_SEAT модели
@Column(name = "payment_method_id")
var paymentMethodId: String? = null, // ID сохранённого метода в шлюзе
@OneToMany(mappedBy = "subscription", cascade = [CascadeType.ALL])
val invoices: MutableList<Invoice> = mutableListOf(),
val createdAt: Instant = Instant.now(),
var updatedAt: Instant = Instant.now()
)
enum class SubscriptionStatus {
TRIALING, // trial-период, доступ есть, платежей нет
ACTIVE, // оплачена, работает
PAST_DUE, // платёж не прошёл, grace period
CANCELED, // отменена пользователем (доступ до конца периода)
EXPIRED, // период закончился, доступ закрыт
PAUSED // приостановлена (если поддерживаете)
}
Обратите внимание: BigDecimal для денег. Никогда Double. Никогда Float. Мы видели проект, где стоимость подписки хранилась как Double. На 10 000 транзакций накапливалась ошибка округления в 17 рублей. Мелочь? Попробуйте объяснить это бухгалтерии.
Структуру мультитенантности здесь упрощаем до tenant_id — подробности в отдельной статье.
State machine подписки
Это ядро биллинга. Если state machine неправильная — всё остальное рассыпается.
Диаграмма переходов
┌────────────────────┐
│ TRIALING │
│ (доступ есть, │
│ платежей нет) │
└────────┬───────────┘
│
trial_end + payment_ok
│
┌──────────────▼───────────────┐
┌───────────│ ACTIVE │◄──── payment_ok
│ │ (оплачена, работает) │ │
│ └──┬──────────┬─────────────┬───┘ │
│ │ │ │ │
user_cancel payment_fail period_end plan_change │
│ │ (no renew) │ │
│ ▼ │ │ │
│ ┌─────────────┐ │ (остаётся ACTIVE, │
│ │ PAST_DUE │ │ новый plan_id) │
│ │ (grace 7d) │─────┼──────────────────────────►──┘
│ └──┬──────────┘ │ retry_success
│ │ │
│ grace_expired │
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐
│ CANCELED │ │ EXPIRED │
│ (доступ до │───►│ (доступ │
│ конца периода)│ │ закрыт) │
└────────────────┘ └──────────────┘
Реализация на Spring
Мы НЕ используем Spring State Machine для этого. Оверинжиниринг. State machine подписки — это 6 состояний и 8 переходов. Обычный when + доменные правила:
@Service
class SubscriptionStateMachine(
private val subscriptionRepository: SubscriptionRepository,
private val invoiceService: InvoiceService,
private val notificationService: NotificationService,
private val clock: Clock // инжектим для тестов
) {
@Transactional
fun transition(subscription: Subscription, event: BillingEvent): Subscription {
val oldStatus = subscription.status
val newStatus = resolveTransition(subscription, event)
if (oldStatus == newStatus) {
log.warn("No-op transition: {} + {} for sub {}", oldStatus, event, subscription.id)
return subscription
}
subscription.status = newStatus
subscription.updatedAt = clock.instant()
when (newStatus) {
ACTIVE -> onActivated(subscription, event)
PAST_DUE -> onPastDue(subscription)
CANCELED -> onCanceled(subscription)
EXPIRED -> onExpired(subscription)
else -> {}
}
return subscriptionRepository.save(subscription)
}
private fun resolveTransition(sub: Subscription, event: BillingEvent): SubscriptionStatus {
return when (sub.status) {
TRIALING -> when (event) {
is PaymentSucceeded -> ACTIVE
is TrialExpired -> if (sub.paymentMethodId != null) ACTIVE else EXPIRED
is UserCanceled -> CANCELED
else -> sub.status
}
ACTIVE -> when (event) {
is PaymentFailed -> PAST_DUE
is UserCanceled -> CANCELED
is PeriodEnded -> EXPIRED // если не продлевает
else -> sub.status
}
PAST_DUE -> when (event) {
is PaymentSucceeded -> ACTIVE
is GraceExpired -> EXPIRED
is UserCanceled -> CANCELED
else -> sub.status
}
CANCELED -> when (event) {
is PeriodEnded -> EXPIRED
is UserReactivated -> ACTIVE // если до конца периода
else -> sub.status
}
EXPIRED -> when (event) {
is PaymentSucceeded -> ACTIVE // реактивация
else -> sub.status
}
PAUSED -> when (event) {
is UserReactivated -> ACTIVE
else -> sub.status
}
}
}
private fun onPastDue(subscription: Subscription) {
notificationService.sendPaymentFailedEmail(subscription.tenant)
// Планируем ретраи через dunning flow
}
private fun onExpired(subscription: Subscription) {
notificationService.sendSubscriptionExpiredEmail(subscription.tenant)
// Отключаем доступ к фичам
}
// ...
}
Ключевой момент: Clock инжектится через конструктор. Это не перфекционизм — это необходимость. Без управления временем вы не можете тестировать trial-истечение, grace period, пропорциональный расчёт. Подробнее — в разделе про тестирование.
Архитектура тарифов
Три модели тарификации. Часто — комбинация всех трёх.
Feature flags по тарифу
@Entity
@Table(name = "plan_features")
class PlanFeature(
@Id
val id: UUID = UUID.randomUUID(),
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id")
val plan: Plan,
val featureKey: String, // "api_access", "custom_reports", "sso"
val enabled: Boolean = true,
val limit: Int? = null // null = unlimited; 100 = макс 100
)
Проверка доступа:
@Service
class FeatureGateService(
private val subscriptionRepository: SubscriptionRepository
) {
fun hasFeature(tenantId: UUID, featureKey: String): Boolean {
val sub = subscriptionRepository.findActiveByTenantId(tenantId)
?: return false
if (sub.status !in listOf(TRIALING, ACTIVE)) return false
return sub.plan.features.any {
it.featureKey == featureKey && it.enabled
}
}
fun getFeatureLimit(tenantId: UUID, featureKey: String): Int? {
val sub = subscriptionRepository.findActiveByTenantId(tenantId)
?: return 0
return sub.plan.features
.firstOrNull { it.featureKey == featureKey }
?.limit
}
}
Антипаттерн, который мы встречаем постоянно: if (plan == "enterprise") в бизнес-коде. Жёсткая привязка к имени тарифа. Потом клиент хочет переименовать «Enterprise» в «Growth» — и начинается grep по всему проекту. Всегда проверяйте через feature flags.
Seat-based биллинг
Формула: seats * pricePerSeat * периодМножитель. Но дьявол в деталях.
fun calculateSeatCharge(
subscription: Subscription,
periodStart: Instant,
periodEnd: Instant
): BigDecimal {
val plan = subscription.plan
val pricePerSeat = plan.basePrice // цена за 1 seat в месяц
return when (plan.billingInterval) {
MONTHLY -> pricePerSeat * subscription.seats.toBigDecimal()
QUARTERLY -> pricePerSeat * subscription.seats.toBigDecimal() * BigDecimal(3)
YEARLY -> pricePerSeat * subscription.seats.toBigDecimal() * BigDecimal(12)
}
}
Сложность — в прорейтинге при добавлении/удалении мест посреди периода. Об этом — в разделе про инвойсы.
Usage-based биллинг
Модель, которая требует отдельной инфраструктуры для трекинга. Подробности — в разделе Metered billing.
При разработке SaaS-платформ мы в Новаком обычно начинаем с flat-rate, добавляем seats через месяц и usage — только когда продукт требует. Чем проще стартовая модель, тем меньше багов на запуске.
Интеграция с платёжным шлюзом
Главное правило: никогда не привязывайтесь к одному шлюзу. Через год клиент скажет «добавьте Тинькофф Pay» или «нам нужен Stripe для Европы». Если вы вызываете API ЮKassa напрямую из сервисного слоя — переписывание неизбежно.
Абстракция
interface PaymentGateway {
/** Создать рекуррентный платёж по сохранённому методу */
fun charge(request: ChargeRequest): ChargeResult
/** Создать первичный платёж с редиректом для подтверждения */
fun createPayment(request: CreatePaymentRequest): CreatePaymentResult
/** Сохранить платёжный метод для рекуррентных платежей */
fun savePaymentMethod(request: SaveMethodRequest): SaveMethodResult
/** Вернуть деньги (полный или частичный рефанд) */
fun refund(request: RefundRequest): RefundResult
/** Имя провайдера для маршрутизации */
fun providerName(): String
}
data class ChargeRequest(
val amount: BigDecimal,
val currency: String,
val paymentMethodId: String,
val idempotencyKey: String,
val description: String,
val metadata: Map<String, String> = emptyMap()
)
sealed class ChargeResult {
data class Success(val transactionId: String, val paidAt: Instant) : ChargeResult()
data class Failed(val code: String, val message: String) : ChargeResult()
data class Pending(val transactionId: String) : ChargeResult()
}
Реализация: ЮKassa
@Component
@ConditionalOnProperty("billing.gateway.yookassa.enabled", havingValue = "true")
class YooKassaGateway(
private val restClient: RestClient,
@Value("\${billing.gateway.yookassa.shop-id}") private val shopId: String,
@Value("\${billing.gateway.yookassa.secret-key}") private val secretKey: String
) : PaymentGateway {
override fun charge(request: ChargeRequest): ChargeResult {
val body = mapOf(
"amount" to mapOf(
"value" to request.amount.setScale(2).toPlainString(),
"currency" to request.currency
),
"payment_method_id" to request.paymentMethodId,
"capture" to true,
"description" to request.description,
"metadata" to request.metadata
)
return try {
val response = restClient.post()
.uri("https://api.yookassa.ru/v3/payments")
.headers { it.setBasicAuth(shopId, secretKey) }
.header("Idempotency-Key", request.idempotencyKey)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body<Map<String, Any>>()!!
when (response["status"]) {
"succeeded" -> ChargeResult.Success(
transactionId = response["id"] as String,
paidAt = Instant.now()
)
"pending" -> ChargeResult.Pending(
transactionId = response["id"] as String
)
else -> ChargeResult.Failed(
code = "yookassa_error",
message = response["status"].toString()
)
}
} catch (e: RestClientException) {
ChargeResult.Failed(code = "network_error", message = e.message ?: "Unknown")
}
}
override fun providerName() = "yookassa"
// ... остальные методы
}
Реализация: Stripe
@Component
@ConditionalOnProperty("billing.gateway.stripe.enabled", havingValue = "true")
class StripeGateway(
@Value("\${billing.gateway.stripe.secret-key}") private val secretKey: String
) : PaymentGateway {
override fun charge(request: ChargeRequest): ChargeResult {
Stripe.apiKey = secretKey
val params = PaymentIntentCreateParams.builder()
.setAmount(request.amount.multiply(BigDecimal(100)).toLong()) // Stripe в копейках
.setCurrency(request.currency.lowercase())
.setPaymentMethod(request.paymentMethodId)
.setConfirm(true)
.setOffSession(true) // рекуррентный платёж без участия юзера
.putAllMetadata(request.metadata)
.setDescription(request.description)
.build()
val requestOptions = RequestOptions.builder()
.setIdempotencyKey(request.idempotencyKey)
.build()
return try {
val intent = PaymentIntent.create(params, requestOptions)
when (intent.status) {
"succeeded" -> ChargeResult.Success(
transactionId = intent.id,
paidAt = Instant.now()
)
"requires_action" -> ChargeResult.Pending(transactionId = intent.id)
else -> ChargeResult.Failed(
code = intent.status,
message = intent.lastPaymentError?.message ?: "Payment failed"
)
}
} catch (e: StripeException) {
ChargeResult.Failed(code = e.code ?: "stripe_error", message = e.message ?: "Unknown")
}
}
override fun providerName() = "stripe"
// ... остальные методы
}
Маршрутизатор шлюзов
@Service
class PaymentGatewayRouter(
private val gateways: List<PaymentGateway>
) {
private val gatewayMap = gateways.associateBy { it.providerName() }
fun resolve(tenantId: UUID): PaymentGateway {
// Логика выбора шлюза: по стране, валюте, настройке тенанта
// Простейший вариант — по конфигу:
return gatewayMap["yookassa"]
?: throw IllegalStateException("No payment gateway configured")
}
fun resolve(providerName: String): PaymentGateway {
return gatewayMap[providerName]
?: throw IllegalArgumentException("Unknown provider: $providerName")
}
}
Подробное сравнение ЮKassa и Stripe по API, комиссиям и подводным камням — в нашем разборе. Здесь важна абстракция: бизнес-логика не знает, какой шлюз используется.
Этот паттерн мы применяем и в финтех-проектах — когда один продукт работает с тремя-пятью шлюзами одновременно.
Webhook: идемпотентность, ретраи, порядок событий
Webhook — самая хрупкая часть биллинга. И самая опасная. Один пропущенный webhook — пользователь платит, но доступ не включается. Один обработанный дважды — двойное списание.
Контроллер
@RestController
@RequestMapping("/api/webhooks")
class WebhookController(
private val webhookProcessor: WebhookProcessor,
private val webhookEventRepository: WebhookEventRepository
) {
@PostMapping("/yookassa")
fun handleYooKassa(
@RequestBody payload: String,
@RequestHeader headers: HttpHeaders
): ResponseEntity<Void> {
// 1. Верификация подписи
if (!YooKassaSignatureVerifier.verify(payload, headers)) {
log.warn("Invalid YooKassa webhook signature")
return ResponseEntity.status(403).build()
}
// 2. Парсинг
val event = objectMapper.readValue(payload, YooKassaWebhookEvent::class.java)
// 3. Идемпотентность: проверяем, обрабатывали ли уже
val eventId = event.event + ":" + event.`object`.id
if (webhookEventRepository.existsByExternalEventId(eventId)) {
log.info("Duplicate webhook ignored: {}", eventId)
return ResponseEntity.ok().build() // 200 — чтобы шлюз не слал повторно
}
// 4. Сохраняем событие ДО обработки
val webhookEvent = WebhookEvent(
externalEventId = eventId,
provider = "yookassa",
eventType = event.event,
payload = payload,
status = WebhookEventStatus.RECEIVED
)
webhookEventRepository.save(webhookEvent)
// 5. Обработка (async, чтобы быстро ответить 200)
webhookProcessor.processAsync(webhookEvent)
return ResponseEntity.ok().build()
}
}
Таблица событий (idempotency store)
@Entity
@Table(
name = "webhook_events",
indexes = [Index(name = "idx_webhook_external_id", columnList = "externalEventId", unique = true)]
)
class WebhookEvent(
@Id
val id: UUID = UUID.randomUUID(),
@Column(unique = true)
val externalEventId: String,
val provider: String,
val eventType: String,
@Column(columnDefinition = "TEXT")
val payload: String,
@Enumerated(EnumType.STRING)
var status: WebhookEventStatus = WebhookEventStatus.RECEIVED,
var processedAt: Instant? = null,
var errorMessage: String? = null,
var retryCount: Int = 0,
val createdAt: Instant = Instant.now()
)
enum class WebhookEventStatus { RECEIVED, PROCESSING, PROCESSED, FAILED, DEAD_LETTER }
Порядок событий — проблема, которую все недооценивают
ЮKassa может прислать payment.succeeded раньше, чем payment.waiting_for_capture. Stripe — invoice.paid раньше, чем invoice.created. Сеть. Ретраи. Параллельная обработка.
Решение: не полагайтесь на порядок. При обработке webhook всегда подтягивайте текущее состояние объекта из API шлюза:
@Service
class WebhookProcessor(
private val gatewayRouter: PaymentGatewayRouter,
private val subscriptionStateMachine: SubscriptionStateMachine,
private val webhookEventRepository: WebhookEventRepository
) {
@Async
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000, multiplier = 2.0))
fun processAsync(event: WebhookEvent) {
try {
event.status = WebhookEventStatus.PROCESSING
when (event.eventType) {
"payment.succeeded" -> handlePaymentSucceeded(event)
"payment.canceled" -> handlePaymentFailed(event)
"refund.succeeded" -> handleRefund(event)
else -> log.info("Unhandled webhook event: {}", event.eventType)
}
event.status = WebhookEventStatus.PROCESSED
event.processedAt = Instant.now()
} catch (e: Exception) {
event.retryCount++
if (event.retryCount >= 5) {
event.status = WebhookEventStatus.DEAD_LETTER
event.errorMessage = e.message
log.error("Webhook moved to dead letter: {}", event.id, e)
// Алерт в Telegram/Slack — это критичная ситуация
} else {
event.status = WebhookEventStatus.FAILED
throw e // @Retryable перехватит
}
} finally {
webhookEventRepository.save(event)
}
}
private fun handlePaymentSucceeded(event: WebhookEvent) {
val paymentId = extractPaymentId(event.payload)
val subscription = findSubscriptionByPaymentId(paymentId)
?: throw IllegalStateException("No subscription for payment $paymentId")
subscriptionStateMachine.transition(subscription, PaymentSucceeded(paymentId))
}
}
Dead letter queue — обязательна. Мы на одном проекте потеряли 3 webhook-события за первый месяц. Обработка падала из-за NullPointerException на необязательном поле. Без dead letter эти события просто исчезли бы.
Генерация инвойсов
Инвойс — юридический документ. Не «запись об оплате». Он должен быть: корректным, неизменяемым после выставления, содержать разбивку по строкам.
Генерация при продлении
@Service
class InvoiceService(
private val invoiceRepository: InvoiceRepository,
private val taxService: TaxService,
private val fiscalService: FiscalService, // 54-ФЗ
private val clock: Clock
) {
@Transactional
fun generateRenewalInvoice(subscription: Subscription): Invoice {
val plan = subscription.plan
val now = clock.instant()
val periodEnd = calculatePeriodEnd(now, plan.billingInterval)
val lineItems = mutableListOf<InvoiceLineItem>()
// Основная строка
val baseAmount = calculateBaseAmount(subscription)
lineItems.add(InvoiceLineItem(
description = "${plan.name} — ${plan.billingInterval.displayName()}",
quantity = 1,
unitPrice = baseAmount,
amount = baseAmount
))
// Seat-строка (если применимо)
if (plan.billingModel in listOf(BillingModel.PER_SEAT, BillingModel.HYBRID)) {
val seatAmount = plan.basePrice * subscription.seats.toBigDecimal()
lineItems.add(InvoiceLineItem(
description = "Дополнительные места: ${subscription.seats} × ${plan.basePrice}",
quantity = subscription.seats,
unitPrice = plan.basePrice,
amount = seatAmount
))
}
val subtotal = lineItems.sumOf { it.amount }
val tax = taxService.calculateVAT(subtotal) // 20% НДС
val total = subtotal + tax
val invoice = Invoice(
subscription = subscription,
tenant = subscription.tenant,
number = generateInvoiceNumber(),
periodStart = now,
periodEnd = periodEnd,
subtotal = subtotal,
taxAmount = tax,
taxRate = BigDecimal("0.20"),
total = total,
currency = plan.currency,
status = InvoiceStatus.OPEN,
lineItems = lineItems,
dueDate = now.plus(Duration.ofDays(3))
)
return invoiceRepository.save(invoice)
}
}
Прорейтинг (proration) при апгрейде
Классическая головная боль. Пользователь на тарифе «Starter» (5 000 руб/мес) апгрейдится на «Business» (15 000 руб/мес) 15-го числа. Как считать?
fun calculateProration(
oldPlan: Plan,
newPlan: Plan,
switchDate: Instant,
periodStart: Instant,
periodEnd: Instant
): ProrationResult {
val totalDays = Duration.between(periodStart, periodEnd).toDays()
val remainingDays = Duration.between(switchDate, periodEnd).toDays()
val usedDays = totalDays - remainingDays
// Кредит за неиспользованные дни старого плана
val dailyRateOld = oldPlan.basePrice.divide(totalDays.toBigDecimal(), 2, RoundingMode.HALF_UP)
val credit = dailyRateOld * remainingDays.toBigDecimal()
// Стоимость оставшихся дней нового плана
val dailyRateNew = newPlan.basePrice.divide(totalDays.toBigDecimal(), 2, RoundingMode.HALF_UP)
val charge = dailyRateNew * remainingDays.toBigDecimal()
return ProrationResult(
credit = credit,
charge = charge,
netAmount = charge - credit, // сумма к доплате
description = "Апгрейд ${oldPlan.name} → ${newPlan.name}: " +
"$remainingDays дней × ${dailyRateNew}/день − кредит ${credit}"
)
}
54-ФЗ: онлайн-касса
Если ваш SaaS работает в России и принимает платежи от физлиц — 54-ФЗ обязателен. Каждый платёж должен сопровождаться фискальным чеком.
ЮKassa встраивает чеки прямо в API платежей. Передаёте receipt в запросе:
fun buildReceipt(invoice: Invoice, customerEmail: String): Map<String, Any> {
return mapOf(
"customer" to mapOf("email" to customerEmail),
"items" to invoice.lineItems.map { item ->
mapOf(
"description" to item.description.take(128), // макс 128 символов
"amount" to mapOf(
"value" to item.amount.setScale(2).toPlainString(),
"currency" to invoice.currency
),
"vat_code" to 4, // 20% НДС
"quantity" to item.quantity.toString(),
"payment_subject" to "service",
"payment_mode" to "full_payment"
)
}
)
}
vat_code = 4 — это 20% НДС. Полный список кодов в документации ЮKassa. Если ваша компания на УСН, код другой. Если продаёте юрлицам по безналу через банк-клиент — чек не нужен. Проконсультируйтесь с бухгалтером, прежде чем хардкодить ставку.
Dunning: что происходит, когда платёж не прошёл
Dunning — процесс обработки неудачных платежей. Карта истекла, недостаточно средств, лимит, техническая ошибка на стороне банка. По нашей статистике, 5-8% рекуррентных платежей SaaS-платформы проваливаются ежемесячно. Без dunning вы теряете эти деньги.
Расписание ретраев
@Service
class DunningService(
private val paymentGatewayRouter: PaymentGatewayRouter,
private val subscriptionStateMachine: SubscriptionStateMachine,
private val notificationService: NotificationService,
private val invoiceRepository: InvoiceRepository,
private val clock: Clock
) {
// Расписание: день 0, день 3, день 5, день 7
private val retrySchedule = listOf(0, 3, 5, 7)
private val gracePeriodDays = 7L
@Scheduled(cron = "0 0 10 * * *") // каждый день в 10:00
fun processDunning() {
val pastDueInvoices = invoiceRepository.findByStatus(InvoiceStatus.PAYMENT_FAILED)
pastDueInvoices.forEach { invoice ->
val daysSinceFailure = Duration.between(
invoice.lastPaymentAttempt ?: invoice.dueDate,
clock.instant()
).toDays()
when {
daysSinceFailure >= gracePeriodDays -> {
// Grace period истёк — отключаем
subscriptionStateMachine.transition(
invoice.subscription, GraceExpired
)
notificationService.sendFinalWarning(invoice.tenant)
}
shouldRetry(invoice, daysSinceFailure) -> {
retryPayment(invoice)
}
}
}
}
private fun shouldRetry(invoice: Invoice, daysSince: Long): Boolean {
val nextRetryDay = retrySchedule.firstOrNull { it > invoice.retryCount * 2 }
return nextRetryDay != null && daysSince >= nextRetryDay
}
private fun retryPayment(invoice: Invoice) {
val gateway = paymentGatewayRouter.resolve(invoice.tenant.id)
val result = gateway.charge(ChargeRequest(
amount = invoice.total,
currency = invoice.currency,
paymentMethodId = invoice.subscription.paymentMethodId!!,
idempotencyKey = "retry-${invoice.id}-${invoice.retryCount}",
description = "Повторная оплата: ${invoice.number}"
))
when (result) {
is ChargeResult.Success -> {
invoice.status = InvoiceStatus.PAID
invoice.paidAt = result.paidAt
subscriptionStateMachine.transition(
invoice.subscription, PaymentSucceeded(result.transactionId)
)
notificationService.sendPaymentRecoveredEmail(invoice.tenant)
}
is ChargeResult.Failed -> {
invoice.retryCount++
invoice.lastPaymentAttempt = clock.instant()
notificationService.sendRetryFailedEmail(
invoice.tenant, invoice.retryCount, gracePeriodDays
)
}
is ChargeResult.Pending -> {
// Ждём webhook
}
}
invoiceRepository.save(invoice)
}
}
Уведомления — структура
| День | Действие | Тон письма |
|---|---|---|
| 0 | Платёж не прошёл, автоматический ретрай | «Проблема с оплатой, пытаемся повторить» |
| 3 | Второй ретрай | «Обновите карту, иначе сервис будет ограничен» |
| 5 | Третий ретрай | «Осталось 2 дня до приостановки» |
| 7 | Grace period истёк, подписка → EXPIRED | «Доступ приостановлен. Оплатите для восстановления» |
По нашему опыту, 60-70% проваленных платежей восстанавливаются автоматически на первом-втором ретрае. Обычно причина — временный недостаток средств или техническая ошибка на стороне банка-эмитента. Ещё 15-20% — после email-напоминания, когда пользователь обновляет карту вручную.
Схема базы данных
PostgreSQL DDL для ключевых таблиц. Индексы подобраны под типичные запросы биллинг-сервиса.
-- Тарифные планы
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
billing_model VARCHAR(20) NOT NULL, -- FLAT, PER_SEAT, USAGE_BASED, HYBRID
base_price NUMERIC(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'RUB',
billing_interval VARCHAR(20) NOT NULL, -- MONTHLY, QUARTERLY, YEARLY
trial_days INT NOT NULL DEFAULT 14,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Фичи тарифов
CREATE TABLE plan_features (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
feature_key VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
"limit" INT, -- NULL = без ограничений
UNIQUE (plan_id, feature_key)
);
-- Подписки
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
plan_id UUID NOT NULL REFERENCES plans(id),
status VARCHAR(20) NOT NULL DEFAULT 'TRIALING',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_end TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
cancel_reason TEXT,
seats INT NOT NULL DEFAULT 1,
payment_method_id VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sub_tenant ON subscriptions(tenant_id);
CREATE INDEX idx_sub_status ON subscriptions(status);
CREATE INDEX idx_sub_period_end ON subscriptions(current_period_end);
-- Инвойсы
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
number VARCHAR(50) NOT NULL UNIQUE,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
subtotal NUMERIC(12,2) NOT NULL,
tax_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
tax_rate NUMERIC(5,4) NOT NULL DEFAULT 0.2000,
total NUMERIC(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'RUB',
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
due_date TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
retry_count INT NOT NULL DEFAULT 0,
last_payment_attempt TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_inv_tenant ON invoices(tenant_id);
CREATE INDEX idx_inv_status ON invoices(status);
CREATE INDEX idx_inv_sub ON invoices(subscription_id);
-- Строки инвойса
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description VARCHAR(255) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL,
amount NUMERIC(12,2) NOT NULL
);
-- Платежи
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
provider VARCHAR(50) NOT NULL, -- yookassa, stripe
external_id VARCHAR(255) NOT NULL, -- ID транзакции в шлюзе
amount NUMERIC(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, SUCCEEDED, FAILED, REFUNDED
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
error_code VARCHAR(100),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_pay_invoice ON payments(invoice_id);
CREATE INDEX idx_pay_external ON payments(external_id);
-- Webhook-события (idempotency store)
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_event_id VARCHAR(255) NOT NULL UNIQUE,
provider VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED',
processed_at TIMESTAMPTZ,
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Usage-записи (metered billing)
CREATE TABLE usage_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
metric_key VARCHAR(100) NOT NULL, -- "api_calls", "storage_gb", "emails_sent"
quantity BIGINT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
billing_period VARCHAR(7) NOT NULL -- "2026-05" формат
);
CREATE INDEX idx_usage_sub_period ON usage_records(subscription_id, billing_period);
CREATE INDEX idx_usage_metric ON usage_records(metric_key, billing_period);
Обратите внимание на NUMERIC(12,2) — не DECIMAL, не FLOAT, не MONEY (PostgreSQL-тип MONEY привязан к locale и создаёт проблемы при миграции). 12 знаков слева от запятой покрывают суммы до 9 999 999 999,99 — хватит для большинства SaaS.
Если строите высоконагруженную систему с миллионами транзакций, рассмотрите партиционирование таблиц payments и usage_records по дате.
Тестирование биллинга
Биллинг — одна из немногих областей, где покрытие тестами 90%+ не перфекционизм, а необходимость. Баг в биллинге = потеря денег. Либо ваших (не списали с клиента), либо клиентских (списали дважды).
Управление временем
Без контроля над часами вы не протестируете: истечение trial, grace period, ежемесячное продление, прорейтинг.
@TestConfiguration
class TestClockConfig {
@Bean
@Primary
fun testClock(): Clock = TestClock()
}
class TestClock : Clock() {
private var instant: Instant = Instant.parse("2026-01-15T10:00:00Z")
fun advanceDays(days: Long) {
instant = instant.plus(Duration.ofDays(days))
}
fun advanceHours(hours: Long) {
instant = instant.plus(Duration.ofHours(hours))
}
fun setTo(newInstant: Instant) {
instant = newInstant
}
override fun instant(): Instant = instant
override fun withZone(zone: ZoneId?): Clock = this
override fun getZone(): ZoneId = ZoneOffset.UTC
}
Тест: полный lifecycle подписки
@SpringBootTest
class SubscriptionLifecycleTest(
@Autowired val stateMachine: SubscriptionStateMachine,
@Autowired val invoiceService: InvoiceService,
@Autowired val testClock: TestClock
) {
@Test
fun `trial to active to past_due to recovered`() {
// Создаём подписку с 14-дневным trial
val sub = createSubscription(status = TRIALING, trialDays = 14)
// Перематываем на 14 дней — trial заканчивается
testClock.advanceDays(14)
stateMachine.transition(sub, TrialExpired)
// paymentMethodId есть → автоматический переход в ACTIVE
assertEquals(ACTIVE, sub.status)
// Генерируем инвойс и оплачиваем
val invoice = invoiceService.generateRenewalInvoice(sub)
assertEquals(InvoiceStatus.OPEN, invoice.status)
// Перематываем на 30 дней — продление, платёж падает
testClock.advanceDays(30)
stateMachine.transition(sub, PaymentFailed("insufficient_funds"))
assertEquals(PAST_DUE, sub.status)
// Перематываем на 3 дня — ретрай, платёж проходит
testClock.advanceDays(3)
stateMachine.transition(sub, PaymentSucceeded("txn_retry_001"))
assertEquals(ACTIVE, sub.status)
}
@Test
fun `proration on mid-cycle upgrade`() {
testClock.setTo(Instant.parse("2026-03-01T00:00:00Z"))
val starter = createPlan("Starter", BigDecimal("5000"))
val business = createPlan("Business", BigDecimal("15000"))
// Апгрейд 15-го марта
testClock.setTo(Instant.parse("2026-03-15T00:00:00Z"))
val result = invoiceService.calculateProration(
oldPlan = starter,
newPlan = business,
switchDate = testClock.instant(),
periodStart = Instant.parse("2026-03-01T00:00:00Z"),
periodEnd = Instant.parse("2026-03-31T00:00:00Z")
)
// 16 оставшихся дней из 30
// Кредит: 5000/30 * 16 = 2666.67
// Доплата: 15000/30 * 16 = 8000.00
// Итого: 5333.33
assertEquals(BigDecimal("5333.33"), result.netAmount)
}
}
Edge cases, которые нужно тестировать
| Кейс | Проблема |
|---|---|
| Февраль → Март (28/29 дней) | Прорейтинг ломается, если хардкодить 30 дней |
| Переход через DST | LocalDate.plus(1, MONTHS) может дать неожиданный результат |
| Апгрейд в последний день периода | Прорейтинг = 0 дней, деление на ноль |
| Даунгрейд (более дорогой → дешёвый) | Кредит превышает стоимость, отрицательный инвойс |
| Две оплаты одновременно (race condition) | Без pessimistic lock — двойное списание |
| Webhook приходит после ручного обновления | Конфликт состояний |
| Валюта с 0 дробных знаков (JPY) | amount * 100 ломает расчёт для йены |
Каждый из этих кейсов мы обнаружили в production. На HR-платформе мы забыли про февраль — прорейтинг для апгрейда 28 февраля использовал 30 дней и выдавал отрицательную сумму. Пользователь получил инвойс на минус 200 рублей.
Metered billing
Usage-based модель — когда платите не за подписку, а за потребление: API-вызовы, хранилище, отправленные письма, часы GPU-вычислений.
Трекинг потребления
@Service
class UsageTrackingService(
private val usageRecordRepository: UsageRecordRepository,
private val clock: Clock
) {
/**
* Записать N единиц потребления.
* Вызывается из бизнес-логики при каждом событии.
*/
fun record(subscriptionId: UUID, metricKey: String, quantity: Long) {
val billingPeriod = YearMonth.from(
clock.instant().atZone(ZoneOffset.UTC)
).toString() // "2026-05"
val record = UsageRecord(
subscriptionId = subscriptionId,
metricKey = metricKey,
quantity = quantity,
billingPeriod = billingPeriod,
recordedAt = clock.instant()
)
usageRecordRepository.save(record)
}
/**
* Получить агрегированное потребление за период.
*/
fun getUsage(subscriptionId: UUID, metricKey: String, period: String): Long {
return usageRecordRepository.sumQuantityBySubscriptionAndMetricAndPeriod(
subscriptionId, metricKey, period
) ?: 0L
}
}
Агрегация для инвойса
@Service
class UsageBillingService(
private val usageTrackingService: UsageTrackingService,
private val pricingTierRepository: PricingTierRepository
) {
fun calculateUsageCharge(
subscription: Subscription,
metricKey: String,
period: String
): BigDecimal {
val totalUsage = usageTrackingService.getUsage(
subscription.id, metricKey, period
)
val tiers = pricingTierRepository.findByPlanIdAndMetricKey(
subscription.plan.id, metricKey
).sortedBy { it.fromUnit }
// Пример: 0-1000 вызовов → бесплатно, 1001-10000 → 0.50₽, 10001+ → 0.30₽
var remaining = totalUsage
var charge = BigDecimal.ZERO
for (tier in tiers) {
val tierSize = (tier.toUnit ?: Long.MAX_VALUE) - tier.fromUnit
val unitsInTier = minOf(remaining, tierSize)
if (unitsInTier <= 0) break
charge += BigDecimal(unitsInTier) * tier.pricePerUnit
remaining -= unitsInTier
}
return charge.setScale(2, RoundingMode.HALF_UP)
}
}
Таблица тарифных уровней (pricing tiers)
CREATE TABLE pricing_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES plans(id),
metric_key VARCHAR(100) NOT NULL,
from_unit BIGINT NOT NULL, -- 0, 1001, 10001
to_unit BIGINT, -- 1000, 10000, NULL (безлимит)
price_per_unit NUMERIC(10,4) NOT NULL, -- 0.0000, 0.5000, 0.3000
UNIQUE (plan_id, metric_key, from_unit)
);
Важный нюанс: usage-данные приходят с задержкой. API-шлюз может сообщить о вызовах через 5-30 секунд. Если вы закрываете биллинговый период ровно в полночь, часть usage не попадёт в текущий инвойс. Решение: закрывайте период с буфером. Мы даём 2 часа после полуночи и фиксируем данные в отдельной таблице usage_period_snapshots с пометкой finalized = true.
На B2B-маркетплейсе мы столкнулись с тем, что клиент видел в дашборде 47 000 API-вызовов, а в инвойсе — 49 200. Разница — запросы, залогированные в последние минуты периода с задержкой. Клиент был уверен, что мы завышаем. Пришлось строить отдельный reconciliation-отчёт с timestamp каждого запроса. Это потребовало полноценной финтех-инфраструктуры — аудит-лог и сверка, как в банке.
Типичные ошибки
Двенадцать ошибок. Каждую мы либо сделали сами, либо нашли на аудите чужого проекта. Мы строим SaaS-платформы в Новаком и знаем эти грабли по собственным шишкам.
1. Не обрабатывать дубли webhook
Шлюз присылает один и тот же webhook 3 раза (таймаут, retry). Без проверки external_event_id вы спишете деньги трижды. Или трижды продлите подписку.
2. Хардкодить названия тарифов
if (plan.name == "Enterprise") — бомба замедленного действия. Через полгода маркетинг переименует план. Или добавит «Enterprise Plus». Всегда проверяйте через feature flags.
3. Игнорировать прорейтинг
«Апгрейд? Просто переключим план с начала следующего периода.» А клиент хочет новые фичи сейчас. И платить готов. Но вы не умеете посчитать разницу.
4. Хранить деньги как Float/Double
// ❌ Потеря точности на больших суммах
val price: Double = 19999.99
val total = price * 12 // 239999.87999999998
// ✅ BigDecimal
val price = BigDecimal("19999.99")
val total = price * BigDecimal(12) // 239999.88
5. Не логировать переходы состояний
Через 3 месяца вам скажут: «Почему подписка клиента X отключилась 14 марта?» Без audit log вы не ответите. Логируйте каждый transition() с oldStatus, newStatus, event, timestamp.
6. Синхронная обработка webhook
Webhook-эндпоинт должен ответить 200 за 5-10 секунд. Если вы внутри webhook вызываете три внешних API, генерируете PDF, отправляете email — шлюз решит, что вы упали, и пришлёт webhook повторно.
7. Одна таймзона для всех
Клиент в UTC+9, сервер в UTC+3, биллинговый период закрывается по UTC. Клиент видит: «Я использовал 100 запросов 31-го числа», а в инвойсе они попали в следующий месяц (потому что по UTC уже 1-е). Храните recorded_at в UTC, но показывайте клиенту в его таймзоне.
8. Нет grace period при неудачном платеже
Платёж не прошёл → мгновенно отключили доступ. Клиент теряет данные, злится, уходит. Всегда давайте 3-7 дней и ретрайте автоматически.
9. Забыть про рефанды
Рефанд — не просто «вернуть деньги». Это: отмена чека по 54-ФЗ, создание корректирующего инвойса, обновление состояния подписки, пересчёт аналитики (MRR, churn). Одна API-вызов к шлюзу — и 5 side-effects в вашей системе.
10. Не тестировать граничные даты
31 января + 1 месяц = 28 февраля (или 29-е). Март 31-е + 1 месяц = апрель 30-е. Ваш код это учитывает? Используйте YearMonth и LocalDate.plusMonths() вместо ручной арифметики.
11. Жёсткая привязка к одному шлюзу
Вы интегрировали ЮKassa, вызывая их API из 15 мест в коде. Теперь нужен Stripe для зарубежных клиентов. Два месяца рефакторинга. Абстракция PaymentGateway + паттерн Strategy — инвестиция, которая окупается при втором шлюзе.
12. Биллинг в том же сервисе, что и основная логика
Биллинг — отдельный bounded context. Он должен деплоиться, масштабироваться и падать независимо от основного приложения. Если ваш сервис пользователей лежит — биллинг всё равно должен обрабатывать webhook и ретраить платежи.
FAQ
Сколько времени занимает MVP биллинга?
По нашему опыту — 4-6 недель для одного разработчика. Flat-rate подписка + trial + один шлюз + webhook + dunning. Seat-based или usage-based добавляет ещё 2-3 недели. Но 80% времени уходит не на код, а на edge cases и тестирование.
Стоит ли использовать Stripe Billing / Chargebee вместо собственного?
Если ваш SaaS на международном рынке и стандартная flat-rate модель — да, Stripe Billing экономит месяцы работы. Но: привязка к платформе, комиссия сверху, ограниченная кастомизация. Для российского рынка Stripe Billing не работает (нет рублёвых расчётов). ЮKassa не имеет встроенного subscription management. Поэтому для России — собственный биллинг на Spring Boot почти всегда необходим.
Как мигрировать с кастомного биллинга на новую архитектуру?
Параллельный запуск. Пишите новые подписки в новую систему, старые — дорабатываются до конца текущего периода. Не пытайтесь мигрировать 10 000 подписок одним скриптом в пятницу вечером. (Да, мы видели такое.)
Нужен ли отдельный микросервис для биллинга?
Для SaaS с 1-3 тарифами и сотнями клиентов — модуль внутри монолита. Отдельный сервис добавляет операционную сложность (деплой, мониторинг, inter-service communication). Для платформы с тысячами клиентов, несколькими шлюзами, usage-based моделью — отдельный сервис оправдан.
Как считать MRR (Monthly Recurring Revenue)?
SELECT
SUM(CASE
WHEN p.billing_interval = 'MONTHLY' THEN p.base_price * s.seats
WHEN p.billing_interval = 'QUARTERLY' THEN p.base_price * s.seats / 3
WHEN p.billing_interval = 'YEARLY' THEN p.base_price * s.seats / 12
END) AS mrr
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.status IN ('ACTIVE', 'TRIALING');
Итого
Биллинг для SaaS — это не «подключить платёжку». Это state machine, прорейтинг, dunning, фискализация, edge cases с датами и валютами. Код, который вы видите в этой статье — скелет. Каждый SaaS добавляет свои нюансы: промокоды, корпоративные договоры, мультивалютность, marketplace-сплиты.
Что точно стоит сделать с первого дня:
- Абстракция над шлюзом.
PaymentGatewayинтерфейс. Даже если сейчас один шлюз. Clockчерез DI. Без этого тесты биллинга — страдание.- Idempotency store для webhook. Таблица
webhook_eventsс unique constraint. BigDecimalдля денег. Без исключений.- Audit log. Каждый переход состояния, каждый платёж, каждый пересчёт.
Если вам нужна помощь с архитектурой биллинга для вашей SaaS-платформы — расскажите нам о проекте. Мы строим платёжные системы и бэкенды на Java/Kotlin с 2017 года. Три SaaS-биллинга за плечами — и все три работают в production.