0039. Учёт доходов и расходов: derive-on-read поверх оцифровки
Копия ADR из репозитория:
backend/docs/adr/0039-accounting-income-expense.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-07-11
- Связанные задачи: OP-151 (5.7 · Бухгалтерия). Потребитель API — OP-155.
Контекст и проблема
ТЗ 5.7 требует учёт доходов и расходов селлера по категориям со сверкой с
данными оцифровки. Финансовые данные уже собраны и нормализованы модулем
оцифровки (OP-54..57, ADR-0016..0019): строки отчёта о реализации WB
(wb_report_lines) и P&L по артикулам (WbFinancialReportService.profitByArticle
→ ArticleProfit: выручка, начисления по типам ChargeType, доплаты, прибыль).
Вопрос: где и как хранить бухгалтерский реестр доходов/расходов и как гарантировать, что категоризация не теряет и не задваивает деньги относительно источника.
Рассмотренные варианты
- Своя таблица реестра (persist). Отдельная сущность строк дохода/расхода с
миграцией, наполняемая фоновой синхронизацией из оцифровки.
− Дублирует уже сохранённые данные оцифровки; требует синхронизации, инвалидации
и разрешения рассинхронов; миграция и фоновый воркер на «не первоочередную» задачу.
- Быстрая выборка предагрегированного реестра.
- Derive-on-read (вычисление на чтение). Категоризация считается по запросу из
P&L оцифровки; своего хранилища нет.
- Единый источник истины (оцифровка), нет рассинхрона, нет миграции/воркера.
- Тонкий, чисто-функциональный слой (легко тестируется). − Стоимость вычисления на каждый запрос (для 5.7 несущественно; период уже ограничен и данные предагрегированы оцифровкой).
Решение
Выбран derive-on-read (вариант 2). Новый модуль accounting — тонкая надстройка
над оцифровкой без собственного хранилища и эндпоинтов:
- Таксономия
AccountingCategory(доходы: выручка, доплаты; расходы — проекцияChargeType: комиссия/логистика/штрафы/эквайринг/хранение/удержания) с исчерпывающими типизированными картами (новыйChargeTypeне соберётся без явного маппинга). - Чистая
buildLedger(ArticleProfit[])— свёртка по категориям в целых копейках (как агрегаторы оцифровки; без float-дрейфа). - Сверка как инвариант:
reconcileLedgerпроверяетnet === Σ ArticleProfit.profit. Инвариант держится по построению (profit = выручка − затраты + доплаты), поэтому расхождение однозначно указывает на потерянную/задвоенную статью категоризации — логируетсяwarn. - Разделение ответственности:
accounting— доменное ядро (категоризация + сверка); клиентский API бухгалтерских отчётов вынесен в OP-155 (там же — Swagger и проверка контракта).
Последствия
- Положительные: нет дублирования данных и рассинхрона; нет миграции/воркера;
самопроверяющаяся категоризация; чистые функции с полным покрытием тестами;
переиспользование денежных хелперов и
ChargeTypeоцифровки. - Отрицательные / компромиссы: реестр вычисляется на каждый запрос (приемлемо для
5.7); знаковые доплаты (
additionalPayment) отнесены к доходам целиком — при отрицательном значении уменьшают итог доходов (нюанс представления для OP-155, сальдо остаётся корректным). - Дальше: OP-155 — клиентский API отчётов поверх
AccountingService(доходы/расходы по категориям, разрезы по периодам).