Новаком
SAAS

Биллинг для SaaS: архитектура подписок и платежей на Spring Boot

Как спроектировать биллинг для SaaS-платформы: тарифные планы, trial, рекуррентные платежи, webhook, 54-ФЗ. Архитектура на Spring Boot + PostgreSQL, интеграция ЮKassa/Stripe.

Н
Новаком
2026-05-24 · 16 минут чтения

Содержание


Почему биллинг — самая сложная часть 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 дня до приостановки»
7Grace 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 дней
Переход через DSTLocalDate.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-сплиты.

Что точно стоит сделать с первого дня:

  1. Абстракция над шлюзом. PaymentGateway интерфейс. Даже если сейчас один шлюз.
  2. Clock через DI. Без этого тесты биллинга — страдание.
  3. Idempotency store для webhook. Таблица webhook_events с unique constraint.
  4. BigDecimal для денег. Без исключений.
  5. Audit log. Каждый переход состояния, каждый платёж, каждый пересчёт.

Если вам нужна помощь с архитектурой биллинга для вашей SaaS-платформы — расскажите нам о проекте. Мы строим платёжные системы и бэкенды на Java/Kotlin с 2017 года. Три SaaS-биллинга за плечами — и все три работают в production.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект