Деньги клиента
Лицевые счета и финансовые операции клиента, обещанный платёж и блокировки: как устроены, как с ними работать и что необратимо.
Содержание раздела
- Обещанный платёж — назначение и роль
- Блокировки — назначение и роль
- Лицевые счета — назначение и роль
- Финансовые операции — назначение и роль
- Счета
- Что такое счёт: карта понятий
- Как деньги попадают на счёт
- Список счетов
- Мультиорганизация и изоляция доступа
- Карточка счёта
- Мобильная версия
- Тёмная тема
- Защита от удаления
- Аудит
- Endpoints
- Денежные правила
- Обслуживание
- Смежные разделы
- Финансовые операции
- Откуда берутся операции и на что влияют
- Список операций
- Мультиорганизация
- Карточка операции
- Мобильная версия
- Тёмная тема
- Достоверность чисел и защита важных действий
- Типы операций (FinTypes)
- Связи и снапшот
- Онлайн-касса (ФР)
- Внешние операторы
- Синхронизация с 1С
- Endpoints
- Обслуживание данных
- Чем отличается от Журнала платежей
- Смежные разделы
Обещанный платёж — назначение и роль
Содержание раздела
Обещанный платёж (ОП) — короткий «кредит» клиенту: биллинг временно увеличивает его баланс, чтобы интернет работал ещё несколько дней до фактической оплаты. Реализован через Usluga id=30353 с system_type=11. Ключевые поля клиента: Abonents.promise_pay (сумма ₽) и promise_date_end (дата истечения).
Что такое ОП и зачем нужен
Сценарий типичного использования:
- Клиент в командировке, ушёл в минус по балансу. Биллинг блокирует (
b_negbal); - Клиент через ЛК / SMS / звонок в поддержку запрашивает ОП;
- Биллинг временно зачисляет ему ₽ на 5 дней (срок настраивается);
- Финансовая блокировка снимается, интернет возвращается через CoA Disconnect;
- Через 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 = без комиссии) |
| Cooldown | 30 дней между двумя ОП | LK_PROMISE_PAY_COOLDOWN_DAYS (default 30) |
| Min age | 30 дней с момента создания договора | 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) |
Примеры (тариф 500 ₽):
- долг −7 ₽ → лимит 500 ₽ (тариф ≥ долга)
- долг −800 ₽ → лимит 801 ₽ (покрытие долга +1₽)
- долг −2000 ₽ → лимит 1000 ₽ (cap 2× тарифа упёрся, баланс остался −1000 ₽)
Eligibility — 4 политики
Перед активацией _check_promise_pay_eligibility() в lk/views/promise_pay.py проверяет:
LK_PROMISE_PAY_MIN_DEBT— минимальный долг (например, ОП доступен только при балансе < -100 ₽);LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE— макс. лимит ОП (если задан, перебивает расчёт по тарифу);LK_PROMISE_PAY_MIN_AGE_DAYS— минимальный «возраст» клиента (с моментаcreate_date). Защита от мошеничества: создал договор и сразу взял ОП;LK_PROMISE_PAY_COOLDOWN_DAYS— пауза между ОП. Защита от циклов «брать ОП каждую неделю».
Все политики — настройки SystemSettings. Если значение = 0 — политика отключена. По умолчанию все включены.
Что происходит при активации
В одной транзакции:
- Создаётся
UsersUsluga(usluga_id=30353, system_type=11, end_time=now+5дней, limit=<сумма ОП>); - Создаётся
FinanceOperations(op_type_id=31, op_summa=+limit, descr='[ЛК/SMS/Mobile] Обещанный платёж...'); abonent.account.ostatok += limit(баланс пополняется);abonent.promise_pay = limit,abonent.promise_date_end = now+5дней— для бейджа в UI и баннера;- Если был
AbonentsBlock(b_negbal=true)— удаляется,Abonents.enabled=true; - CoA Disconnect на NAS — текущая Reject-сессия разрывается, клиент переавторизуется, RADIUS отвечает Accept → интернет работает в течение секунд;
- Аудит — запись в
AUDIT_OPERATIONSс подробной descr.
Каналы активации
| Канал | URL/действие |
|---|---|
| ЛК клиента | /lk/promise_pay/ — кнопка с подтверждением |
| Mobile App | POST /mobile-api/v1/finance/promise_pay + GET /finance/promise_pay |
| SMS-команда | Текст «ОП» → биллинг шлёт условия → текст «ДА» в течение 10 мин |
| Админка | Карточка клиента → вкладка «Услуги» → кнопка «+ Обещанный платёж» (modal с условиями) |
| REST API v2 | POST /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). Тот же путь используют кнопка в карточке, дровер и массовое действие раздела «Должники». При активации в одной транзакции:
- Создаётся
UsersUsluga(usluga_id=30353, countt=1)иFinanceOperations(op_type=31, +limit)— баланс пополняется на лимит; - Если комиссия > 0 и есть лицевой счёт — отдельная
FinanceOperations(op_type=9, −комиссия),ostatokуменьшается на сумму комиссии; - Снимается
b_negbal, интернет возвращается через CoA Disconnect; - Аудит: «лимит N ₽, комиссия M ₽».
Самостоятельная активация клиентом (ЛК / мобильное / SMS) идёт без операторской комиссии.
Уведомления
- Активация: SMS-ответ + Push «ОП {N}₽ активирован до DD.MM.YYYY». Если cap упёрся и баланс остался отрицательным — добавляется «Баланс X ₽ — недостаточно, требуется доплата».
- За 24 часа до окончания:
push.promise_pay_expiringbeat-задача (раз в сутки) шлёт push «Обещанный платёж истекает через N ч». Окно настраивается черезMOBILE_PUSH_PROMISE_PAY_EXPIRE_HOURS(default 24). - В день истечения:
push.promise_pay_expiredbeat-задача шлёт push «Обещанный платёж истёк. Пополните баланс». - При погашении оплатой: уведомление об оплате (стандартное «На счёт зачислено …») + Audit-запись о закрытии ОП.
Audit
Все операции с ОП пишутся в AUDIT_OPERATIONS с TABLE_NAME='USERS_USLUGA' через billing.services.audit_log.write_audit():
- Активация через ЛК / SMS — «Обещанный платёж активирован через ЛК/SMS: {usluga.name} (#id, UU={pk}), лимит {N} ₽, до DD.MM.YYYY»;
- Завершение (любой из 4 сценариев) — «Обещанный платёж завершён ({reason}): {N} ₽, удалено UU={count}»;
- Запись попадает на вкладку Аудит → услуги карточки клиента.
Сводка настроек ОП
Все настройки в SystemSettings (/admin/settings/system/):
| Ключ | Default | Назначение |
|---|---|---|
LK_PROMISE_PAY_MIN_DEBT | 0 | Минимальный долг (₽) для активации. 0 = выкл |
LK_PROMISE_PAY_MIN_AGE_DAYS | 30 | Минимальный возраст клиента (дни) |
LK_PROMISE_PAY_COOLDOWN_DAYS | 30 | Интервал между двумя ОП (дни) |
LK_PROMISE_PAY_MAX_DURATION_DAYS | 0 | Override длительности (0 = берём из Usluga.end_count_days) |
LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE | 0 | Override лимита (0 = автоматический расчёт max(тариф, долг+1)) |
LK_PROMISE_PAY_DAILY_CHARGE | 1 | Списывать Usluga.price ежедневно через billing_worker |
LK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT | 1 | Авто-погашение при достаточной оплате |
LK_PROMISE_PAY_NOTIFY_BEFORE_HOURS | 24 | Окно уведомления перед истечением |
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.
Жизненный цикл блокировки
- Создание:
process_blocks(Celery, раз в 5 мин) — сравниваетOSTATOKсLIMITу всех клиентов и создаётb_negbal;accounts_payment_ajax— мгновенно блокирует если расход через UI ушёл в минус;- Кнопка «Заблокировать» в карточке клиента — ставит
b_admin; Abonents.add_promise_pay()при истечении срока ОП — возвращаетb_negbalесли баланс остался отрицательный.
- Активные действия при блокировке (см.
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).
- Создаётся / обновляется запись в
- Снятие:
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:
- Параллельно (
ThreadPoolExecutor) на ВСЕ enabled NAS — потому что биллинг может не знать через какой NAS реально идёт сессия; - NAS отвечает
Disconnect-ACKесли у него есть сессия с этим логином, илиDisconnect-NAKесли нет; - FreeRADIUS закрывает сессию, клиент видит обрыв линии;
- При попытке переподключения FreeRADIUS отвечает
Reject(Users.enabled=false); - Сессия закрывается в
RADIUS_SESSIONSсEND_REASON='Disconnect-ACK'.
Без CoA Disconnect блокировка работала бы только при следующей перерегистрации клиента — а это иногда раз в сутки. С CoA — за 5-10 секунд.
AUDIT_OPERATIONS через Abonents.block / Abonents.unblock(). Все события блокировок видны во вкладке «Аудит» карточки клиента.
Лицевые счета — назначение и роль
Содержание раздела
Лицевой счёт — это финансовый «кошелёк» клиента в биллинге. Хранит баланс, бухгалтерские дебет/кредит, лимит блокировки, валюту. Хранится в таблице admin_accounts.
Зачем нужен лицевой счёт
Счёт решает четыре задачи:
- Учёт денег клиента — пополнения и списания идут через счёт. Текущий баланс — поле
OSTATOK; - Финансовая блокировка по порогу — если
OSTATOK < LIMIT, Celery-задачаprocess_blocksблокирует клиента (см. Блокировки); - Бухучёт — поля
DEBIT(поступления) иCREDIT(расходы) накапливают полные суммы для отчётности; - Группа клиентов на одном счёте — можно привязать несколько
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 знаков после запятой.
- В БД:
50000000000(целое число, типnumeric); - В UI/коде:
5.00 ₽(после деления на10^10); - Константа:
DB_MONEY_KOEF = Decimal('10000000000')вbilling/models/admin_accounts.py,billing/services/sms_inbound.pyи других местах; - Кастомное поле
MoneyField(наследуется отDecimalField) — при вводе формы умножает на коэф, при отображении делит. UI работает в рублях, а БД хранит в «копейках × 10^8».
Кто меняет баланс
| Источник | Что делает |
|---|---|
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.
Зачем нужны финоперации
- Журнал движения денег — каждое изменение баланса должно сопровождаться записью FinOp. Это аудиторский след: всегда понятно почему баланс такой, как сейчас;
- Отчётность — отчёты «сколько собрано за месяц», «топ должников», «расход на услуги» строятся через агрегаты по FinanceOperations;
- Сторнирование — при ошибке оператора можно создать обратную операцию, не теряя истории. Поле
storno=true+ связьrelated_operation_idна оригинал; - Идемпотентность платежей — для онлайн-оплат через webhook (YooKassa/W1) сверяется уникальность по `[txn:<ID>]` в descr — повторный webhook не создаст дубль.
Поля и связи
| Поле | Назначение |
|---|---|
OP_ID | PK операции |
OP_DATE | Дата операции (когда деньги фактически прошли). Может отличаться от SYSTEM_DATE (когда запись создана) |
OP_SUMMA | Сумма ×10^10 (положительная = приход, отрицательная = расход). Знак определяется типом операции через op_sign |
OP_TYPE_ID | FK → fin_types — категория операции (см. ниже) |
ABONENT_ID | FK → abonents — кому принадлежит операция |
ACCOUNT_ID | FK → admin_accounts — на какой лицевой счёт |
OWNER_ID | FK → auth_user — какой оператор провёл (NULL для автоматических операций) |
DESCR | Свободный текст с деталями. Для платежей содержит [txn:<ID>] |
NUMBER | Номер документа (квитанция, чек, № платёжки) |
STORNO | true, если эта операция — обратная сторно к другой |
RELATED_OPERATION_ID | FK на саму себя — связь с оригиналом для пары storno↔original |
USLUGA_ID | FK → usluga — какая услуга была списана/начислена (если применимо) |
AA_OSTATOK / AA_DEBIT / AA_CREDIT | Снимок состояния счёта на момент операции (для отчётов) |
Типы операций
Справочник fin_types определяет:
type_id(PK),op_name(название для UI),op_sign(+1 = приход / −1 = расход),enabled
Основные типы (из чтения кода):
| 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_abonent | Webhook YooKassa/W1/Generic — пополнение после онлайн-оплаты |
lk/views/promise_pay.py | Активация обещанного платежа (op_type=31) |
billing/services/sms_inbound.py | SMS-команды (ОП через SMS) |
Сторнирование
Сторно — это создание обратной операции, не удаление оригинала:
- Оператор открывает модалку счёта → клик «🔄 Сторно последней» (с подтверждением);
- Создаётся новая
FinanceOperationsсop_summa = -original.op_summa, тот жеop_type,storno=true,related_operation_id = original.op_id; - Оригинал помечается
storno=true(для UI: зачёркнутый текст); - Баланс счёта корректируется на сумму сторно;
- Аудит-запись фиксирует обе таблицы —
FINANCE_OPERATIONS(с привязкой к клиенту) иADMIN_ACCOUNTS(с привязкой к счёту).
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-бейджи: Всего / Активных / Без клиента / Должников / Сумма балансов выборки. Кликабельны — работают как быстрый фильтр.
- Чипы: Должники (баланс < 0) · Без клиента · Плюс (баланс > 0). Повторный клик снимает фильтр.
- Поиск по ID, имени клиента, договору и тарифу; Ctrl+K ставит курсор в поле.
- Колонка «Орг.» — единый бейдж организации, см. Мультиорганизация.
- Колонка «Счёт» — только номер (
#N). Отдельной колонки «№» нет (номер и так виден). Деньги показывает колонка «Баланс». - Колонка «Тариф» — абонплата в формате
₽/м.без копеек (в биллинге нет цен с копейками). - Колонки «Должник с» и «Последняя операция» — дата ухода баланса в минус (
negative_since, с красной стрелкой) и дата последней финоперации по счёту (один агрегатMax(op_date)на всю страницу, без N+1). - Суммы без копеек: целые значения показываются без
.00, дробные — как есть. - Бесконечная прокрутка: первая порция 100 строк, дальше подгрузка при скролле.
- Массовые действия при выборе строк: установка лимитов, CSV выбранных, удаление «сирот».
- CSV-экспорт по текущим фильтрам.
- Справка — единая i-иконка (
SmitHelpIcon/.smit-help) в заголовке раздела ведёт сюда.
Цвет не единственный сигнал: рядом с балансом стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати.
Как счёт может быть «без клиента»
Лицевой счёт (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 нет своего поля организации — она берётся у клиента счёта. Поэтому «счёт без организации» означает «клиент без организации», и гарантия стоит на клиенте.
- Колонка «Орг.» показывается при двух условиях сразу: включена мультиорганизация и выбран режим «Все организации». При выбранной одной компании колонка вырождается в один и тот же бейдж.
- Тот же бейдж — в шапке карточки счёта, сразу после номера.
- Организация проставляется на уровне модели (
Abonents.save(),FinanceOperations.save()): записи создают мастер, быстрое добавление, импорт, webhook'и иbilling_worker— точечные проверки в каждом месте протекали, поэтому гарантия одна и обойти её нельзя. Порядок выбора: организация папки-родителя → организация по умолчанию (СмИТ).
Кто какие счета видит
Сотрудник видит только счета своих организаций. Проверка стоит не только в списке, но и на каждом действии с конкретным счётом — иначе чужой счёт открывался бы подстановкой номера в адрес.
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, а при пустом — по меткам в описании. Ниже — статус интеграций со ссылками на настройки: видно, настроен ли канал вообще.
④ Параметры
- Порог предупреждения — при пересечении уходит SMS/Push.
- Порог отключения — срабатывает автоблокировка (
b_negbal). - Безлимит + дата окончания: пока активен, автоблокировка не срабатывает.
- Валюта, бонусные баллы, сброс даты последнего оповещения.
⑤ Клиенты
Привязанные клиенты с бейджем «основной» (первый по ID) и статусом. Кнопка Привязать открывает поиск; если клиент уже на другом счёте, появляется чекбокс Перенести — молча увести клиента с чужого счёта нельзя.
⑥ Финансовые операции
Последние 50 операций с иконкой источника и цветом по знаку; сторнированные зачёркнуты. Ссылка Все операции по счёту ведёт в Финансовые операции с фильтром по счёту.
⑦ Банковские выписки
Строки выписок, связанные с операциями счёта (BankStatementOperation.fin_operation_id): банк, плательщик, ИНН, назначение платежа. Нужна, когда клиент говорит «я платил через банк», а платёж не виден.
Пополнение, корректировка, сторно
- Пополнение: список типов отфильтрован по
op_sign > 0— расходный тип сюда не подставишь. Поле ID транзакции даёт идемпотентность: повторный запрос с тем же идентификатором вернёт существующую операцию, а не зачислит деньги дважды. - Корректировка: те же поля, но типы все. Знак берётся из типа операции — положительная сумма при расходном типе инвертируется автоматически.
- Сторно последней: показывает реквизиты последней операции, создаёт обратную и помечает оригинал
storno=True. Повторно сторнировать ту же операцию нельзя.
Под формой — предпросмотр: текущий баланс, сумма, новый баланс. Сервер выполняет всё в одной транзакции с SELECT FOR UPDATE на счёт, поэтому два оператора не «потеряют» одно из зачислений.
Мобильная версия
На телефоне у раздела другая задача: посмотреть баланс и принять оплату, а не разбирать журнал. Поэтому таблица не ужимается, а перестраивается.
- Панель управления — одна строка: поиск, кнопка фильтров со счётчиком активных, зелёная кнопка добавления. Подписи у кнопок убраны: на 390 px они не помещаются рядом с полем поиска. Дубль-поиска в слайд-панели фильтров нет.
- Таблица списка на всю ширину экрана: карточка тянется к краям (full-bleed), без боковых полей — данные не жмутся в узкую колонку. Скрыт только чекбокс (массовых действий с телефона не делают).
- Таблицы внутри карточки счёта — карточками: «Клиенты», «Фин. операции», «Банк. выписки» на телефоне перестраиваются в карточки (подпись столбца слева), а не режутся по ширине.
- Имена не переносятся: длинное ФИО сокращается многоточием, полное — в подсказке.
- Карточка счёта открывается полноэкранной панелью, крестик — в правом верхнем углу, тач-цель 56 px.
- Полоса вкладок прокручивается к выбранной вкладке, чтобы было видно, что идёт дальше.
Тёмная тема
Цвета раздела заданы токенами, поэтому тема переключается без отдельного набора правил.
Защита от удаления
Счёт удаляется только когда выполнены все условия: к нему не привязан ни один клиент, по нему нет ни одной финансовой операции и на него не ссылается видео-объект. Иначе сервер отвечает 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/ | GET | CSV (UTF-8 BOM, разделитель ;) |
Все endpoint'ы, принимающие <id>, проверяют доступ к счёту по организации сотрудника.
Денежные правила
Суммы в БД хранятся как целое × 1010 (DB_MONEY_KOEF). Конвертация автоматическая в MoneyField: при отображении делится, при сохранении умножается. В UI и API — всегда рубли с двумя знаками.
Обслуживание
manage.py backfill_abonent_org [--apply]— проставляет организацию клиентам, у которых её нет (папка-родитель → организация по умолчанию). От неё зависит и организация счёта.manage.py backfill_finops_org [--apply]— то же для финансовых операций.
Обе идемпотентны — трогают только записи с пустой организацией. После перехода гарантии на уровень модели они нужны разве что после массового импорта.
Смежные разделы
- Финансовые операции — журнал, который двигает остаток счёта.
- Карточка клиента — владелец счёта, там же блокировки и услуги.
- Банковские выписки — автоматические зачисления из выписок.
- Платежи (API) — webhook'и, создающие операции по счёту.
- Обещанный платёж — временный кредит на счёте.
- Фискализация 54-ФЗ — чеки по зачислениям.
Финансовые операции
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-загрузкой и модальным редактированием — иначе журнал такого размера не открыть за приемлемое время.
- Бесконечная загрузка — постраничной навигации нет: список догружается по 50 строк при прокрутке (
IntersectionObserver). Для журнала, где ищут «последнее за сегодня», это быстрее, чем перелистывать страницы. - Период по умолчанию — текущий месяц. Устанавливается на загрузке, чтобы первый запрос не поднимал всю таблицу.
- Фильтры: текстовый поиск (клиент / описание / номер), тип операции, источник, диапазон дат с быстрыми периодами.
- Колонка «Орг.» — единый бейдж организации, см. Мультиорганизация.
- Клик по «Типу операции» открывает карточку операции — то же, что кнопка 👁. Целиться в широкую ячейку удобнее, чем в мелкую иконку.
- Клик по имени клиента открывает выезжающую панель клиента (не карточку операции).
Цветовое кодирование. Цвет не единственный сигнал — рядом с суммой стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати:
- 🟢 Зелёный бейдж со стрелкой ↓ — приход (
op_summa > 0). - 🔴 Красный бейдж со стрелкой ↑ — расход (
op_summa < 0). - ⚪ Серый — нулевая операция.
- Зачёркнутая строка — сторнированная (
storno=True).
Пагинация: почему курсор, а не 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 на каждую из сотен тысяч строк.
- Колонка «Орг.» в списке показывается только при выполнении двух условий: включена мультиорганизация и в шапке выбран режим «Все организации». При выбранной одной организации колонка вырождается в один и тот же бейдж и только съедает ширину.
- Бейдж рисует общий компонент
OrgBadge— тот же, что в тарифах и в карточке клиента. Сервер отдаёт толькоorg_id, а название, иконку и фирменный цвет клиент берёт из реестраwindow.SMIT_ORGS: лишнего 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:
- YooKassa —
operator_name='yookassa'или[txn:N]в описании - Wallet One —
operator_name='w1'или содержит «wallet» - Банковская выписка —
operator_name='bank'или[bank_op:N]в описании - EBS (миграция) —
operator_name='ebs', подавляющая часть исторических данных - Обещанный платёж —
usluga_id IN (30353, -3, -4) - Ручная проводка —
op_type_id IN (7, 9) - Ежемесячное списание —
op_type_id IN (3, 31, 32) - Смена тарифа — описание начинается с «Смена тарифа»
Чипы связей рядом с именем клиента (📎🔗💳💰) — флаги наличия usluga, fin_op, credit_type, operator_pay_id_str. В панели фильтров есть отдельный фильтр «Источник» со всеми восемью видами.
Режим просмотра
- Шапка — номер операции, цветная сумма, бейдж источника, бейдж «Сторно» при
storno=True, бейдж организации, кнопка закрытия. - Левая колонка: Операция (тип, дата, сумма, номер, описание, флаг сторно, ссылка на полную форму); Связи (ответственный, оплата по, услуга, счёт-родитель, счётчик дочерних операций); Платёжный шлюз; Банковская выписка (через
BankStatementOperation.fin_operation_id— банк, плательщик, ИНН, назначение платежа, файл выписки). - Правая колонка: Клиент (имя-ссылка, договор, тариф, баланс с цветом, статус блокировки с флагами
b_negbal,b_admin,b_own,b_sys); снапшот лицевого счёта на момент операции (aa_ostatok,aa_credit,aa_debit,balance_buh); Кассовый чек / 1С; Аудит.
Клик по имени клиента в карточке открывает выезжающую панель клиента — можно посмотреть, кому принадлежит платёж, не теряя открытую операцию.
Режим редактирования
Та же сетка: слева форма основных полей, справа — связи операции через поля с поиском по мере ввода.
- Клиент — поиск через
/rest_api/v2/Abonents/. Выбранный клиент проверяется на стороне сервера: подставить чужую организацию crafted-запросом нельзя. - Тип операции — справочник
FinTypes. - Ответственный за операцию (
owner) — из/admin/Abonents/finops_staff_users/. - Оплата по (
credit_type) — из/admin/Abonents/finops_credit_types/. - Связанная услуга (
usluga) — из/admin/Abonents/finops_uslugi/. Нужна для обещанного платежа, бонусов и сверки начислений. - Счёт на оплату (
fin_op, родительская операция) — из/admin/Abonents/finops_search/, с защитой от связывания операции самой с собой. - Комментарий администратора (
admin_comment) — заметка, не видимая клиенту.
Создание операции
Кнопка «Добавить» открывает ту же карточку с пустой формой и текущей датой. Организация проставится автоматически из выбранного клиента.
Управление
- Открыть полную форму — на
/admin/Abonents/FinanceOperations/<id>/с 30+ полями (банковские реквизиты, чек, операторские поля). - Удалить — через модалку подтверждения с предупреждением о балансе, дочерних операциях и связи с выпиской. Нативный
confirm()не используется. - Редактировать — переключение режима без перезагрузки данных.
Мобильная версия
На телефоне у раздела другая задача: не «работать с журналом», а быстро посмотреть последние платежи — например, стоя у клиента. Поэтому таблица не ужимается, а перестраивается.
- Строка → карточка (единый компонент SmitTable): на телефоне каждая операция превращается в карточку. Имя клиента — заголовок карточки сверху; ниже поля в формате «ПОДПИСЬ значение» в одну строку слева (Дата, Тип операции, Сумма, Описание). Данные не прячутся и не уезжают за горизонтальный скролл.
- Иконка источника на карточке скрыта — рядом стоит текстовый «Тип операции», значок был бы дублем.
- Дата сокращается до
дд.мм.гг; полная дата со временем доступна по нажатию. - Панель управления — одна строка: поиск, кнопка фильтров со счётчиком активных, кнопка добавления.
- Фильтры уезжают в нижний лист (offcanvas, единый
SmitMobileFilterSheet) — на маленьком экране горизонтальная панель заняла бы весь первый экран. - Карточка операции (просмотр) открывается боковой панелью справа, форма создания/редактирования — панелью снизу.
Тёмная тема
Цвета раздела заданы токенами (tokens.css), поэтому тема переключается без отдельного набора правил. Суммы в тёмной теме используют осветлённые оттенки — тёмно-зелёный и тёмно-красный на тёмном фоне не проходят по контрасту.
Достоверность чисел и защита важных действий
Журнал рассчитан на большие объёмы: около 450 тысяч операций листаются без задержки, суммы хранятся без потери копеек. Доступ закрыт организацией — и к списку, и к каждой операции по прямой ссылке. Отдельно проработаны две вещи: достоверность и понятность чисел и защита действий, которые нельзя отменить.
Восемь источников операции
Источник определяется на лету по комбинации сигналов (operator_name + метки в descr + op_type_id + связи), без отдельного поля в БД:
эквайринг, метка
[txn:…]
эквайринг W1
метка
[bank_op:N]
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"]
Достоверность и понятность чисел
- Колонка «Орг.» — эффективная организация. Раньше показывала денорм-поле
organization_id(у старых/«Единая касса» — NULL → «—»), хотя список скоупится по организации клиента. Теперь:organization_idс fallback на организацию клиента — «—» у атрибутированных записей больше нет. - «Единая касса»: расшифровка комиссии. У платежей «Единая касса» в описании лежит голая сумма оплаты (с комиссией агрегатора), а на баланс идёт нетто — числа не сходились. Теперь в списке у таких строк маркер и подсказка, а в модалке — строка «Расшифровка: Оплачено / Зачислено / Комиссия». Новые записи из EBS-синка приходят уже с подписью; текущий generic-webhook и так пишет внятно.
- Без утечки внутренних значений. Убрано «Единая касса
(id=37)» → «Единая касса»; «pay_id (int)» → «ID платежа».
Типы операций (FinTypes)
Справочник типов хранится в fin_types. Поле op_sign определяет знак суммы:
- +1 (приход) — оплаты через эквайринг (Сбербанк РФ, ЮKassa, EasyPay), пополнение ручной проводкой, обещанные платежи.
- −1 (расход) — периодические и единоразовые услуги, корректирующие списания, списания обещанных платежей.
- 0 (информационный) — редко, для технических записей без движения денег.
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
чек ОФД"]
owner(FKauth_user) — кто из сотрудников провёл операцию.operator(FK Abonents) — оператор связи, кому выставлять счёт.usluga(FK Usluga) — услуга, по которой проведено списание.fin_op(FK self) — родительский счёт, к которому относится платёж.related_operation(FK self) — связанная операция (например, оригинал для сторно).- Снапшот баланса на момент операции:
aa_debit,aa_credit,aa_ostatok,aa_credit_adjust,aa_limit_warning,aa_limit,aa_unlimited,aa_unlimited_date. Нужен, чтобы восстановить состояние счёта на любой момент прошлого — без него разбор жалобы «а почему меня отключили в марте» превращается в гадание.
Онлайн-касса (ФР)
Поля для интеграции с ОФД и кассовой техникой:
kkm_printed— флаг успешной отправки в кассу.kkm_storno— флаг сторно через ФР.kkm_check_id— идентификатор чека.kkm_check_retry_num— количество попыток отправки.atol_config(FKAtolConfig) — какая ОФД-конфигурация использована.print_form(binary) — распечатанная форма документа.is_cash— оплата через вебкассу.
Настройки фискализации — в /admin/settings/atol_config/.
Внешние операторы
Для операций, пришедших через webhook платёжных систем (/admin/settings/payment/):
operator_pay_id/operator_pay_id_str— ID транзакции у оператора.operator_name— название оператора («yookassa», «w1», «sber»).operator_result— код результата.
Идемпотентность по operator_pay_id_str: повторный webhook на ту же транзакцию не создаёт дубль. Это не теоретическая предосторожность — платёжные системы штатно повторяют доставку, пока не получат подтверждение.
Синхронизация с 1С
sync1c— флаг успешной синхронизации.code1c— ID документа в 1С (до 32 символов).isbuhdoc— признак реального финансового документа: служебные операции в отчётность не попадают.
Endpoints
| URL | Метод | Назначение |
|---|---|---|
/admin/Abonents/FinanceOperations/ | GET | HTML-страница списка |
/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 / POST | REST API v2 (CRUD) |
Обслуживание данных
manage.py backfill_finops_org [--apply] — проставляет организацию операциям, у которых она пустая: сначала из организации клиента, затем организацию по умолчанию для остатка. Без --apply только показывает, что будет сделано.
Команда идемпотентна — трогает только записи с пустой организацией. Это осознанно: у части операций организация уже проставлена верно и отличается от организации по умолчанию, и «проставить всем одну» затёрло бы их и сломало изоляцию доступа.
Чем отличается от Журнала платежей
В системе два раздела поверх одной таблицы finance_operations:
- «Финансовые операции» (этот раздел) — рабочий инструмент кассира и бухгалтера: создание, правка, разбор конкретной операции.
- «Журнал платежей» (
/admin/reports/PayLog/) — отчётный инструмент: грид с экспортом в CSV/XLSX и фильтрами по периоду и типу для отчётности.
Разница только в UI и наборе фильтров — данные одни и те же.
Смежные разделы
- Счета — лицевой счёт, который двигают операции; там же пополнение и корректировка.
- Карточка клиента — вкладка «Операции» показывает тот же журнал в разрезе одного клиента.
- Банковские выписки — автоматическое создание операций из выписок Сбер / Альфа.
- Фискализация 54-ФЗ — чеки в ОФД по этим операциям.
- Платежи (API) — webhooks ЮKassa, Wallet One и Единой кассы, которые эти операции создают.
- Обещанный платёж — пара операций «выдача / закрытие».