Схемы Что нового

Деньги клиента

Лицевые счета и финансовые операции клиента, обещанный платёж и блокировки: как устроены, как с ними работать и что необратимо.

Содержание раздела

Обещанный платёж — назначение и роль

Содержание раздела

Обещанный платёж (ОП) — короткий «кредит» клиенту: биллинг временно увеличивает его баланс, чтобы интернет работал ещё несколько дней до фактической оплаты. Реализован через Usluga id=30353 с system_type=11. Ключевые поля клиента: Abonents.promise_pay (сумма ₽) и promise_date_end (дата истечения).

Что такое ОП и зачем нужен

Сценарий типичного использования:

  1. Клиент в командировке, ушёл в минус по балансу. Биллинг блокирует (b_negbal);
  2. Клиент через ЛК / SMS / звонок в поддержку запрашивает ОП;
  3. Биллинг временно зачисляет ему ₽ на 5 дней (срок настраивается);
  4. Финансовая блокировка снимается, интернет возвращается через CoA Disconnect;
  5. Через 5 дней клиент пополняет счёт. Иначе ОП истекает, баланс возвращается к минусу, блокировка повторяется.

Экономика

ПараметрЗначениеГде настраивается
Лимитmax(месячная цена тарифа, |долг|+1₽) + комиссия с cap 2× тарифа + комиссияАвтоматически (calc_limit). Override: LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE
Длительность5 днейUsluga.end_count_days=5. Override: LK_PROMISE_PAY_MAX_DURATION_DAYS
Комиссия оператора90 ₽ разово при подключении ОП оператором (отдельная FinanceOperations op_type=9, списывается с лицевого счёта). Дневная плата 30 ₽/день отменена.PROMISE_PAY_OPERATOR_FEE (default 90; 0 = без комиссии)
Cooldown30 дней между двумя ОПLK_PROMISE_PAY_COOLDOWN_DAYS (default 30)
Min age30 дней с момента создания договораLK_PROMISE_PAY_MIN_AGE_DAYS (default 30)
Авто-погашение при оплатеЕсли YooKassa/W1/Generic webhook зачислил достаточно — ОП авто-отменяетсяLK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT (default 1)
Уведомление за N часовPush за 24ч до окончания + при истеченииMOBILE_PUSH_PROMISE_PAY_EXPIRE_HOURS (default 24)
Логика лимита: Если долг превышал цену — баланс не выходил в плюс и интернет не включался («активировал ОП, а интернета нет»). Теперь лимит масштабируется до покрытия долга, но ограничен 2× тарифа — защита от злоупотреблений через SMS. Если долг больше cap — клиент получает ОП, но баланс остаётся отрицательным; SMS-ответ предупреждает «Баланс X ₽ — недостаточно, требуется доплата».

Примеры (тариф 500 ₽):

Eligibility — 4 политики

Перед активацией _check_promise_pay_eligibility() в lk/views/promise_pay.py проверяет:

  1. LK_PROMISE_PAY_MIN_DEBT — минимальный долг (например, ОП доступен только при балансе < -100 ₽);
  2. LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE — макс. лимит ОП (если задан, перебивает расчёт по тарифу);
  3. LK_PROMISE_PAY_MIN_AGE_DAYS — минимальный «возраст» клиента (с момента create_date). Защита от мошеничества: создал договор и сразу взял ОП;
  4. LK_PROMISE_PAY_COOLDOWN_DAYS — пауза между ОП. Защита от циклов «брать ОП каждую неделю».

Все политики — настройки SystemSettings. Если значение = 0 — политика отключена. По умолчанию все включены.

Что происходит при активации

В одной транзакции:

  1. Создаётся UsersUsluga(usluga_id=30353, system_type=11, end_time=now+5дней, limit=<сумма ОП>);
  2. Создаётся FinanceOperations(op_type_id=31, op_summa=+limit, descr='[ЛК/SMS/Mobile] Обещанный платёж...');
  3. abonent.account.ostatok += limit (баланс пополняется);
  4. abonent.promise_pay = limit, abonent.promise_date_end = now+5дней — для бейджа в UI и баннера;
  5. Если был AbonentsBlock(b_negbal=true) — удаляется, Abonents.enabled=true;
  6. CoA Disconnect на NAS — текущая Reject-сессия разрывается, клиент переавторизуется, RADIUS отвечает Accept → интернет работает в течение секунд;
  7. Аудит — запись в AUDIT_OPERATIONS с подробной descr.

Каналы активации

КаналURL/действие
ЛК клиента/lk/promise_pay/ — кнопка с подтверждением
Mobile AppPOST /mobile-api/v1/finance/promise_pay + GET /finance/promise_pay
SMS-командаТекст «ОП» → биллинг шлёт условия → текст «ДА» в течение 10 мин
АдминкаКарточка клиента → вкладка «Услуги» → кнопка «+ Обещанный платёж» (modal с условиями)
REST API v2POST /rest_api/v2/promise_pay/ для внешних интеграций

Завершение ОП — 4 сценария

Есть четыре пути завершить активный ОП — все приводят к одному финалу: возврат лимита через обратную FinOp, очистка promise_pay/promise_date_end, удаление UsersUsluga, и при отрицательном балансе — авто-блок.

СценарийТриггерРеализация
A. Авто-погашение при оплате Webhook YooKassa/W1/Generic зачислил сумму, достаточную чтобы баланс после возврата лимита остался ≥0 lk/services/payment.py::_credit_abonent после успешного зачисления вызывает abonent.del_promise_pay(reverse_finance=True, reason='погашен оплатой ...'). Управляется LK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT
B. Истечение по таймеру Abonents.promise_date_end < now Celery beat check_expired_promises (раз в 15 мин). Вызывает del_promise_pay(reverse_finance=True, reason='истёк срок'). Если баланс < 0 — ставит b_negbal через abonent.block()
C. Ручное удаление через UI Оператор удаляет UU обещанного платежа (кнопка корзины во вкладке «Услуги») uslugi_delete_ajax детектирует system_type=11 или usluga_id ∈ {-3,-4} → выполняет тот же reverse-сценарий
D. Тогглер «Выкл.» во вкладке «Услуги» Оператор переключает UU.enabled с True на False uslugi_edit_ajax детектирует переход True→False для ОП-услуги → вызывает del_promise_pay(reverse_finance=True, reason='отключено вручную').

Комиссия оператора 90 ₽ (единый путь активации)

Прежняя модель «30 ₽/день» отменена. Сейчас при подключении ОП оператором берётся разовая комиссия — по умолчанию 90 ₽ (настройка PROMISE_PAY_OPERATOR_FEE, 0 = без комиссии).

Вся операторская логика ОП собрана в едином сервисе billing/services/promise_pay.py::activate_by_operator (build 2365). Тот же путь используют кнопка в карточке, дровер и массовое действие раздела «Должники». При активации в одной транзакции:

  1. Создаётся UsersUsluga(usluga_id=30353, countt=1) и FinanceOperations(op_type=31, +limit) — баланс пополняется на лимит;
  2. Если комиссия > 0 и есть лицевой счёт — отдельная FinanceOperations(op_type=9, −комиссия), ostatok уменьшается на сумму комиссии;
  3. Снимается b_negbal, интернет возвращается через CoA Disconnect;
  4. Аудит: «лимит N ₽, комиссия M ₽».

Самостоятельная активация клиентом (ЛК / мобильное / SMS) идёт без операторской комиссии.

Уведомления

Audit

Все операции с ОП пишутся в AUDIT_OPERATIONS с TABLE_NAME='USERS_USLUGA' через billing.services.audit_log.write_audit():

SMS-flow «ОП → ДА»: двухэтапная активация через SMS защищает от случайного нажатия. Сначала «ОП» проверяет 4 правила (eligibility), отправляет условия и сохраняет pending-state в Django cache на 10 минут. Только при получении подтверждения «ДА» (или «да», «yes», «ok», «+») биллинг активирует ОП. Pending-state хранится по последним 10 цифрам номера.

Сводка настроек ОП

Все настройки в SystemSettings (/admin/settings/system/):

КлючDefaultНазначение
LK_PROMISE_PAY_MIN_DEBT0Минимальный долг (₽) для активации. 0 = выкл
LK_PROMISE_PAY_MIN_AGE_DAYS30Минимальный возраст клиента (дни)
LK_PROMISE_PAY_COOLDOWN_DAYS30Интервал между двумя ОП (дни)
LK_PROMISE_PAY_MAX_DURATION_DAYS0Override длительности (0 = берём из Usluga.end_count_days)
LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE0Override лимита (0 = автоматический расчёт max(тариф, долг+1))
LK_PROMISE_PAY_DAILY_CHARGE1Списывать Usluga.price ежедневно через billing_worker
LK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT1Авто-погашение при достаточной оплате
LK_PROMISE_PAY_NOTIFY_BEFORE_HOURS24Окно уведомления перед истечением

Defaults применяются миграцией 0127_promise_pay_defaults. UPSERT — существующие значения не перезаписываются, на проде с 0 (выкл) останется 0 пока админ сам не включит.

Блокировки — назначение и роль

Содержание раздела

AbonentsBlock — таблица записей о блокировках клиента. Один клиент может иметь одну активную запись с несколькими булевыми флагами (один флаг = одна причина блокировки). Хранится в ABONENTS_BLOCK.

Три типа блокировок

ФлагКто ставитКогда
b_negbal 🔴 ФинансоваяСистема (Celery / accounts_payment_ajax)Баланс ушёл ниже LIMIT на счёте
b_admin 🟠 АдминистративнаяОператорРучное действие — нарушение договора, спам, заявление клиента
b_own 🟡 ДобровольнаяСам клиентЧерез ЛК / SMS-команду «ВЫКЛ» — пауза услуг (отпуск, командировка)

Блокировка vs Abonents.enabled

Это два разных механизма, иногда путают:

ПолеЧто этоКто меняет
Abonents.enabledГлавный «выключатель» клиента. Если false — RADIUS отвечает Reject, услуги не работаютМетод block() ставит в false, unblock() — в true (если других блокировок нет)
AbonentsBlockЗапись о причине блокировки (флаги b_negbal/b_admin/b_own)process_blocks Celery, accounts_payment_ajax, ручные действия

Корректная схема — блокировка всегда сопровождает enabled=false. Рассинхрон («есть запись блокировки, но enabled=true» или наоборот) — это баг, чинится через метод process_blocks или ручную правку через UI.

Жизненный цикл блокировки

  1. Создание:
    • process_blocks (Celery, раз в 5 мин) — сравнивает OSTATOK с LIMIT у всех клиентов и создаёт b_negbal;
    • accounts_payment_ajax — мгновенно блокирует если расход через UI ушёл в минус;
    • Кнопка «Заблокировать» в карточке клиента — ставит b_admin;
    • Abonents.add_promise_pay() при истечении срока ОП — возвращает b_negbal если баланс остался отрицательный.
  2. Активные действия при блокировке (см. Abonents.block()):
    • Создаётся / обновляется запись в ABONENTS_BLOCK;
    • Abonents.enabled = false;
    • Все Users.enabled = false (учётки) → RADIUS отвечает Reject при следующей авторизации;
    • На все NAS параллельно отправляется CoA Disconnect (см. ниже) — текущие сессии разрываются;
    • Пишется аудит-запись в AUDIT_OPERATIONS с table_name='ABONENTS_BLOCK';
    • Опционально — IPTV sync (отключить пакеты в TVIP / LFStream).
  3. Снятие:
    • process_blocks при пополнении баланса убирает b_negbal;
    • accounts_payment_ajax — мгновенно снимает b_negbal когда баланс >= 0;
    • Кнопка «Разблокировать» в карточке — снимает b_admin;
    • SMS-команда «ВКЛ» снимает b_own;
    • Если все флаги в записи стали false — AbonentsBlock удаляется, Abonents.enabled=true, услуги возвращаются.

CoA Disconnect — что происходит с активной сессией

Чтобы блокировка отрабатывала немедленно (а не через 5-15 мин когда сессия сама закроется), биллинг шлёт CoA Disconnect:

Без CoA Disconnect блокировка работала бы только при следующей перерегистрации клиента — а это иногда раз в сутки. С CoA — за 5-10 секунд.

Аудит блокировок: все блокировки и снятие блокировок теперь пишутся в AUDIT_OPERATIONS через Abonents.block / Abonents.unblock(). Все события блокировок видны во вкладке «Аудит» карточки клиента.

Лицевые счета — назначение и роль

Содержание раздела

Лицевой счёт — это финансовый «кошелёк» клиента в биллинге. Хранит баланс, бухгалтерские дебет/кредит, лимит блокировки, валюту. Хранится в таблице admin_accounts.

Зачем нужен лицевой счёт

Счёт решает четыре задачи:

  1. Учёт денег клиента — пополнения и списания идут через счёт. Текущий баланс — поле OSTATOK;
  2. Финансовая блокировка по порогу — если OSTATOK < LIMIT, Celery-задача process_blocks блокирует клиента (см. Блокировки);
  3. Бухучёт — поля DEBIT (поступления) и CREDIT (расходы) накапливают полные суммы для отчётности;
  4. Группа клиентов на одном счёте — можно привязать несколько Abonents к одному AdminAccounts (через Abonents.ACCOUNT_ID) для семейных тарифов или корпоративных схем.

Поля и связи

ПолеНазначение
OSTATOKТекущий баланс (×10^10, см. ниже)
DEBITСумма всех поступлений за всё время
CREDITСумма всех списаний за всё время
LIMITМинимально допустимый баланс (если UNLIMITED=false) — ниже идёт блокировка
UNLIMITEDБезлимитный счёт (не блокировать по балансу)
WARNПорог предупреждения: при OSTATOK < WARN отправляется уведомление
BONUS_SCOREБонусные баллы (программа лояльности)
CURRENCY_IDВалюта счёта (FK → currencies)
SLEEP_DATE«Заморозить» счёт до даты (счёт неактивен)

DB_MONEY_KOEF — почему ×10^10

Все денежные поля биллинга (OSTATOK, DEBIT, CREDIT, LIMIT, op_summa в FINANCE_OPERATIONS, price/summa в usluga) хранятся как целые числа, умноженные на 10^10. Это позволяет избежать ошибок округления плавающей запятой и хранить суммы с точностью до 10 знаков после запятой.

Кто меняет баланс

ИсточникЧто делает
billing_worker (Celery)Ежемесячное списание услуг по тарифу (op_type=32). Минусит OSTATOK
accounts_payment_ajaxРучное пополнение/списание оператором через UI «Касса»
YooKassa / W1 webhooksАвтоматическое пополнение после онлайн-оплаты
add_promise_payАктивация ОП — плюс к балансу + UU 30353
stornoСторнирование операции — обратное движение баланса с флагом storno=true
Каждое изменение баланса сопровождается записью в FINANCE_OPERATIONS и AUDIT_OPERATIONS. Не правьте OSTATOK SQL-запросом — это нарушит инварианты OSTATOK = DEBIT - CREDIT и историю операций. Всегда через UI или сервисный слой (_create_finop_atomic).

Финансовые операции — назначение и роль

Содержание раздела

Финансовая операция — каждое движение денег по лицевому счёту: пополнение, списание, обещанный платёж, начисление бонуса. Хранится в таблице finance_operations.

Зачем нужны финоперации

  1. Журнал движения денег — каждое изменение баланса должно сопровождаться записью FinOp. Это аудиторский след: всегда понятно почему баланс такой, как сейчас;
  2. Отчётность — отчёты «сколько собрано за месяц», «топ должников», «расход на услуги» строятся через агрегаты по FinanceOperations;
  3. Сторнирование — при ошибке оператора можно создать обратную операцию, не теряя истории. Поле storno=true + связь related_operation_id на оригинал;
  4. Идемпотентность платежей — для онлайн-оплат через webhook (YooKassa/W1) сверяется уникальность по `[txn:<ID>]` в descr — повторный webhook не создаст дубль.

Поля и связи

ПолеНазначение
OP_IDPK операции
OP_DATEДата операции (когда деньги фактически прошли). Может отличаться от SYSTEM_DATE (когда запись создана)
OP_SUMMAСумма ×10^10 (положительная = приход, отрицательная = расход). Знак определяется типом операции через op_sign
OP_TYPE_IDFK → fin_types — категория операции (см. ниже)
ABONENT_IDFK → abonents — кому принадлежит операция
ACCOUNT_IDFK → admin_accounts — на какой лицевой счёт
OWNER_IDFK → auth_user — какой оператор провёл (NULL для автоматических операций)
DESCRСвободный текст с деталями. Для платежей содержит [txn:<ID>]
NUMBERНомер документа (квитанция, чек, № платёжки)
STORNOtrue, если эта операция — обратная сторно к другой
RELATED_OPERATION_IDFK на саму себя — связь с оригиналом для пары storno↔original
USLUGA_IDFK → usluga — какая услуга была списана/начислена (если применимо)
AA_OSTATOK / AA_DEBIT / AA_CREDITСнимок состояния счёта на момент операции (для отчётов)

Типы операций

Справочник fin_types определяет:

Основные типы (из чтения кода):

op_type_idНазначениеЗнак
31Обещанный платёж — активация ОП (плюс к балансу)+
32Тарифное списание (billing_worker, ежемесячно)−
другиеРучная проводка, единая касса, YooKassa, W1, корректировки и т.д. — справочник в /admin/settings/system/?tab=params±

Кто создаёт финоперации

ИсточникЧто делает
billing_worker (Celery, ежедневно)Списание тарифа (op_type=32) у всех активных клиентов
finops_list.py (UI «Финансовые операции»)Прямое CRUD оператором — модалка add/edit/delete
abonents.py:add_fin_op (карточка)Ручная проводка из карточки клиента кнопкой «+ Финоп»
accounts_list.py:_create_finop_atomicКасса — пополнение/корректировка через UI «Лицевые счета»
accounts_storno_last_ajaxСторно последней операции — кнопка в модалке счёта
lk/services/payment.py:_credit_abonentWebhook YooKassa/W1/Generic — пополнение после онлайн-оплаты
lk/views/promise_pay.pyАктивация обещанного платежа (op_type=31)
billing/services/sms_inbound.pySMS-команды (ОП через SMS)

Сторнирование

Сторно — это создание обратной операции, не удаление оригинала:

  1. Оператор открывает модалку счёта → клик «🔄 Сторно последней» (с подтверждением);
  2. Создаётся новая FinanceOperations с op_summa = -original.op_summa, тот же op_type, storno=true, related_operation_id = original.op_id;
  3. Оригинал помечается storno=true (для UI: зачёркнутый текст);
  4. Баланс счёта корректируется на сумму сторно;
  5. Аудит-запись фиксирует обе таблицы — FINANCE_OPERATIONS (с привязкой к клиенту) и ADMIN_ACCOUNTS (с привязкой к счёту).
Аудит всех операций. Все 5 точек создания/удаления (UI модалка, REST API, ChangeView, storno, billing_worker) обязательно создают запись в AUDIT_OPERATIONS с table_name='FINANCE_OPERATIONS' и object_id=op_id. Видно во вкладке «Аудит» карточки клиента с drill-down на саму операцию.

Счета

URL: /admin/Abonents/AdminAccounts/

Лицевой счёт (AdminAccounts) — кошелёк клиента. На нём лежат остаток, дебет, кредит, стоплата, лимиты и валюта. К одному счёту можно привязать нескольких клиентов (Abonents.account_id → AdminAccounts, FK PROTECT), а все финансовые операции ссылаются на счёт напрямую — поэтому история денег живёт своей жизнью и не рвётся при смене клиента.

Разделение простое: счёт отвечает на вопрос «сколько денег сейчас», финоперации — «откуда они взялись». На проде 6309 счетов.

Что такое счёт: карта понятий

- Лицевой счёт
  - Деньги
    - Остаток
    - Дебет
    - Кредит
    - Стоплата
    - Баланс = остаток + дебет − кредит
  - Правила
    - Порог предупреждения
    - Порог отключения
    - Безлимит и дата
    - Валюта
  - Связи
    - Клиенты много к одному
    - Финансовые операции
    - Банковские выписки
    - Организация через клиента
  - Действия
    - Пополнить
    - Корректировка
    - Сторно последней
    - Привязать отвязать клиента

Отсюда и главная путаница раздела: «остаток» и «баланс» — разные величины. Остаток — одно поле, баланс считается по формуле. В карточке счёта формула выведена прямо под крупной цифрой, чтобы не приходилось помнить.

Как деньги попадают на счёт

flowchart LR
    W["Webhook оплаты
ЮKassa · W1"] --> FO B["Банковская
выписка"] --> FO O["Оператор:
Пополнить"] --> FO C["Оператор:
Корректировка"] --> FO M["billing_worker
абонплата"] --> FO FO(["FinanceOperations"]) --> ACC["AdminAccounts.ostatok"] ACC --> BAL{"Баланс
отрицательный?"} BAL -->|"да"| BLK["Блокировка b_negbal
+ CoA Disconnect"] BAL -->|"нет"| OK["Доступ активен"] ACC --> LIM{"Ниже порога
предупреждения?"} LIM -->|"да"| MSG["SMS · Push
уведомление"]

Ни одна кнопка в разделе не меняет остаток «напрямую»: любое движение денег создаёт финоперацию, а остаток — её следствие. Поэтому удалить операцию нельзя без последствий, а для отмены есть сторно.

Список счетов

Список лицевых счетов: KPI, чипы, колонки Счёт / Клиент / Тариф (₽/м.) / Баланс / Должник с / Последняя операция
Список лицевых счетов: KPI, чипы, колонки Счёт / Клиент / Тариф (₽/м.) / Баланс / Должник с / Последняя операция

Цвет не единственный сигнал: рядом с балансом стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати.

Как счёт может быть «без клиента»

Лицевой счёт (AdminAccounts) и клиент (Abonents) — разные сущности. Обычная связь идёт от клиента к счёту (Abonents.account_id). Но есть и второй путь: отдельный видео-счёт модуля видеонаблюдения привязан к клиенту через VideoObject.account, а не через основной счёт.

Поэтому «без клиента» = на счёт не ссылается ни основной клиент, ни видео-объект. Видео-счета показывают клиента (с маркером видео) и не считаются сиротами — иначе реальные видео-счета клиентов попадали бы в «Удалить сироты». Настоящие сироты — это, как правило, тестовые/демо-записи или счёт, у которого клиента удалили жёстко.

flowchart LR
    AB["Клиент
Abonents"] -->|"account_id"| ACC["Лицевой счёт
AdminAccounts"] VO["Видео-объект
VideoObject"] -->|"abonent"| AB VO -->|"account (видео-счёт)"| ACC2["Отдельный
видео-счёт"] ACC2 -.->|"клиент через видео-объект"| AB ORPHAN["«Без клиента»"] -.->|"нет ни того, ни другого"| ACC3["Счёт-сирота"]

Скорость списка

Название тарифа считается по услугам тарифа, и str(tarif) ради этого ходит в БД дважды на каждый тариф. В списке это превращалось в 299 запросов на 100 строк и 2,7 с ответа, а выгрузка всех 6309 счетов в CSV занимала 47 секунд.

Теперь подписи считаются пачкой (Tarif.bulk_labels(ids)): те же две агрегации выполняются один раз на всю страницу.

ОперацияБылоСтало
Список, 100 строк299 запросов · 2 698 мс8 запросов · ~300 мс
CSV всех счетов47,3 с1,3 с

Мультиорганизация и изоляция доступа

У AdminAccounts нет своего поля организации — она берётся у клиента счёта. Поэтому «счёт без организации» означает «клиент без организации», и гарантия стоит на клиенте.

Кто какие счета видит

Сотрудник видит только счета своих организаций. Проверка стоит не только в списке, но и на каждом действии с конкретным счётом — иначе чужой счёт открывался бы подстановкой номера в адрес.

flowchart LR
    REQ["Запрос сотрудника"] --> ALLOW{"allowed_orgs"}
    ALLOW -->|"None — все"| FULL["без фильтра"]
    ALLOW -->|"свои орг."| SCOPE["filter_qs_by_org
через клиента"] FULL --> ACT{"действие"} SCOPE --> ACT ACT -->|"список"| L["accounts_json"] ACT -->|"карточка"| G["accounts_get"] ACT -->|"деньги"| P["payment · storno"] ACT -->|"массово"| BK["bulk · csv"] G --> GUARD["чужой счёт → 404"] P --> GUARD BK --> F403["чужой в списке → 403"]

Ответ на чужой счёт — 404, а не 403: одинаковая реакция на «нет такого» и «есть, но не ваш» не даёт перебором выяснить, какие счета существуют. Массовые действия отвечают 403, если хоть один выбранный счёт вне доступа, — молча пропустить часть выборки хуже, чем отказать целиком.

Карточка счёта

Открывается кликом по строке или по названию. Восемь вкладок слева, действия с деньгами — на первой. Параметр ?edit=<id> открывает карточку сразу при загрузке страницы (для ссылок из других разделов).

Карточка счёта — вкладка Баланс, вкладки «Фин. операции» и «Платеж. системы»
Карточка счёта — вкладка Баланс, вкладки «Фин. операции» и «Платеж. системы»
flowchart TB
    subgraph T["Вкладки карточки"]
      direction LR
      T1["① Баланс
деньги и действия"] T2["② Услуги и тарифы
ожидаемое списание"] T3["③ Платёжные системы
история по каналам"] T4["④ Параметры
лимиты и пороги"] T5["⑤ Клиенты
привязка"] T6["⑥ Финоперации
последние 50"] T7["⑦ Банк. выписки
связанные строки"] T8["⑧ Уведомления
SMS · Email · Push"] end

① Баланс

Крупная цифра баланса, под ней формула баланс = остаток + дебет − кредит, ниже — четыре составляющие и производные величины (бухгалтерский, на конец месяца). Три кнопки: Пополнить, Корректировка, Сторно последней.

② Услуги и тарифы

Карточка счёта — услуги и тарифы
Карточка счёта — услуги и тарифы

Услуги всех клиентов счёта с ценами и ожидаемым списанием за месяц. Каскад цены: uu.summ → usluga.summa → usluga.price. Бонусы (отрицательная сумма или «бонус» в названии) вынесены отдельно.

③ Платёжные системы

Карточка счёта — история платежей по каналам
Карточка счёта — история платежей по каналам

Приходы за 90 дней, сгруппированные по каналу (ЮKassa, Wallet One, банковская выписка, ручная проводка, EBS). Канал определяется по operator_name, а при пустом — по меткам в описании. Ниже — статус интеграций со ссылками на настройки: видно, настроен ли канал вообще.

④ Параметры

Карточка счёта — параметры и лимиты
Карточка счёта — параметры и лимиты

⑤ Клиенты

Карточка счёта — привязанные клиенты
Карточка счёта — привязанные клиенты

Привязанные клиенты с бейджем «основной» (первый по ID) и статусом. Кнопка Привязать открывает поиск; если клиент уже на другом счёте, появляется чекбокс Перенести — молча увести клиента с чужого счёта нельзя.

⑥ Финансовые операции

Карточка счёта — финансовые операции
Карточка счёта — финансовые операции

Последние 50 операций с иконкой источника и цветом по знаку; сторнированные зачёркнуты. Ссылка Все операции по счёту ведёт в Финансовые операции с фильтром по счёту.

⑦ Банковские выписки

Карточка счёта — банковские выписки
Карточка счёта — банковские выписки

Строки выписок, связанные с операциями счёта (BankStatementOperation.fin_operation_id): банк, плательщик, ИНН, назначение платежа. Нужна, когда клиент говорит «я платил через банк», а платёж не виден.

Пополнение, корректировка, сторно

Пополнение счёта
Пополнение — только приходные типы
Корректировка счёта
Корректировка — все типы, знак от типа

Под формой — предпросмотр: текущий баланс, сумма, новый баланс. Сервер выполняет всё в одной транзакции с SELECT FOR UPDATE на счёт, поэтому два оператора не «потеряют» одно из зачислений.

Мобильная версия

На телефоне у раздела другая задача: посмотреть баланс и принять оплату, а не разбирать журнал. Поэтому таблица не ужимается, а перестраивается.

Список счетов на телефоне
Список: поиск, фильтры, добавление — одной строкой
Карточка счёта на телефоне
Карточка — полноэкранная панель

Тёмная тема

Цвета раздела заданы токенами, поэтому тема переключается без отдельного набора правил.

Защита от удаления

Счёт удаляется только когда выполнены все условия: к нему не привязан ни один клиент, по нему нет ни одной финансовой операции и на него не ссылается видео-объект. Иначе сервер отвечает 400 с понятной причиной («Сначала отвяжите N клиентов…», «У счёта M операций — удаление запрещено для сохранения истории», «видео-объектов: K»).

Отдельная защита у видео-счетов: они принадлежат клиенту через VideoObject и не считаются сиротами, поэтому массовое «Удалить сироты» их не трогает даже при пустом балансе. См. Как счёт может быть «без клиента».

Смысл в том, что счёт — это история денег. Удалив его, вы теряете основание для всех прошлых начислений; в спорной ситуации восстановить картину будет нечем.

Аудит

Каждое действие пишется в AuditOperations (table_name='ADMIN_ACCOUNTS') с оператором и описанием изменения: создание и правка счёта (с diff вида «warn 100→200»), пополнение и корректировка (с op_id и типом), сторно, привязка и отвязка клиента, удаление (с балансом на момент удаления).

Endpoints

URLМетодНазначение
/admin/Abonents/AdminAccounts/GETСтраница списка
/admin/Abonents/accounts_json/GETСписок: q, chip, page, per_page, sort. Возвращает items, KPI и show_org
/admin/Abonents/accounts_get/<id>/GETСчёт + клиенты + 50 операций + 20 уведомлений + организация
/admin/Abonents/accounts_crud/[<id>/]POSTСоздание / правка / удаление (_delete=1)
/admin/Abonents/accounts_payment/<id>/POSTПополнение и корректировка
/admin/Abonents/accounts_storno/<id>/POSTСторно последней операции
/admin/Abonents/accounts_link/<id>/POSTПривязать клиента (force=1 — перенос с другого счёта)
/admin/Abonents/accounts_unlink/<id>/<abonent_id>/POSTОтвязать клиента
/admin/Abonents/accounts_services/<id>/GETУслуги и тарифы клиентов счёта
/admin/Abonents/accounts_payments_summary/<id>/GETПриходы по каналам + статус интеграций
/admin/Abonents/accounts_bank_ops/<id>/GETСвязанные строки банковских выписок
/admin/Abonents/accounts_bulk/POSTМассово: валюта / лимиты / удаление сирот
/admin/Abonents/accounts_csv/GETCSV (UTF-8 BOM, разделитель ;)

Все endpoint'ы, принимающие <id>, проверяют доступ к счёту по организации сотрудника.

Денежные правила

Суммы в БД хранятся как целое × 1010 (DB_MONEY_KOEF). Конвертация автоматическая в MoneyField: при отображении делится, при сохранении умножается. В UI и API — всегда рубли с двумя знаками.

Обслуживание

Обе идемпотентны — трогают только записи с пустой организацией. После перехода гарантии на уровень модели они нужны разве что после массового импорта.

Финансовые операции

URL: /admin/Abonents/FinanceOperations/

Финансовая операция (FinanceOperations, таблица finance_operations) — атомарная запись в журнале движения денег. Привязана к лицевому счёту (account_id → AdminAccounts) и к клиенту (abonent_id → Abonents). Тип операции (op_type → FinTypes) определяет знак (op_sign: +1 приход, −1 расход). Сумма хранится в БД как op_summa × 10^10.

Это самая большая таблица биллинга — на проде более 450 000 записей. Почти всё, что клиент видит в ЛК на вкладке «История», и всё, что бухгалтер сдаёт в отчётности, — строки отсюда.

Откуда берутся операции и на что влияют

Финоперации не создают «просто так»: каждая — след реального события. Обратная сторона в том, что операция почти никогда не существует в одиночку — она двигает баланс, а баланс двигает доступ клиента в сеть.

flowchart LR
    W["Webhook оплаты
ЮKassa · W1"] --> FO B["Банковская
выписка"] --> FO M["billing_worker
абонплата"] --> FO P["Обещанный
платёж"] --> FO O["Оператор
вручную"] --> FO E["EBS-импорт
история"] --> FO FO[("finance_operations")] --> BAL["ostatok
+= op_summa"] BAL --> BLK{"Баланс
отрицательный?"} BLK -->|"да"| B1["b_negbal
+ CoA"] BLK -->|"нет"| B2["Доступ активен"] FO --> KKM["Чек в ОФД
54-ФЗ"] FO --> LK["История в ЛК
и мобильном"]

Практический вывод: операцию нельзя «просто удалить». Удаление не откатывает остаток счёта автоматически и рвёт связь с банковской выпиской и чеком. Для отмены влияния используйте сторно (обратная операция + флаг storno=True) — см. Сторнирование.

Список операций

Финансовые операции — список с фильтрами и колонкой организации
Финансовые операции — список с фильтрами и колонкой организации

Раздел построен как кастомная страница с AJAX-загрузкой и модальным редактированием — иначе журнал такого размера не открыть за приемлемое время.

Цветовое кодирование. Цвет не единственный сигнал — рядом с суммой стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати:

Пагинация: почему курсор, а не OFFSET

Классический LIMIT/OFFSET заставляет PostgreSQL пройти и выбросить все предыдущие строки. На глубине 100 000 записей это 94 мс против 0,3 мс у курсора (замер EXPLAIN ANALYZE на проде). Поэтому подгрузка идёт по составному курсору (op_date, op_id) — составному потому, что даты повторяются и одной даты для однозначного «продолжи отсюда» не хватает.

sequenceDiagram
    participant JS as Список в браузере
    participant V as finops_list_json
    participant DB as PostgreSQL
    JS->>V: первая порция (page=1)
    V->>DB: ORDER BY op_date DESC, op_id DESC LIMIT 50
    DB-->>V: 50 строк + COUNT(*)
    V-->>JS: items + total + next_cursor
    Note over JS: оператор прокрутил вниз
    JS->>V: after_date + after_id (курсор)
    V->>DB: (op_date, op_id) < курсор LIMIT 50
    DB-->>V: 50 строк за 0,3 мс
    V-->>JS: items + next_cursor, total = -1

total = -1 на подгрузках — это не ошибка: общее число между порциями не меняется, поэтому COUNT(*) (около 0,12 с на 450 тыс. строк) считается только для первой порции, а клиент помнит прежнее значение.

Мультиорганизация

FinanceOperations.organization — денормализованный внешний ключ: он дублирует организацию клиента, чтобы фильтр по организации не делал JOIN на каждую из сотен тысяч строк.

Тот же бейдж выводится в шапке карточки операции, чтобы при разборе обращения было видно, к какой компании относится платёж.

Изоляция доступа

Скоупинг применяется не только к списку, но и к чтению и правке конкретной операции. Иначе сотрудник одной компании мог бы читать и менять чужие платежи простым перебором op_id в адресной строке.

flowchart LR
    REQ["Запрос"] --> ALLOW{"allowed_orgs"}
    ALLOW -->|"None"| FULL["без фильтра"]
    ALLOW -->|"свои орг."| SCOPE["filter_qs_by_org"]
    FULL --> ACT{"действие"}
    SCOPE --> ACT
    ACT -->|"список"| LIST["list_json"]
    ACT -->|"карточка"| DET["detail_json"]
    ACT -->|"правка"| CRUD["crud_ajax"]
    DET --> GUARD["чужая → 404"]
    CRUD --> GUARD
    CRUD --> ABCHK["чужой клиент → 403"]

Ответ на чужую операцию — 404, а не 403: одинаковое сообщение для «нет такой» и «есть, но не ваша» не позволяет перебором выяснить, какие номера операций существуют.

Открывается кликом по 👁 или по ячейке «Тип операции». Это карточка на две колонки: слева — сама операция и её связи, справа — клиент, снапшот счёта и аудит. Пустые секции скрываются: у миграционных записей из EBS обычно не заполнены кассовые и 1С-поля, и секция просто не рендерится.

Карточка финансовой операции — режим просмотра
Карточка финансовой операции — режим просмотра

Модалка v915 (variant B)

Двухрежимная карточка пришла на смену мини-форме: до неё была мини-форма из шести полей, по которой нельзя было разобрать обращение, не открывая полную форму Django.

Иконки источника

Рядом с описанием — цветная иконка, показывающая, откуда пришла операция. Источник вычисляет эвристика _resolve_source(op) в billing/views/finops_list.py по комбинации operator_name, меток в descr, usluga_id и op_type_id:

Чипы связей рядом с именем клиента (📎🔗💳💰) — флаги наличия usluga, fin_op, credit_type, operator_pay_id_str. В панели фильтров есть отдельный фильтр «Источник» со всеми восемью видами.

Режим просмотра

Клик по имени клиента в карточке открывает выезжающую панель клиента — можно посмотреть, кому принадлежит платёж, не теряя открытую операцию.

Режим редактирования

Карточка операции — режим редактирования со связями
Карточка операции — режим редактирования со связями

Та же сетка: слева форма основных полей, справа — связи операции через поля с поиском по мере ввода.

Создание операции

Создание финансовой операции
Создание финансовой операции

Кнопка «Добавить» открывает ту же карточку с пустой формой и текущей датой. Организация проставится автоматически из выбранного клиента.

Мобильная версия

На телефоне у раздела другая задача: не «работать с журналом», а быстро посмотреть последние платежи — например, стоя у клиента. Поэтому таблица не ужимается, а перестраивается.

Мобильный список операций
Список: дата, клиент, сумма
Лист фильтров на телефоне
Фильтры — в нижнем листе
Карточка операции на телефоне
Карточка — полноэкранная панель

Тёмная тема

Цвета раздела заданы токенами (tokens.css), поэтому тема переключается без отдельного набора правил. Суммы в тёмной теме используют осветлённые оттенки — тёмно-зелёный и тёмно-красный на тёмном фоне не проходят по контрасту.

Достоверность чисел и защита важных действий

Журнал рассчитан на большие объёмы: около 450 тысяч операций листаются без задержки, суммы хранятся без потери копеек. Доступ закрыт организацией — и к списку, и к каждой операции по прямой ссылке. Отдельно проработаны две вещи: достоверность и понятность чисел и защита действий, которые нельзя отменить.

Список финансовых операций
Список: колонка «Орг.» — эффективная организация, ⓘ у «Единой кассы»
Модалка операции с расшифровкой
Модалка: строка «Расшифровка» — оплачено / зачислено / комиссия
Дропдаун источника
Фильтр «Источник» — 8 типов с цветными значками

Восемь источников операции

Источник определяется на лету по комбинации сигналов (operator_name + метки в descr + op_type_id + связи), без отдельного поля в БД:

YooKassa
эквайринг, метка [txn:…]
Wallet One
эквайринг W1
Банковская выписка
метка [bank_op:N]
EBS (миграция)
operator_name=EBS
Обещанный платёж
usluga 30353 / −3 / −4
Ручная проводка
op_type 7 / 9
Ежемесячное списание
op_type 3 / 31 / 32
Смена тарифа
descr = «Смена тарифа…»
flowchart LR
    OP["Операция
FinanceOperations"] --> R{"_resolve_source"} R -->|"метка txn / оператор"| Y["YooKassa"] R -->|"оператор wallet"| W["Wallet One"] R -->|"метка bank_op"| B["Банковская выписка"] R -->|"оператор EBS"| E["EBS (миграция)"] R -->|"услуга ОП"| P["Обещанный платёж"] R -->|"тип 7 или 9"| M["Ручная проводка"] R -->|"тип 3 / 31 / 32"| MO["Ежемесячное"] R -->|"описание «Смена тарифа»"| TC["Смена тарифа"]

Сквозной drill-down до чека ОФД

Операцию можно проследить от письма банка до фискального чека и обратно — карточка v915 связывает все звенья:

flowchart LR
    IMP["Письмо-импорт
выписки"] <--> QUE["Очередь
модерации"] QUE --> FO["Финансовая
операция"] FO --> OFD["Чек ОФД
54-ФЗ"] FO -->|"обратная ссылка"| BOP["Банковская
операция"] BOP --> IMP

Пробитие чека: подтверждение и аудит

«Пробить чек» — необратимое действие: чек уходит в ОФД и оттуда в ФНС. Поэтому он требует подтверждения, а результат пишется в журнал аудита. Удаление операции подтверждается так же.

flowchart LR
    BTN["«Пробить чек»"] --> SC{"SmitConfirm
предупреждение"} SC -->|"Отмена"| X["ничего"] SC -->|"Пробить"| API["finops_fiscalize"] API --> FM["FiscalManager
в ОФД"] API --> AUD["AuditOperations"]

Достоверность и понятность чисел

Типы операций (FinTypes)

Справочник типов хранится в fin_types. Поле op_sign определяет знак суммы:

UI пополнения счёта (см. Счета → Пополнение) фильтрует список типов по op_sign > 0; UI корректировки показывает все типы, направление выбирает оператор.

Редактирование справочника

URL: /admin/dictionary/FinTypes/ (Справочники → Типы фин. операций).

Список типов финансовых операций
Список типов финансовых операций

Список с поиском, чипами-фильтрами по знаку и счётчиком использований в журнале. Создание и редактирование — в модальном окне с радиогруппой Приход (+1) / Нейтр. (0) / Расход (−1).

Модалка редактирования типа с предупреждением об использовании
Модалка редактирования типа с предупреждением об использовании

Поля: type_id (автоинкремент, read-only), op_name (название, до 256 символов), op_sign (+1 / 0 / −1), op_descr (описание), is_common (чекбокс «Основной» — ходовые типы показываются первыми, чтобы оператор не искал их среди 47 legacy-типов).

Защита удаления: тип нельзя удалить, если он использован хотя бы в одной операции. Причина не только в истории: часть type_id жёстко зашита в коде (23 — YooKassa в webhook, 32 — ежемесячное списание тарифа в billing_worker), и удаление сломает интеграции.

Аудит: создание, изменение и удаление пишутся в AuditOperations (table_name='FIN_TYPES').

Когда добавлять новый тип: при подключении новой платёжной системы (СБП, T-Bank) или появлении специфической бухгалтерской операции. Перед использованием нового type_id в коде убедитесь, что значение берётся из FinTypes.objects.get(...), а не зашито числом.

Связи и снапшот

Операция — узел, а не изолированная строка. Ниже — что к ней присоединено:

flowchart TB
    FO["FinanceOperations"]
    AB["Abonents
клиент"] --- FO AA["AdminAccounts
лицевой счёт"] --- FO FT["FinTypes
тип · op_sign ±1"] --- FO ORG["Organization
денорм."] --- FO FO --- U["auth_user
кто провёл"] FO --- US["Usluga
за что списано"] FO --- CT["CreditType
оплата по"] FO --- SELF["счёт-родитель
сторно"] BSO["строка
выписки"] --> FO FO --> KKM["AtolConfig
чек ОФД"]

Онлайн-касса (ФР)

Поля для интеграции с ОФД и кассовой техникой:

Настройки фискализации — в /admin/settings/atol_config/.

Внешние операторы

Для операций, пришедших через webhook платёжных систем (/admin/settings/payment/):

Идемпотентность по operator_pay_id_str: повторный webhook на ту же транзакцию не создаёт дубль. Это не теоретическая предосторожность — платёжные системы штатно повторяют доставку, пока не получат подтверждение.

Синхронизация с 1С

Endpoints

URLМетодНазначение
/admin/Abonents/FinanceOperations/GETHTML-страница списка
/admin/Abonents/finops_json/GETСписок: q, op_type, source, date_from, date_to, page, per_page, курсор after_date + after_id. Возвращает items, total, next_cursor, has_more, show_org
/admin/Abonents/finops_detail/<op_id>/GETПолный контекст карточки (операция, клиент, связи, выписка)
/admin/Abonents/finops_crud/[<op_id>/]POSTСоздание / изменение / удаление (_delete=1)
/admin/Abonents/finops_credit_types/?q=GETПодсказки: типы оплаты
/admin/Abonents/finops_staff_users/?q=GETПодсказки: сотрудники
/admin/Abonents/finops_uslugi/?q=GETПодсказки: услуги
/admin/Abonents/finops_search/?q=GETПоиск операции-родителя
/rest_api/v2/FinanceOperations/GET / POSTREST API v2 (CRUD)

Обслуживание данных

manage.py backfill_finops_org [--apply] — проставляет организацию операциям, у которых она пустая: сначала из организации клиента, затем организацию по умолчанию для остатка. Без --apply только показывает, что будет сделано.

Команда идемпотентна — трогает только записи с пустой организацией. Это осознанно: у части операций организация уже проставлена верно и отличается от организации по умолчанию, и «проставить всем одну» затёрло бы их и сломало изоляцию доступа.

Чем отличается от Журнала платежей

В системе два раздела поверх одной таблицы finance_operations:

Разница только в UI и наборе фильтров — данные одни и те же.