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