Когда я начал делать кредитный трекер, казалось, что финансовая математика — самая простая часть проекта. Формула аннуитета есть в любом учебнике, 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>"""Используется для двух целей:
Анализ: вклад vs досрочка — если ставка долга ниже ключевой, свободные деньги могут быть эффективнее на депозите
Сигнал рефинансирования — если ставка долга превышает ключевую более чем на 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.
Что намеренно упрощено
Честно о том, чего нет:
Возможность | Статус |
|---|---|
Учёт реальных дней в месяце при расчёте % | Нет, всегда |
Несколько досрочных платежей с накоплением | Нет, только разовая «снежинка» |
Плавающая ставка (ипотека с ЦБ+%) | Нет |
Учёт инфляции во времени | Частично — показываем реальную стоимость долга (ставка − инфляция), но симуляция погашения в номинальных рублях |
Для личного трекера, где главная цель — мотивировать пользователя гасить долги — этого достаточно. Точность «до копейки» здесь не нужна: нужна честная оценка и понятный интерфейс.
Тестирование
Юнит-тесты алгоритма и мой фаворит — Compose UI-тесты. Вместо того чтобы прокликивать всё приложение, я просто рендерю Composable-функцию через createComposeRule. Это позволяет проверить реакцию UI на специфические данные (например, огромные суммы или пустые списки) за считанные секунды в полной изоляции от Activity и навигации
Итог
Кредитный калькулятор, который делает больше чем одну формулу — это:
Итеративная симуляция погашения по месяцам
Два вида переплаты: по договору и реальная (со скрытыми комиссиями)
Реактивный пересчёт предстоящих платежей от текущего баланса через Room Flow
Два режима досрочного погашения с разной математикой
Два потока данных с ЦБ РФ: ключевая ставка (SOAP, 7 дней) и инфляция ( 30 дней)
Анализ реальной стоимости долга с учётом инфляции: вклад vs досрочка, сигнал рефинансирования
Осознанные компромиссы по точности в пользу производительности и простоты
Главное открытие: этого проекта лично для меня: за годы моего отсутствия Android-разработка стала не только быстрее, но и банально человечнее. Там, где раньше приходилось воевать с XML-разметкой и жизненным циклом фрагментов, теперь можно просто описывать логику и наслаждаться результатом
Приложение Гасим доступно в RuStore бесплатно.























