Бэкенд для учёта подписок и регулярных платежей. Создаёт обязательства, считает даты списаний, сам меняет статусы и шлёт уведомления о ближайших списаниях.
Стек: Java 21, Spring Boot, PostgreSQL, Flyway, Docker Compose. Схема БД описана в database/README.md.
Нужен только Docker. Все команды — через make.
make run-all
Что делает: проверяет docker, создаёт .env из .env.example если его нет, собирает и поднимает два контейнера — базу и бэкенд. База стартует первой, бэкенд ждёт её готовности.
Миграции применяются сами. Папка database/migration монтируется в контейнер бэкенда, при старте Flyway накатывает V1__init.sql и создаёт таблицы. Руками ничего создавать не надо.
После старта:
- Swagger — http://localhost:8080/docs
- API — http://localhost:8080/obligations
Другие команды:
make show-logs— логи.make show-status— статус контейнеров.make open-psql— консоль psql в базе.make stop-all— остановить.make clean-all— остановить и удалить данные.
make run-tests
Тесты гоняются в контейнере с Gradle, локальный Java не нужен. Базы в тестах нет — репозиторий замокан через Mockito, Spring не поднимается.
Почему lazy expiry не трогает рекуррентные. Перед выдачей списка GET /obligations все active с датой в прошлом становятся expired. Но только разовые (recurrence == null). У подписки просроченная дата не значит, что она кончилась — деньги всё равно списываются, пользователь просто не нажал «Оплачено». Поэтому подписка остаётся active, а дату правит оплата.
Как считаются даты при рекуррентности. При оплате дата сдвигается от текущего next_payment_date, а не от дня оплаты — иначе при просрочке копится ошибка. Сдвиг: monthly +1 месяц, quarterly +3 месяца, yearly +1 год. Считает LocalDate, сторонних библиотек нет. Если числа в месяце нет — берётся последний день: 31 января +1 месяц = 28 февраля (или 29 в високосный год), 31 марта +1 месяц = 30 апреля. Разовое при оплате не сдвигается, а закрывается в cancelled.
Какие тесты. Ленивое истечение и исключение для подписок. Оплата для каждого recurrence и для null. Оплата 31-го числа при monthly. Оплата и отмена не-active обязательства. Создание дубля с warning в ответе.
Компромиссы и что бы улучшил.
- Время берётся через
LocalDate.now(). Вынес бы вClockдля детерминированных тестов на граничные даты. - Lazy expiry перебирает записи в памяти и сохраняет по одной. На больших объёмах заменил бы на один пакетный
UPDATE. - SSE держит подписчиков в памяти процесса. Для нескольких инстансов нужен внешний брокер.
- Курсы валют не считаются,
totalsгруппирует по валюте как есть — так требует ТЗ. - Тесты только на уровне сервиса. Добавил бы HTTP-тесты через MockMvc на коды 404/422 и формат SSE.
Enum передаются именами в верхнем регистре: category — SUBSCRIPTION, WARRANTY, BILL, INSURANCE; recurrence — MONTHLY, QUARTERLY, YEARLY или null. Тела в snake_case.
Создать. Если дата в прошлом — сразу expired, не ошибка.
curl -X POST http://localhost:8080/obligations \
-H 'Content-Type: application/json' \
-d '{
"title": "Яндекс.Плюс",
"amount": 299.00,
"currency": "RUB",
"category": "SUBSCRIPTION",
"recurrence": "MONTHLY",
"next_payment_date": "2026-08-01"
}'
Если активное обязательство с таким title уже есть — запись создаётся, но в ответе есть warning.
{
"obligation": { "...": "созданная запись" },
"warning": "Активное обязательство с таким названием уже существует"
}
Список с фильтрами. Оба параметра опциональны, работают вместе, сортировка по next_payment_date.
curl 'http://localhost:8080/obligations?category=SUBSCRIPTION&status=ACTIVE'
Ближайшие списания за N дней (по умолчанию 7). В ответе — список, суммы по валютам и отдельно renewal_alerts (только подписки).
curl 'http://localhost:8080/obligations/upcoming?days=30'
{
"obligations": [ "..." ],
"totals": { "RUB": 299.00 },
"renewal_alerts": [ { "id": "...", "title": "Яндекс.Плюс", "next_payment_date": "2026-08-01", "amount": 299.00, "currency": "RUB" } ]
}
Оплатить. Пишет строку в payments, сдвигает дату или закрывает разовое. Не-active → 422.
curl -X POST http://localhost:8080/obligations/{id}/pay
Отменить. Только из active, иначе 422.
curl -X PATCH http://localhost:8080/obligations/{id}/cancel
Удалить. В любом статусе, вместе с платежами, ответ 204.
curl -X DELETE http://localhost:8080/obligations/{id}
Подписаться на события. После каждого DELETE в поток падает obligation_deleted.
curl -N http://localhost:8080/obligations/events
event: obligation_deleted
data: {"type":"obligation_deleted","id":"..."}