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

Почему данные биллинга перестают сходиться
Для финансов «сверить» — простое обещание: итоги в отчётах совпадают с исходными записями, и каждую цифру можно проследить. Если в месяце указано, что собрано $12,430, вы должны суметь показать точные платежи (и возвраты), увидеть, к каким счетам они применялись, и объяснить каждую разницу датированной записью.
Данные биллинга обычно перестают сходиться, когда в базе хранят не факты, а результаты. Колонки вроде paid_amount, balance или amount_due обновляются приложением со временем. Одна ошибка, одна повторная попытка или одно ручное «исправление» могут тихо изменить историю. Через недели таблица счетов говорит, что счёт «оплачен», а строки платежей не сходятся или есть возврат без соответствующего кредита.
Ещё одна распространённая причина — смешение разных типов документов. Счёт — это не платёж. Кредит‑мемо — не возврат. Корректировка — не скидка. Когда всё сжимают в одну строку «transactions» с множеством опциональных полей, отчётность превращается в угадывание, а аудит — в споры.
Корень несоответствия прост: приложения часто интересует текущее состояние («доступ активен?»), а финансам нужен след («что произошло, когда и почему?»). Схема биллингового реестра должна поддерживать оба запроса, но прослеживаемость должна быть приоритетом.
Проектируйте под эти цели:
- Понятные итоги по клиенту, по счёту и по отчётному периоду
- Каждое изменение записывается новой строкой (не перезаписывается)
- Полная цепочка от счета к платежам, кредитам, возвратам и корректировкам
- Возможность пересчитать итоги из сырых записей и получить тот же ответ
Пример: клиент платит $100, затем получает кредит $20 — в отчётах должно быть видно $100 собранных, $20 зачислено и $80 чистыми, без правки исходной суммы счета.
Отдельно: счета, платежи, кредиты и корректировки
Если хотите схему, которая сходится, рассматривайте каждый тип документа как отдельное событие. Смешивание их в одну таблицу "transactions" кажется аккуратным, но стирает смысл.
Счёт — это претензия: «клиент нам должен». Храните его как документ с заголовком (клиент, номер счёта, дата выставления, срок оплаты, валюта, итоги) и отдельными позициями (что продано, количество, цена за единицу, налоговая категория). Допускается хранить итоговые суммы заголовка для скорости, но вы всегда должны уметь объяснить их из позиций.
Платёж — это движение денег: «деньги пошли от клиента к нам». В карточных потоках часто встречаются авторизация (банк разрешил) и capture (деньги списаны). Многие системы хранят авторизации как операционные записи и в реестр помещают только capture, чтобы не завышать отчёт о наличности.
Кредит‑мемо уменьшает то, что клиент должен, без обязательной выдачи денег. Возврат — это уход наличных. Они часто идут вместе, но это разные вещи.
- Invoice: увеличивает дебиторскую задолженность и выручку (или отложенную выручку)
- Payment: увеличивает наличность и уменьшает дебиторку
- Credit memo: уменьшает дебиторку
- Refund: уменьшает наличность
Корректировка — это исправление, которое вносит команда, когда реальность не совпала с записями. Корректировка требует контекста, чтобы финансы доверяли ей. Храните, кто создал, когда опубликована, код причины и краткую заметку. Примеры: «списать 0.03 из‑за округления» или «мигрировать наследственный баланс».
Практическое правило: спросите «существовало бы это, если бы никто не ошибся?». Счета, платежи, кредит‑мемо и возвраты всё равно существовали бы. Корректировки должны быть редкими, явно помеченными и легко проверяемыми.
Выберите модель реестра, удобную для аудита
Сверяющаяся схема реестра начинается с одной идеи: документы описывают, что произошло, а проводки реестра доказывают итоги. Счёт, платёж или кредит‑мемо — документ. Реестр — набор записей, которые в сумме дают итог, точка.
Документы и проводки (храните оба)
Сохраняйте документы (заголовок и позиции счёта, квитанция о платеже, кредит‑мемо), потому что людям нужно их читать. Но не полагайтесь только на итоги документов как на единственный источник правды для сверки.
Вместо этого проводите каждый документ в таблицу реестра как одну или несколько неизменяемых записей. Тогда финансы могут суммировать записи по счёту, клиенту, валюте и дате проводки и всегда получать один и тот же ответ.
Простая модель, дружелюбная к аудиту, следует нескольким правилам:
- Неизменяемые записи: не редактируйте опубликованные суммы; изменения — это новые записи.
- Явное событие проводки: каждый документ создаёт пакет проводок с уникальной ссылкой.
- Сбалансированность: записи правильно сходятся по сумме (часто дебет равен кредиту на уровне компании).
- Разные даты: храните дату документа (что видит клиент) и дату проводки (что попадает в отчёты).
- Стабильные ссылки: храните внешние ссылки (номер счёта, ID процессора платежей) рядом с внутренними ID.
Натуральные ключи vs суррогатные ID
Используйте суррогатные ID для джоинов и производительности, но также храните стабильный натуральный ключ, который переживёт миграции и реимпорты. Финансы будут просить «Invoice INV-10483» задолго до смены внутренних ID. Рассматривайте номера счётов и внешние ID провайдеров как первоклассные поля.
Сторнирования без удаления истории
Когда что‑то нужно отменить, не удаляйте и не перезаписывайте. Постьте сторнирование: новые записи, зеркально отражающие оригинал с противоположными знаками, связанные с оригинальной проводкой.
Пример: платёж $100, применённый не к тому счёту, превращается в два шага: сторнируете неправильно применённую проводку, затем проводите новое применение к правильному счёту.
Пошаговый план схемы (таблицы и ключи)
Схема реестра надёжнее, когда каждый тип документа имеет собственную таблицу, и вы связываете их явными записями распределений (вместо догадок позже).
Начните с небольшого набора основных таблиц, каждая с понятным первичным ключом (UUID или bigserial) и обязательными внешними ключами:
- customers:
customer_id(PK), плюс стабильные идентификаторы вродеexternal_ref(unique) - invoices:
invoice_id(PK),customer_id(FK),invoice_number(unique),issue_date,due_date,currency - invoice_lines:
invoice_line_id(PK),invoice_id(FK),line_type,description,qty,unit_price,tax_code,amount - payments:
payment_id(PK),customer_id(FK),payment_date,method,currency,gross_amount - credits:
credit_id(PK),customer_id(FK),credit_number(unique),credit_date,currency,amount
Затем добавьте таблицы, которые делают итоги проверяемыми: allocations. Платёж или кредит могут покрывать несколько счетов, а счёт может быть оплачен несколькими платежами.
Используйте таблицы‑связки с собственными ключами (не только составные):
- payment_allocations:
payment_allocation_id(PK),payment_id(FK),invoice_id(FK),allocated_amount,posted_at - credit_allocations:
credit_allocation_id(PK),credit_id(FK),invoice_id(FK),allocated_amount,posted_at
Наконец, держите корректировки отдельно, чтобы финансы видели, что и почему изменилось. Таблица adjustments может ссылаться на целевой документ через invoice_id (nullable) и хранить дельту суммы, не переписывая историю.
Добавьте поля аудита везде, где вы публикуете деньги:
created_at,created_byreason_code(write-off, rounding, goodwill, chargeback)source_system(manual, import, Stripe, support tool)
Кредиты, возвраты и списания без поломанных итогов
Большинство проблем со сверкой начинается, когда кредиты и возвраты записывают как «отрицательные платежи», или когда списания смешивают в позициях счета. Чистая схема хранит каждый тип документа отдельно, и их взаимодействие происходит только через явные распределения.
Кредит должен показывать, почему вы уменьшили задолженность клиента. Если он применяется к одному счёту — заведите одно кредит‑мемо и выделите его на тот счёт. Если охватывает несколько счетов — распределите один кредит‑мемо на несколько счетов. Кредит остаётся одним документом с множеством распределений.
Возвраты — это события, похожие на платежи, а не «отрицательные платежи». Возврат — это уход наличности, поэтому обрабатывайте его как отдельную запись (часто связанную с оригинальным платёжным ID для справки), а затем распределяйте как платёж. Это сохраняет ясность аудита, когда банковская выписка показывает и приход, и исходящий возврат.
Частичные платежи и частичные кредиты работают одинаково: храните общую сумму платежа или кредита, а распределениями указывайте, сколько применено к каждому счёту.
Правила проводки, которые предотвращают двойной учёт
Эти правила убирают большинство «таинственных разниц»:
- Никогда не храните отрицательный платёж. Используйте запись возврата.
- Никогда не уменьшайте итог счёта после публикации. Используйте кредит‑мемо или корректировку.
- Публикуйте документы один раз (с
posted_at) и не редактируйте суммы после публикации. - Единственное, что меняет баланс счёта — это сумма опубликованных распределений.
- Списание — это корректировка с кодом причины, распределяемая по счёту как кредит.
Налоги, сборы, валюты и правила округления
Большинство проблем со сверкой начинаются с итогов, которые нельзя пересчитать. Самое надёжное правило: храните сырые позиции, которые создали счёт, и также храните итоговые суммы, показанные клиенту.
Налоги и сборы: храните на уровне позиции
Храните суммы налогов и сборов по каждой позиции, а не только суммарно по счёту. Разные товары имеют разные ставки, сборы могут облагаться налогом или нет, и освобождения часто применяются к части счёта. Если хранить только tax_total на уровне счета, рано или поздно появится случай, который вы не сможете объяснить.
Храните:
- Сырые позиции (что продано, qty, unit_price, скидка)
- Вычисленные итоги позиции (line_subtotal, line_tax, line_total)
- Итоги счёта (subtotal, tax_total, total)
- Ставку налога и тип налога, использованные для расчёта
- Сборы как отдельные позиции (например, «комиссия за обработку платежа»)
Это позволяет финансам восстановить итоги и подтвердить, что налоги считались одинаково каждый раз.
Мультивалюта: храните и то, что произошло, и то, как вы это отражаете
Если поддерживаете несколько валют, записывайте и валюту транзакции, и значения в валюте отчётности. Практический минимум: currency_code на каждом денежном документе, fx_rate, использованный при проводке, и отдельные суммы для отчётности (например, amount_reporting), если книги ведутся в одной валюте.
Пример: клиент выставлен на 100.00 EUR плюс 20.00 EUR НДС. Сохраните эти EUR‑позиции и итоги, плюс fx_rate, применённый при публикации, и конвертированные итоги для отчётности.
Округление заслуживает отдельного обращения. Выберите одно правило округления (по строке или по счёту) и придерживайтесь его. Когда округление даёт разницу, фиксируйте её явно как строку корректировки округления (или маленькую корректировку), а не молча меняйте итоги.
Статусы, даты проводок и что не стоит хранить
Сверка портится, когда «статус» используется как сокращение бухгалтерской правды. Рассматривайте статус как метку процесса, а опубликованные проводки — как источник истины.
Сделайте статусы строгими и скучными. Каждый должен отвечать: может ли этот документ уже влиять на итоги?
- Draft: внутренний, не опубликован, не должен попадать в отчёты
- Issued: финализирован и отправлен, готов к публикации (или уже опубликован)
- Void: отменён; если был опубликован, должен быть сторнирован
- Paid: полностью погашен опубликованными платежами и кредитами
- Refunded: деньги возвращены через опубликованный возврат
Даты важнее, чем многие команды думают. Финансы спросят: «В какой месяц это относится?» — и ваш ответ не должен зависеть от логов UI.
issued_at: когда счёт стал финальнымposted_at: когда он учитывается в отчётностиsettled_at: когда средства прошли клиринг или платёж подтверждёнvoided_at/refunded_at: когда сторнирование вступило в силу
Что не стоит хранить как правду: выводимые числа, которые нельзя восстановить из реестра. Поля вроде balance_due, is_overdue или customer_lifetime_value подходят как кэшированные представления только если вы всегда можете пересчитать их из invoices, payments, credits, allocations и adjustments.
Небольшой пример: повторная попытка платежа дошла до шлюза дважды. Без idempotency key вы запишете два платежа, пометите счёт «paid», а финансы увидят лишние $100 в наличности. Храните уникальный idempotency_key для внешней попытки списания и отвергайте дубли на уровне БД.
Отчёты, которые финансы ждут с первого дня
Схема реестра доказывает свою ценность, когда финансы быстро получают ответы на базовые вопросы и получают одни и те же итоги каждый раз.
Большинство команд начинают с:
- Aging дебиторки: суммы в разрезе клиентов и по возрастным корзинам (0‑30, 31‑60 и т.д.)
- Полученные деньги: наличность по дням, неделям и месяцам, по датам публикации платежей
- Выручка vs наличность: выставленные счета по датам публикации vs поступления по датам публикации
- Трассируемость для экспорта: путь от строки GL‑экспорта до точных документов и строк распределений, которые её создали
В aging распределения играют ключевую роль. Aging — это не просто «итог счёта минус итоги платежей». Это «что осталось открытым по каждому счёту на указанную дату». Для этого нужно хранить, как каждый платёж, кредит или корректировка была применена к конкретным счетам и когда эти распределения были опубликованы.
Полученная наличность должна основываться на таблице payments, а не на статусе счета. Клиенты платят заранее, с опозданием или частями.
Выручка vs наличность — причина, почему счета и платежи должны быть разными. Пример: вы выписываете счёт $1,000 30 марта, получаете $600 5 апреля и выдаёте кредит $100 20 апреля. Выручка относится к марту (публикация счёта), наличность — к апрелю (публикация платежа), а кредит снижает дебиторку в момент публикации. Рубежи связывают всё вместе.
Пример: один клиент, четыре типа документов
Один клиент, один месяц, четыре типа документов. Каждый документ хранится один раз, а деньги перемещаются через таблицу распределений (иногда называемую "applications"). Это делает итог по балансу лёгким для пересчёта и аудита.
Предположим клиента C-1001 (Acme Co.).
Записи, которые вы создаёте
invoices
| invoice_id | customer_id | invoice_date | posted_at | currency | total |
|---|---|---|---|---|---|
| INV-10 | C-1001 | 2026-01-05 | 2026-01-05 | USD | 120.00 |
payments
| payment_id | customer_id | received_at | posted_at | method | amount |
|---|---|---|---|---|---|
| PAY-77 | C-1001 | 2026-01-10 | 2026-01-10 | card | 70.00 |
credits (кредит‑мемо, goodwill и т.д.)
| credit_id | customer_id | credit_date | posted_at | reason | amount |
|---|---|---|---|---|---|
| CR-5 | C-1001 | 2026-01-12 | 2026-01-12 | service issue | 20.00 |
adjustments (коррекция после факта, не новая продажа)
| adjustment_id | customer_id | adjustment_date | posted_at | note | amount |
|---|---|---|---|---|---|
| ADJ-3 | C-1001 | 2026-01-15 | 2026-01-15 | underbilled fee | 5.00 |
allocations (именно это фактически сверяет баланс)
| allocation_id | doc_type_from | doc_id_from | doc_type_to | doc_id_to | posted_at | amount |
|---|---|---|---|---|---|---|
| AL-900 | payment | PAY-77 | invoice | INV-10 | 2026-01-10 | 70.00 |
| AL-901 | credit | CR-5 | invoice | INV-10 | 2026-01-12 | 20.00 |
Как считается баланс по счёту
Для INV-10 аудитор может пересчитать открытый баланс из исходных строк:
open_balance = invoice.total + sum(adjustments) - sum(allocations)
Итого: 120.00 + 5.00 - (70.00 + 20.00) = 35.00 к оплате.
Для трассировки суммы 35.00:
- Начните с итоговой суммы счета (INV-10)
- Прибавьте опубликованные корректировки, связанные с этим счётом (ADJ-3)
- Вычтите каждое опубликованное распределение, применённое к счёту (AL-900, AL-901)
- Убедитесь, что каждое распределение ссылается на реальный исходный документ (PAY-77, CR-5)
- Проверьте даты и
posted_at, чтобы объяснить хронологию
Частые ошибки, разрушающие сверку
Большинство проблем со сверкой не «математические баги». Это отсутствие правил, из‑за чего одно и то же событие записывают по-разному в зависимости от участника процесса.
Типичная ловушка — использование отрицательных строк как короткого пути. Отрицательная позиция счёта, отрицательный платёж и отрицательная налоговая строка могут значить разные вещи. Если разрешать отрицательные записи, определите одно строгое правило сторнирования (например: используйте только сторнирующую строку, которая ссылается на оригинал, и не смешивайте семантику сторнирования со скидками).
Ещё одна частая причина — изменение истории. Если счёт выставлен, не редактируйте его позже, чтобы подогнать под новую цену или исправленный адрес. Сохраните исходный документ и опубликуйте корректировку или кредит, объясняющий изменение.
Шаблоны, которые обычно ломают итоги:
- Использование отрицательных строк без строгого правила сторнирования и ссылки на оригинал
- Редактирование старых счетов вместо публикации корректировок или кредит‑нотов
- Смешение внешних ID шлюзов транзакций и внутренних ID без таблицы соответствия и явного источника правды
- Позволять коду приложения вычислять итоги при отсутствии поддерживающих строк (налоги, сборы, округление, распределения)
- Не разделять «движение денег» (cash movement) и «распределение денег» (какому счёту применено)
Последний пункт вызывает наибольшую путаницу. Пример: клиент платит $100, затем вы списываете $60 на счёт A и $40 на счёт B. Платёж — одно движение наличности, но создаёт два распределения. Если хранить только «платёж = счёт», вы не сможете поддержать частичные платежи, переплаты или перераспределения.
Чек‑лист и следующие шаги
Прежде чем добавлять новые функции, убедитесь, что базовые вещи держатся. Схема реестра сходится, когда каждый итог прослеживается до конкретных строк, и каждое изменение имеет след аудита.
Быстрые проверки сверки
Запустите эти проверки на небольшой выборке (один клиент, один месяц), затем на полном наборе:
- Каждая опубликованная сумма в отчёте трассируется до исходных строк (позиция счёта, платёж, кредит‑мемо, корректировка) с датой публикации и валютой.
- Суммы распределений не превышают документ, к которому они применяются (сумма распределений платежа <= сумма платежа; то же для кредитов).
- Ничего не удаляется. Ошибочные записи сторнируются с указанием причины, затем корректируются новой опубликованной строкой.
- Открытый баланс выводится, а не хранится (open amount = invoice total - опубликованные распределения и кредиты).
- Итоги документа соответствуют его позициям (заголовок счёта = сумма позиций, налогов и сборов с учётом выбранного правила округления).
Следующие шаги для запуска рабочего решения
Когда схема устоялась, постройте вокруг неё операционные рабочие процессы:
- Админ‑экраны для создания, публикации и сторнирования счетов, платежей, кредитов и корректировок с обязательными заметками
- Вид сверки, показывающий документы и распределения рядом, включая кто и когда публиковал
- Экспорты, которые ждут финансы (по дате проводки, по клиенту, по GL‑маппингу, если он есть)
- Процесс закрытия периода: блокировка дат проводок для закрытых месяцев и требование сторнирований для поздних исправлений
- Тестовые сценарии (возвраты, частичные платежи, списания), которые должны давать ожидаемые итоги
Если нужен быстрый путь к рабочему внутреннему финансовому порталу, AppMaster (appmaster.io) помогает моделировать PostgreSQL‑схему, генерировать API и собирать админ‑экраны из единого источника, чтобы правила публикации и распределений оставались согласованными по мере развития приложения.
Вопросы и ответы
Реконcиляция означает, что каждая сумма в отчёте может быть восстановлена из исходных записей и прослежена до датированных проводок. Если отчёт показывает, что вы собрали $12,430, вы должны уметь указать точные опубликованные платежи и возвраты, которые в сумме дают эту цифру, без опоры на перезаписываемые поля.
Чаще всего проблема возникает из‑за хранения изменяющихся «результатов», таких как paid_amount или balance_due, как если бы это были факты. Когда эти поля обновляют повторные попытки, баги или ручные правки, теряется исторический след и суммы перестают соответствовать реальным событиям.
Потому что каждый тип документа отражает разное реальное событие и имеет различный смысл в учёте. Если всё сжимается в одну строку «transaction» с множеством опциональных полей, отчёты превращаются в гадание, а аудит — в спор о том, что именно означала строка.
Кредит‑мемо уменьшает задолженность клиента, но не предполагает выдачу наличных. Возврат — это исходящий поток денег. Как правило, они связаны, но это разные события. Обозначая их одинаково (например, как отрицательные платежи), вы усложняете сопоставление банка и отчётность по денежным потокам.
Вместо правки или удаления создавайте сторнирование. Запишите новые проводки, зеркально противоположные оригиналу, свяжите их с исходным постингом и затем опубликуйте корректную операцию — так аудит увидит, что именно и почему изменили.
Используйте явные записи распределений (applications), которые связывают платеж или кредит с одним или несколькими счетами с указанием суммы и даты проводки. Открытый остаток по счёту должен вычисляться из сумм счёта плюс корректировки минус опубликованные распределения.
Храните и дату документа, и дату проводки. Дата документа — то, что видит клиент; дата проводки (posted_at) — когда это учитывается в финансовых отчётах и закрытиях периодов. Так месяц‑конец не будет сдвигаться из‑за правок позже.
Детали налогов и сборов храните на уровне строк, а также сохраняйте итоговые суммы, которые вы показывали клиенту. Оперируя только tax_total на уровне счета, вы рано или поздно столкнётесь со случаем, который не сможете объяснить.
Храните суммы в валюте транзакции и также сохраняйте значения в валюте отчётности с курсом, использованным при проводке. Выберите правило округления (по строке или по счёту) и придерживайтесь его; любые разницы фиксируйте отдельной строкой корректировки округления.
Статусы — это часть рабочей логики (Draft, Issued, Void, Paid), но учётной правдой являются опубликованные записи в реестре и распределения. Статус может быть неправильным; неизменяемые опубликованные записи позволяют финансам пересчитать суммы одинаково в любой момент.


