Как мы считаем досрочное погашение кредита: что реально работает в коде

от автора

Когда я начал делать кредитный трекер, казалось, что финансовая математика — самая простая часть проекта. Формула аннуитета есть в любом учебнике, Excel справляется за пять минут.

Я ошибался.

Небольшой контекст: до этого я довольно долго не делал ничего для Android — работал в других областях, экосистема успела заметно измениться. Вернуться оказалось неожиданно приятно: Compose после нескольких лет XML-вёрстки ощущается как глоток свежего воздуха, KSP вместо KAPT работает заметно быстрее, а Room с Flow и корутинами — это уже совсем другой уровень удобства по сравнению с тем, что я помнил. Так что статья отчасти и про это: как выглядит возвращение в Android-разработку после перерыва.Плюс технический разбор того, как на самом деле устроен кредитный калькулятор внутри Android-приложения. С реальным кодом, реальными компромиссами и честным признанием того, что мы намеренно упростили.

Архитектура: откуда берутся данные

Стек: Kotlin 2.1, Jetpack Compose, Room, Coroutines. Никакого Hilt и Dagger — зависимости собираются вручную в главном Application-классе.

Данные текут так:

Room Flow → Repository → ViewModel (StateFlow + combine) → Composable (collectAsStateWithLifecycle)

Все stateIn вызовы используют SharingStarted.Eagerly — поток стартует при создании ViewModel, не при первом подписчике.  Это убирает большинство «миганий» пустого состояния при навигации, хотя в холодном старте база всё равно иногда берет своё

Room + Flow: реактивность «из коробки»

Раньше база данных была вещью в себе: нужно было вручную обновлять списки после каждого изменения или городить сложные механизмы с ContentObserver. Сейчас Room в связке с Flow делает всё это за тебя.

База данных (Room) отдает поток долгов, мы подмешиваем к нему выбранную стратегию из настроек, и на выходе получаем готовый uiState. Всё это реактивно: изменился долг в базе или пользователь переключил «Лавину» на «Снежный ком» — UI обновится мгновенно и автоматически.

Базовая формула аннуитета

Стандартная формула ежемесячного платежа:

A = P × r / (1 - (1 + r) ^-n)

где P — остаток долга, r — месячная ставка (годовая / 12), n — оставшийся срок в месяцах.

В коде это выглядит так (CalcLoanUseCase.kt):

val r = annualRate / 100.0 / 12.0val annuityPayment = if (r > 0)    balance * r / (1 - (1 + r).pow(-termMonths.toDouble()))else    balance / termMonths

Та же формула используется в CalcPayoffUseCase для автоматического расчёта minPayment, если пользователь его не указал.

Симуляция погашения: итерации, а не формула

Для стратегий лавина и снежный ком мы не ищем аналитическое решение — симулируем помесячно:

// CalcPayoffUseCase.ktwhile (balances.any { it > 0.01 }) {    var extra = extraBudget    months++    sorted.forEachIndexed { i, debt ->        if (balances[i] <= 0.01) return@forEachIndexed        val monthlyInterest = balances[i] * (debt.interestRate / 100.0 / 12.0)        totalInterestPaid += monthlyInterest        balances[i] += monthlyInterest        val payment = when (debt.paymentType) {            PaymentType.DIFFERENTIAL -> monthlyPrincipal[i] + monthlyInterest            else -> effectiveMinPayment[i]        }        // Весь свободный бюджет идёт первому активному долгу        val firstActive = balances.indexOfFirst { it > 0.01 }        var totalPayment = payment        if (firstActive == i && extra > 0) {            totalPayment += extra            extra = 0.0        }        totalPayment = minOf(totalPayment, balances[i])        balances[i] -= totalPayment    }    if (months > 600) break}

Лавина — sortedByDescending { it.interestRate }, математически оптимальна.
Снежный ком — sortedBy { it.currentBalance }, психологически мотивирует.

Скрытые комиссии: три вида

Многие калькуляторы считают только проценты. Мы добавили три дополнительных поля в сущность долга:

// Debt.ktval originationFeePercent: Double = 0.0   // единоразовая комиссия за выдачу, % от суммыval monthlyServiceFee: Double = 0.0        // ежемесячные комиссии: обслуживание, СМС и т.д., ₽val annualInsurancePercent: Double = 0.0   // страховка, % от остатка в год

В симуляции они накапливаются отдельно от процентов:

// Комиссия за выдачу — единоразово, считается от оригинальной суммыvar totalFeesPaid = sorted.sumOf { it.originalAmount * it.originationFeePercent / 100.0 }// Внутри цикла каждый месяц:totalFeesPaid += debt.monthlyServiceFeetotalFeesPaid += balances[i] * (debt.annualInsurancePercent / 100.0 / 12.0)

Результат: два числа переплаты — totalInterestPaid (по договору) и totalRealOverpayment (реальная, включая все скрытые расходы). Разница иногда достигает сотен тысяч рублей.

График предстоящих платежей: пересчёт от текущего баланса

Предстоящие платежи строятся через CalcLoanUseCase — отдельный класс с полным помесячным графиком:

// CalcLoanUseCase.kt — строит список PaymentRow с реальными датамиval cal = startCalendar.clone() as Calendarval payDay = firstPaymentDay.coerceIn(1, 28)if (cal.get(Calendar.DAY_OF_MONTH) >= payDay) cal.add(Calendar.MONTH, 1)cal.set(Calendar.DAY_OF_MONTH, payDay)for (i in 1..termMonths) {    val interest = rem * r    val principal = (annuityPayment - interest).coerceAtLeast(0.0)    rows.add(PaymentRow(i, sdf.format(cal.time), annuityPayment, interest, principal, rem))    cal.add(Calendar.MONTH, 1)    rem = (rem - principal).coerceAtLeast(0.0)}

Когда пользователь вносит платёж — currentBalance обновляется в Room. Room эмитит новый Flow → ViewModel пересчитывает → projectMonthlyUpcoming строит новый график от актуального остатка. Никакого ручного «обновления» — реактивность через Room.

Честное ограничение: проценты считаются как баланс × ставка / 12, без учёта реального количества дней в месяце. Разница с банковским расчётом «по дням» — копейки на обычных суммах, заметна только на крупной ипотеке за много лет.

Досрочное погашение: два режима

На экране «Стратегия» есть калькулятор снежинки — разового досрочного платежа. Два режима:

Уменьшить срок — перезапускаем полную симуляцию с уменьшенным балансом и смотрим разницу в месяцах и переплате:

// StrategyViewModel.ktval modifiedDebts = currentDebts.toMutableList().also { list ->    val idx = list.indexOf(targetDebt)    list[idx] = targetDebt.copy(currentBalance = newBalance)}val withSf = calcUseCase.calcAvalanche(modifiedDebts, extraBudget)val monthsSaved = withoutSf.totalMonths - withSf.totalMonthsval interestSaved = withoutSf.totalInterestPaid - withSf.totalInterestPaid

Уменьшить платёж — считаем новый аннуитетный платёж по формуле для уменьшенного баланса, показываем разницу в ₽/мес:

val oldPayment = balance * r / (1 - (1 + r).pow(-term))val newPayment = newBalance * r / (1 - (1 + r).pow(-term))val saved = (oldPayment - newPayment).coerceAtLeast(0.0)

Целевой долг — тот, у которого наибольшая процентная ставка (логика лавины).

Ключевая ставка и инфляция: два потока данных с ЦБ РФ

Ключевая ставка — SOAP раз в 7 дней

Ставка подтягивается через WorkManager:

// KeyRateRepository.ktval soap = """<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:web="http://web.cbr.ru/">  <soap:Body>    <web:KeyRateXML>      <web:fromDate>$weekAgo</web:fromDate>      <web:ToDate>$today</web:ToDate>    </web:KeyRateXML>  </soap:Body></soap:Envelope>"""

Используется для двух целей:

  1. Анализ: вклад vs досрочка — если ставка долга ниже ключевой, свободные деньги могут быть эффективнее на депозите

  2. Сигнал рефинансирования — если ставка долга превышает ключевую более чем на 5%, показываем рекомендацию проверить рыночные предложения

val refinanceThreshold = depositRate + 5.0val refinanceDebts = debts.filter { debt ->    debt.paymentType != PaymentType.CREDIT_CARD &&    (debt.termMonths ?: 0) > 12 &&    debt.interestRate > refinanceThreshold}

Инфляция — раз в 30 дней

ЦБ РФ публикует данные об инфляции г/г на своём сайте. Подтягиваем через WorkManager раз в 30 дней, кэшируем в SharedPreferences. Fallback — 6%, если данные недоступны.

Имея обе цифры, считаем реальную стоимость долга и реальную доходность вклада:

val realDebt    = item.rate - inflationRate   // реальная стоимость долгаval realDeposit = depositRate - inflationRate // реальная доходность вклада

Это позволяет показать пользователю не просто «ставка 9% ниже ключевой 15%», а конкретный вывод: вклад приносит 9.1%/год реальной доходности, долг обходится 3.1%/год — и объяснить, что с этим делать.

Дата обновления обоих показателей хранится в SharedPreferences и отображается в тултипе на экране стратегии.

Уведомления: два механизма для двух задач

В приложении два вида фоновых уведомлений с принципиально разной природой.

Напоминание о платеже — AlarmManager

Пользователь выбирает за сколько дней и в какое время получать напоминание. Здесь важна точность: уведомление в 9:00 должно прийти в 9:00, а не в 9:23.

WorkManager для этого не подходит — он может задержать задачу на 15+ минут из-за батчинга и Doze mode. Используем AlarmManager.setExactAndAllowWhileIdle:

am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, target, pendingIntent)

На Android 12+ для точных будильников нужно разрешение SCHEDULE_EXACT_ALARM. Если пользователь его не выдал — graceful fallback на setAndAllowWhileIdle с точностью ~15 минут, что для напоминания о платеже вполне приемлемо.

После срабатывания BroadcastReceiver сам перепланирует себя на следующий день — будильник одноразовый, цепочка самоподдерживающаяся. При перезагрузке телефона BootReceiver восстанавливает расписание.

Авто-списание — WorkManager

Авто-списание — это запись планового платежа в базу в заданное время каждый месяц. Здесь точность до минуты не нужна, зато нужна гарантия выполнения. WorkManager подходит идеально — перезагрузка и Doze не мешают, задача всё равно выполнится.

Но есть нюанс: WorkManager может запустить два экземпляра воркера одновременно — плановый тик и повторный запуск после перезагрузки. Без защиты один платёж запишется дважды.

Решение — оптимистичная блокировка прямо в SQL. Вместо SELECT → проверка → UPDATE делаем атомарный UPDATE WHERE next_payment_date = :expectedDate и смотрим на количество затронутых строк:

val claimed = debtDao.claimPayment(    id           = debt.id,    expectedDate = debt.nextPaymentDate,    newBalance   = newBalance,    newDate      = nextDate)if (claimed == 0) continue  // параллельный воркер уже обработал

Если второй воркер добрался до этого долга раньше — дата уже изменилась, UPDATE не затронет ни одной строки и вернёт 0. Никаких транзакций, никаких мьютексов — база данных сама гарантирует атомарность.

Double вместо BigDecimal: осознанный выбор

Деньги хранятся в Double. Классический совет — использовать BigDecimal, и он правильный для банковских систем. Но для трекера долгов:

  • Double даёт ~15 значимых цифр. Для 10 000 000 ₽ точность до копейки — без проблем.

  • Ошибки накопления за всё время симуляции дают отклонение в доли копейки.

  • Room не поддерживает BigDecimal нативно — нужен TypeConverter для каждого поля.

  • Симуляция с BigDecimal заметно медленнее.

Настоящая проблема точности была в другом месте: Animatable в Compose работает с Float (7 значимых цифр). При анимации крупных сумм последние цифры «прыгали». Решение — анимировать целые рубли через toLong(), а не исходный Double.

Что намеренно упрощено

Честно о том, чего нет:

Возможность

Статус

Учёт реальных дней в месяце при расчёте %

Нет, всегда /12

Несколько досрочных платежей с накоплением

Нет, только разовая «снежинка»

Плавающая ставка (ипотека с ЦБ+%)

Нет

Учёт инфляции во времени

Частично — показываем реальную стоимость долга (ставка − инфляция), но симуляция погашения в номинальных рублях

Для личного трекера, где главная цель — мотивировать пользователя гасить долги — этого достаточно. Точность «до копейки» здесь не нужна: нужна честная оценка и понятный интерфейс.

Тестирование

Юнит-тесты алгоритма и мой фаворит — Compose UI-тесты. Вместо того чтобы прокликивать всё приложение, я просто рендерю Composable-функцию через createComposeRule. Это позволяет проверить реакцию UI на специфические данные (например, огромные суммы или пустые списки) за считанные секунды в полной изоляции от Activity и навигации

Итог

Кредитный калькулятор, который делает больше чем одну формулу — это:

  • Итеративная симуляция погашения по месяцам

  • Два вида переплаты: по договору и реальная (со скрытыми комиссиями)

  • Реактивный пересчёт предстоящих платежей от текущего баланса через Room Flow

  • Два режима досрочного погашения с разной математикой

  • Два потока данных с ЦБ РФ: ключевая ставка (SOAP, 7 дней) и инфляция ( 30 дней)

  • Анализ реальной стоимости долга с учётом инфляции: вклад vs досрочка, сигнал рефинансирования

  • Осознанные компромиссы по точности в пользу производительности и простоты

Главное открытие: этого проекта лично для меня: за годы моего отсутствия Android-разработка стала не только быстрее, но и банально человечнее. Там, где раньше приходилось воевать с XML-разметкой и жизненным циклом фрагментов, теперь можно просто описывать логику и наслаждаться результатом

Приложение Гасим доступно в RuStore бесплатно.

ссылка на оригинал статьи https://habr.com/ru/articles/1027196/