Когда я начал делать кредитный трекер, казалось, что финансовая математика — самая простая часть проекта. Формула аннуитета есть в любом учебнике, 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.0
val annuityPayment = if (r > 0)
    balance * r / (1 - (1 + r).pow(-termMonths.toDouble()))
else
    balance / termMonths

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

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

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

// CalcPayoffUseCase.kt
while (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.kt
val 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.monthlyServiceFee
totalFeesPaid += balances[i] * (debt.annualInsurancePercent / 100.0 / 12.0)

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

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

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

// CalcLoanUseCase.kt — строит список PaymentRow с реальными датами
val cal = startCalendar.clone() as Calendar
val 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.kt
val 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.totalMonths
val 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.kt
val 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.0
val 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 бесплатно.

Комментарии (15)


  1. vadimr
    24.04.2026 07:12

    Какой кошмар.

    За вещественные числа в финансовых приложениях в реальной жизни могут побить палкой. Это не вопрос компромисса.


    1. isergeymd Автор
      24.04.2026 07:12

      В чем кошмар то)? Скажем для ипотеки на 20 лет погрешность будет меньше одной сотой копейки


      1. vadimr
        24.04.2026 07:12

        Как принято говорить в таких случаях, если нужно объяснять, то не нужно объяснять.


        1. isergeymd Автор
          24.04.2026 07:12

          Ну я согласен с посылом если бы это было бы действительно банковским приложением но в моем случае это не так


          1. SpiderEkb
            24.04.2026 07:12

            А что это в вашем случае? Рисователь каких-то цифирек? А потом пользователь будет удивляться почему ваш калькулятор показывает одно, а банк "немножко другое"?

            Если бы беретесь за какие-то финансовые расчеты - делайте это по правилам, принятым для финансовых расчетов. Или не делайте вообще.


        1. Kwisatz
          24.04.2026 07:12

          А попробуйте, например мне, занимающемуся мультивалютными расчетами высокой точности в реальном времени на яваскрипте

          PS оперативно вы, но если по существу сказать нечего то на этом и закончим


          1. vadimr
            24.04.2026 07:12

            Не подскажете, в какой финансовой организации это происходит? Просто чтобы знать.

            P.S. По существу вам ваша главбух должна была это объяснить.


      1. Klenov_s
        24.04.2026 07:12

        На фоне того, что вы не считаете количество дней в месяце (а большинство банков считает именно по дням), а просто делите ставку на 12, такая точность нафиг не нужна.


        1. isergeymd Автор
          24.04.2026 07:12

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


  1. SpiderEkb
    24.04.2026 07:12

    По поводу математики - все суммы в банковских БД обычно хранятся в миноритарных единицах. Формат полей - типы с фиксированной точкой (обычно - decimal, есть еще numeric, но там внутренне представление другое, decimal компактнее).

    В подавляющем большинстве случаев хватает decimal(15, 0), в исключительных - decimal(23, 0).

    В языках, разработанных специально для коммерческих вычислений (COBOL, RPG...) эти типы нативно поддерживаются самим языком (безо всяких костылей типа BigDecimal). В RPG decimal -> packed, numeric -> zoned. Естественно, что там же поддерживается и вся арифметика, включая операции с бухгалтерским округлением (в RPG просто пишем eval(h) a = b / c вместо a = b / c)

    Работа с float/double, а еще итеративная, может преподнести забавные сюрпризы (см. рекуррентное соотношение Мюллера).

    Вообще, про коммерческие вычисления было уже

    Заложники COBOL и математика. Часть 1

    Заложники COBOL и математика. Часть 2

    Также следует обратить внимание на возможность переполнений. В системах, ориентированных именно на коммерческие вычисления переполнение вызывает системное исключение (как деление на ноль). Вам же придется за этим следить руками. Обычно объявляется временная переменная с максимально поддерживаемой размерностью, результат заносится туда, потом проверяется, не превышает ли он размерности целевой переменной и только в случае положительного ответа уже переносится в целевую.

    Это если все делать правильно.


    1. Gromilo
      24.04.2026 07:12

      С одной стороны я согласен, с другой стороны, можно ли на практике получить хоть сколь-нибудь существенные отклонения считая в даблах?

      Тут шагов то 50*12 = 600 максимум и никаких экстремальных сум.


      1. vadimr
        24.04.2026 07:12

        Отклонение в сумме платежа в одну копейку достаточно для получения большого штрафа или другого наказания. Тем более если речь идёт о погашении кредита.


        1. isergeymd Автор
          24.04.2026 07:12

          Вы правы для платежной системы отклонение в копейку критично, но из моего приложение нельзя отправлять платеж в банк, это все во лишь помощник/визуализатор


    1. isergeymd Автор
      24.04.2026 07:12

      Согласен, для банковского бэкенда и учета реальных денег Double это табу. Там только Long в копейках или Decimal.

      Но тут выбрал другой компромисс. На дистанции в 240 месяцев погрешность Double меньше сотой доли копейки. Для стратегии какой кредит гасить первым этой точности за глаза, зато код в Room и во ViewModel остается лаконичным и быстрым.

      За ссылки на COBOL спасибо)


      1. SpiderEkb
        24.04.2026 07:12

        Тогда надо сразу предупреждать крупными буквами что вычисления приблизительные, не соответствуют банковским правилам. Только оценка.

        За ссылки на COBOL спасибо)

        Да не за что :-)

        Если интересно, есть еще. Уже мое (почти 9 лет в этом кручусь, правда, моя тема комплаенс, клиентские данные всякие террористы-экстремисты, санкции, риски и т.п., счета-платежи практически не бывает в работе, этим другие команды занимаются).

        Современный RPG — что может и зачем нужен

        Способы работы с БД DB2 в языке RPG на платформе IBM i

        Экзотика, но ы РФ на этом работали (на уровне центральных серверов и ядра АБС) Райф, Росбанк, Альфа (и Альфа-Страхование), Ак-Барс. Мы (Альфа) пока еще на ней продолжаем, про остальных не в курсе.

        Какое-то время назад таки машины были еще в ПФР и РЖД. Сейчас не в курсе что там, скорее всего нет уже.