Перейти к основному содержимому

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, доплаты, прибыль).

Вопрос: где и как хранить бухгалтерский реестр доходов/расходов и как гарантировать, что категоризация не теряет и не задваивает деньги относительно источника.

Рассмотренные варианты​

  1. Своя таблица реестра (persist). Отдельная сущность строк дохода/расхода с миграцией, наполняемая фоновой синхронизацией из оцифровки. − Дублирует уже сохранённые данные оцифровки; требует синхронизации, инвалидации и разрешения рассинхронов; миграция и фоновый воркер на «не первоочередную» задачу.
    • Быстрая выборка предагрегированного реестра.
  2. 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 (доходы/расходы по категориям, разрезы по периодам).