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

Почему итоги счетов ломаются в реальных приложениях
Итоги по счетам обычно выглядят правильно в первый день. Они ломаются на десятый день, когда реальные люди начинают править счета, применять скидки, фиксировать частичные платежи и просить возвраты.
Большая часть проблем возникает потому, что у итогов нет ясного источника правды. AI-сгенерированный экран может сохранить total = 120.00 в счёте, тогда как позиции в сумме дают 119.99 после округления. Позже кто‑то редактирует количество, но сохранённый итог не обновляется. Теперь PDF, база данных и запись о платеже не совпадают.
«Правильные итоги» — это не только базовая арифметика. Это способность приложения воспроизвести те же числа каждый раз, по одинаковым правилам, даже через месяцы. Это также означает, что итоги соответствуют тому, что пользователь видел при отправке счёта, включая налоги, скидки и любые ручные корректировки.
Ещё одна причина — потерянная история. Если вы перезаписываете счёт на месте, позже вы не ответите на простые вопросы: что изменилось, кто это изменил, произошло ли изменение до или после платежа/возврата и какая версия фактически была отправлена.
Небольшой сценарий показывает, как быстро это ломается: вы отправляете счёт за 10 часов работы. Клиент платит половину. Потом вы корректируете часы до 9.5. Если модель данных не разделяет версии счета и платежи, приложение может показать клиента переплаченным, недоплаченным или полностью оплаченным в зависимости от того, какой экран открыть.
Хранить одно поле «total» кажется быстрым на старте, но создаёт боль, когда появляются правки, частичные платежи, кредиты и возвраты.
Основные сущности, которые нужны (и что каждая означает)
Надёжная модель данных приложения для выставления счетов разделяет, кого вы выставляете, за что вы выставляете, и что происходило после отправки счёта. Если эти вещи смешиваются, итоги и статусы уходят в дрейф.
Контакты: клиент — не всегда плательщик
Customer — это аккаунт, с которым вы ведёте бизнес (компания или человек). Держите контакты отдельно, чтобы можно было менять, куда отправляются счета, не меняя саму запись клиента.
Типовая схема — долгоживущая запись клиента (имя, валюта по умолчанию, налоговый ID, заметки) плюс отдельные billing и shipping контакты. Тогда «Отправляйте счета в бухгалтерию, а отправляйте товар на наш склад» не превращается в перезаписанные адреса.
Счета и позиции: что вы отправили vs что правите
Invoice — документ, который вы планируете отправить, с клиентом, датой выставления, сроком оплаты, валютой и номером счета. Рассматривайте draft vs sent как фазы одного и того же счёта. Если вы разрешаете изменения после отправки, добавьте Invoice Version (или ревизию), чтобы сохранить чистую историю.
Для начислений рассматривайте Line Items как источник правды для сумм. Позиция может ссылаться на Product/Service из каталога или быть пользовательским текстовым элементом. Не заставляйте всё попадать в таблицу продуктов и не храните дополнительные итоги на позициях, если у вас нет чёткой причины.
События с денежными средствами: платежи — это не возвраты
Payment фиксирует полученные деньги и то, как они были применены к одному или нескольким счетам. Refund — это возвращённые деньги, как правило, привязанные к платежу. (Платёжный вывод — Payout — это деньги, которые вы отправляете, и часто он не связан со счетами.)
Держите статусы жизненного цикла минимальными и стабильными (draft, sent, void). Рассматривайте «paid/overdue/partially paid» как производные от платежей и дат, а не как поле, редактируемое вручную.
Пошагово: спроектируйте схему до того, как соберёте экраны
Если вы сначала строите UI, таблицы обычно копируют то, что понадобилось первому экрану. Так модель данных для выставления счетов получает пробелы: неясные статусы, потерянная история или счета, которые можно отредактировать до абсурда.
Начните с жизненного цикла счета — он задаёт правила и данные. Простого цикла хватит многим приложениям: draft (не финален), sent (клиент видит), paid (полностью оплачен), void (отменён и не подлежит взысканию). Решите, какие изменения разрешены на каждом шаге, прежде чем добавлять столбцы.
Практический порядок:
- Определите состояния и переходы (draft -> sent -> paid, и void из draft или sent).
- Используйте внутренние неизменяемые ID (например, UUID) и держите invoice numbers отдельными для людей.
- Решите, что блокируется после отправки (позиции, ставки налога, адрес выставления счета).
- Перечислите минимальные обязательные поля для каждой таблицы.
- Раннее определите политику по валютам (одна валюта против истинной мультивалютности).
Для ID используйте внутренний неизменяемый ключ для каждой записи. Затем добавьте invoice_number, уникальный и читаемый, который никогда не меняется после отправки. Пользователи будут ссылаться на invoice_number, а не на ваш первичный ключ.
Будьте явны относительно редактируемости. Возможно, вы позволите исправить опечатку в имени клиента после отправки, но не менять количества или цены. Если хотите позволить денежные изменения, моделируйте их как новую версию, кредит-ноту или корректировку, а не как тихое переписывание.
Минимально необходимые поля могут быть небольшими:
- Customer: id, name, email, billing_address
- Invoice: id, customer_id, invoice_number, status, issued_at, currency
- Line item: id, invoice_id, description, quantity, unit_price
- Payment (если отслеживаете): id, invoice_id, amount, received_at
Одновалютные приложения могут хранить деньги как целые минимальные единицы (например, центы). Мультивалютные приложения нуждаются в валюте на каждом денежном поле и ясных правилах обменных курсов.
Как держать итоги правильными и согласованными
Большинство багов «неправильный итог» исходят из неясного владения данными. Если UI правит позицию, итог счёта должен меняться в одном предсказуемом месте, всегда. Решите заранее, что вычисляется, что хранится и когда значения становятся окончательными.
Начинайте с позиции. Храните сырые входные данные (unit_price и quantity) и сохраняйте вычисленный line_total, который приложение рассчитало в момент записи. Используйте целые минимальные единицы (например, центы) для каждого денежного поля, включая скидки и суммы налога. Это предотвращает плавающие погрешности типа 19.999999, которые отображаются как 20.00.
Полезное правило: вычисляйте из сырых полей, сохраняйте то, что отображаете, и будьте последовательны в том, когда вы перекалькулируете.
Выберите ясный источник правды
У вас есть три рабочего варианта. Смешивание без правила создаёт дрейф:
- Пересчитывать итоги на лету из позиций при каждом загрузке счёта.
- Хранить итоги счёта и обновлять их при каждом изменении.
- Делать и то, и другое: вычислять, хранить, затем валидировать.
Если вы делаете и то, и другое, рассматривайте вычисленные значения как валидатор. Когда они расходятся, помечайте это, а не молча выбирайте одно.
Обрабатывайте правки, не переписывая историю
Решите, когда счёт редактируем. Распространённый подход: черновики редактируемы, отправленные счета заблокированы, изменения происходят через новую версию, кредит-ноту или корректирующую позицию.
Пример: вы отправили счёт со ставкой налога 7.5%. Месяц спустя ставка изменилась. Если вы пересчитаете по сегодняшней ставке, старый итог изменится, и клиент увидит другую сумму, чем получал. Чтобы этого избежать, снимайте снимок входных данных, важные при финализации: ставка налога, правила скидок, режим округления и валюта. Пересчёт должен использовать снимок, а не текущие настройки аккаунта.
Модель статуса платежей без загоняния себя в угол
Множество багов начинается, когда одно поле пытается делать слишком много. «Status» часто используется для всего: draft vs sent, late vs paid, refunded vs disputed. Это работает для демо, но ломается при частичных платежах.
Разделяйте две идеи:
- Статус жизненного цикла счёта: что это за документ (draft, sent, void, списан)
- Статус платежа: что с деньгами произошло (неоплачен, частично оплачен, оплачен, переплата, возвращён, оспорен)
Они могут не совпадать — и это нормально. Счёт может быть «sent», но «unpaid». Может быть «void», но при этом иметь платёж, который нужно вернуть.
Моделируйте движения денег, а не только ярлык
Вместо хранения «paid: true» записывайте каждое движение денег в таблицах типа payments и refunds (или в одной таблице payment_events). Также отслеживайте попытки платежей отдельно от успешных. Карты падают, повторяются и потом проходят. Если хранить только успехи, теряется история и техподдержке сложнее.
Правило: одна строка на реальное событие, никогда не переписывайте историю. Если платёж отменили, добавьте событие возврата или чарджбека, связанное с оригинальным платёжом.
Определяйте «оплачен» как вычисление
Решите, что означает «paid», и используйте это в UI, письмах и отчётах. Распространённое определение:
net_paid = sum(successful payments) - sum(refunds and chargebacks)amount_due = invoice_total - net_paid- Статус платежа основан на
amount_due(0 — оплачен, отрицательное — переплата)
Пример: итог счета $100. Клиент платит $60, потом ещё $50. Net paid = $110, значит «overpaid» на $10. Если позже вы вернёте $10, добавьте событие refund, и статус снова станет «paid» без редактирования старых платежей.
Налоги, скидки и округление, которые не удивят пользователей
Большинство багов «итоги не совпадают» происходит потому, что налоги, скидки и округление добавляются поздно или непоследовательно. Если модель данных делает эти правила явными, UI остаётся простым, и пользователи могут сверить каждое число.
Где хранить налог
Налог может храниться на уровне позиции, на уровне счёта или на обоих. Главное — один ясный источник расчёта.
Налог на уровне позиции проще объяснить: каждая позиция имеет флаг налогооблагаемости, снимок ставки налога и вычисленную сумму налога. Это удобно при разных ставках или освобождённых позициях.
Налог на уровне счёта подходит, когда всё использует одну ставку, но при смешанных ставках он усложняется. Если вы используете налог на уровне счёта, храните ставку и налогооблагаемую базу, к которой она применялась, чтобы правки позже не меняли историю.
Скидки и правила округления
Скидки моделируйте как процент или фиксированную сумму. Решите, к чему они применяются (на позицию, на промежуточную сумму или на итог после налога) и зашифруйте это правило.
Практичный паттерн — относиться к корректировкам как к полноценным позициям: доставка или сборы как положительная позиция, скидки и кредиты как отрицательные позиции, купоны — отдельными явными записями.
Округление тоже требует явного правила. Если вы округляете по позициям, итог счёта может отличаться на цент от округления единожды по промежуточной сумме. Выберите политику и сохраняйте округлённые результаты, которые отображаете.
Чтобы итоги внушали доверие, показывайте расчёт так же, как вы его считаете: subtotal, каждая корректировка, разбивка налогов и общая сумма.
Правки, версии и аудит
Счета кажутся простыми, пока кто‑то не спросит: «А что мы реально отправляли?» Если приложение разрешает свободно править старые счета, итоги и налог могут измениться задним числом, и доверие исчезнет.
Когда счёт отправлен
Рассматривайте «sent» как чёткую линию. Храните неизменяемый снимок того, что видел клиент, даже если продукт позже меняет правила ценообразования, ставки налогов или округление.
Это можно сделать версионированием строк или замороженным payload-снимком. Минимум: снимите отображаемые клиенту данные на момент отправки, позиции как они были отправлены (включая ставку налога), итоги как отправлялись, условия и срок оплаты, а также метаданные отправки вроде sent_at и адресата email.
Затем решите, что всё ещё можно менять без влияния на деньги. Заметки и внутренние теги обычно безопасно оставлять редактируемыми. Денежные поля (unit_price, quantity, скидки, налог и валюта`) должны быть заблокированы после отправки (или изменяться только через новую версию).
Обработка исправлений
Ошибки случаются. Не "редактируйте историю", чтобы их скрыть. Используйте явные корректирующие действия: кредит-ноту на разницу, аннулирование с указанием причины или отмену и перевыпуск с новым номером (старый сохраняйте для аудита).
Избегайте жёстких удалений. Удалённые счета ломают нумерацию, отчёты и сверки платежей. Аннулирование (void) сохраняет запись, устанавливает сумму к взысканию в ноль и сохраняет след.
Для нетехнических команд держите небольшой журнал изменений: кто что изменил, когда и почему.
Пример: один клиент, два счёта, частичный платёж, возврат
Вот конкретный сценарий, который можно использовать для проверки модели данных приложения для выставления счетов.
Customer: Green Field Studio (одна платежная контактная запись, одна валюта).
Счёт 1: разовый взнос за настройку
Invoice INV-1001 содержит одну позицию: плата за настройку $500.00. Предположим налог 8%.
Subtotal: $500.00 | Tax: $40.00 | Total: $540.00
Зарегистрирован единый платёж $540.00. Счёт переходит из Sent в Paid, открытый баланс становится $0.00.
Счёт 2: месячный план с позициями, скидкой, налогом
Invoice INV-1002 имеет три позиции: месячный план ($200.00), дополнительные места (3 x $20 = $60.00) и приоритетная поддержка ($50.00). Промежуточная сумма $310.00.
Применена скидка 10% от промежуточной суммы: -$31.00, значит дисконтированный подытог $279.00. Налог 8% = $22.32.
Total due: $279.00 + $22.32 = $301.32
События и то, что вы должны видеть после каждого шага:
| Step | What happens | Payments total | Refunds total | Net paid | Open balance | Status |
|---|---|---|---|---|---|---|
| 1 | Invoice sent | $0.00 | $0.00 | $0.00 | $301.32 | Sent |
| 2 | Partial payment of $150.00 | $150.00 | $0.00 | $150.00 | $151.32 | Partially paid |
| 3 | Final payment of $151.32 | $301.32 | $0.00 | $301.32 | $0.00 | Paid |
| 4 | Refund $50.00 tied to the support line item | $301.32 | $50.00 | $251.32 | $0.00 | Paid (partially refunded) |
Обратите внимание, что меняется и что не меняется на Шаге 4: итог счёта остаётся $301.32, а открытый баланс остаётся $0.00, потому что вы уже собрали полную сумму. Возврат — отдельное движение денег, поэтому клиент теперь должен получить $50.00 обратно (часто отслеживается как кредит клиенту).
Частые ошибки, которые делают AI-прототипы
AI-инструменты помогают быстро дойти до рабочего демо, но выставление счетов полно мелких правил, которые тихо ломаются. Если модель данных хоть немного неверна, вы получите итоги, которые меняются, платежи, которые не сходятся, и отчёты, которым нельзя доверять.
Денежная арифметика, дрейфующая со временем
Типичная ошибка прототипа — использование чисел с плавающей точкой для денег (например, 19.99) и суммирование в разных местах. Это создаёт мелкие расхождения, которые проявляются как 1 цент между итогом счёта, суммой платежей и PDF.
Храните деньги как целые минимальные единицы (центы) и округляйте лишь при выводе (отображение, PDF, экспорт). Если приходится хранить десятичные, используйте тип с фиксированной точностью и будьте последовательны.
Итоги перестают совпадать после правок
Многие прототипы сохраняют subtotal, tax_total и total в счёте, но никогда их не пересчитывают при изменении позиций. Или пересчитывают при создании, но не при редактировании, импорте или обновлении через API.
Более безопасный паттерн: вычислять итоги из позиций и корректировок, сохранять вычисленные итоги и фиксировать момент последнего расчёта. Если что‑то меняется, триггерьте перекалькуляцию в одном месте, а не во всех экранах.
Ошибки, стоящие за большинством «почему итог неверен?» багов, предсказуемы: раздробление правил расчёта между UI и бекендом, разрешение редактирования денег после проведения платежей без записи изменений, несовпадение налогов по позициям и по счёту и разное округление по позициям и по итоговой сумме.
Статусы, которые загоняют вас в угол
Прототипы часто смешивают «sent» и «paid» в одном статусе, что ломается при частичных платежах, неудачных попытках или возвратах. Держите состояние доставки (draft/sent/void) отдельно от состояния платежа (unpaid/partial/paid/overpaid).
Также никогда не удаляйте платежи, чтобы «откатить» их. Записывайте отмену или возврат так, чтобы история оставалась истинной.
Бычек-лист перед релизом
Перед релизом пройдитесь ещё раз, сосредоточившись на правильности денег, а не на полировке UI.
- Храните деньги как целые минимальные единицы (например, центы), а не как float. Держите валюту на счёте и на каждой записи платежа, чтобы не смешивать USD и EUR.
- Делайте счета неизменяемыми после отправки. Если нужно править — создавайте новую версию или кредит-ноту и снимайте поля (имя клиента, адрес, налоговые ID, описания позиций, цены) точно такими, какими они были на момент отправки.
- Моделируйте платежи и возвраты как отдельные записи. Возврат — не просто отрицательный платёж в той же таблице, если вы явно не учитываете это в отчётах и поведении «paid».
- Верифицируйте итоги одним источником правды. Сохранённые итоги счёта должны соответствовать: sum(line totals) + налоги/сборы - скидки, с применением округления в одном месте.
- Опишите «оплачен» письменно и зашифруйте это правило. Например: счёт считается оплаченным, когда (платежи - возвраты) >= amount_due и счёт не аннулирован. Решите политику по переплате.
После этого запустите простой отчёт сверки: для диапазона дат перечислите сумму по счёту, общие платежи, общие возвраты и оставшийся баланс. Если вы не можете получить это из модели чисто, значит, чего‑то важного не хватает.
Следующие шаги: валидировать, рефакторить и подготовить к проду
Когда таблицы и статусы на бумаге выглядят правильно, проверьте итоги тест-кейсами, которые отражают реальное поведение:
- Отредактировать позицию после отправки счёта (изменение количества, цены, удаление позиции)
- Частичный платёж, ещё один частичный платёж, затем возврат
- Применить скидку, затем изменить ставку налога, затем убрать скидку
- Аннулировать счёт после попытки платежа
- Изменить данные клиента и убедиться, что исторические счета прошлое не переписывают
Добавьте одну проверку сверки, которую можно запустить в любой момент: сравните invoice total (что вы выставили) и payments net (сумма платежей минус возвраты). Если они не соответствуют ожидаемому балансу, вы знаете, куда смотреть.
Если вы унаследовали AI-созданное приложение для выставления счетов, которое «в целом работает», но разваливается вокруг итогов, изменений статусов или возвратов, команды часто привлекают FixMyMess (fixmymess.ai) для быстрой диагностики и ремонта кода. Бесплатный аудит может выявить, где считаются итоги, где происходят изменения состояния и какие поля дрейфуют, чтобы вы могли обезопасить систему перед продакшеном.
Часто задаваемые вопросы
What should be the “source of truth” for an invoice total?
Используйте позиций (line items) (плюс явные корректировки: скидки, доставка, кредиты) как источник правды и вычисляйте итог из них в одном месте. Если вы также сохраняете итог на счёте для ускорения, рассматривайте это как кеш и регулярно валидайте с пересчётом, чтобы обнаруживать рассинхронизации.
How do I stop 1-cent rounding errors from showing up?
Храните деньги в виде целых минимальных единиц (например, центов) и применяйте единое правило округления. Плавающая арифметика в конце рано или поздно даст расхождение в 1 цент между UI, PDF, экспортом и записями по платежам.
Should users be able to edit an invoice after it’s sent?
По умолчанию делайте счета редактируемыми только в черновике, а денежные поля блокируйте после отправки. Если исправление влияет на сумму, создавайте новую версию, кредит-ноту или запись корректировки вместо незаметного перезаписывания старого счета.
Why model payments and refunds as separate records?
Разные события — разные записи. Платёж фиксирует поступившие деньги; возврат фиксирует возврат средств, как правило, связанный с конкретным платежом. Оба события должны остаться в истории для поддержки и сверок.
What invoice statuses should I store vs calculate?
Храните статус жизненного цикла документа (например: draft, sent, void) отдельно от статуса платежа (неоплачен, частично оплачен, оплачен, переплата). Payment-related метки выводите как производную величину от сумм и дат, а не храните их вручную.
How do I keep historical totals stable when tax rates or pricing rules change?
Снимайте снимок входных данных, которые влияют на итог, в момент отправки: использованную ставку налога, правила скидок, режим округления и отображаемые данные клиента. Пересчёты должны использовать этот снимок, чтобы старые счета не менялись при изменении настроек позднее.
Should tax be calculated per line item or at the invoice level?
Выберите один подход и придерживайтесь его. Налог на уровне позиции обычно проще объяснить и поддерживает разные ставки для разных товаров; налог на уровне счёта годится, если всё использует одну ставку — тогда храните саму ставку и базу, к которой она применялась.
How do I define “paid” when there are partial payments and refunds?
Определите это как вычисление: amount_due = invoice_total - (sum(payments) - sum(refunds/chargebacks)). Это покрывает частичные платежи, переплаты и возвраты без хрупкого булева поля «paid: true».
Should I delete invoices or void them?
Не удаляйте счета навсегда — это ломает нумерацию, аудит и сверки. Лучше аннулировать (void) с указанием причины: запись остаётся, сумма становится недосягаемой для взыскания, и вы сохраняете объяснение произошедшего.
My AI-generated invoicing app “mostly works” but breaks around totals—what should I do?
Если у вас прототип, созданный ИИ, где итоги дрейфуют, статусы непоследовательны или возвраты ломают балансы — FixMyMess может быстро диагностировать и исправить базу кода. Мы даём бесплатный аудит, который выявит, где расчёты и изменения состояния расходятся, а затем делаем продукт безопасным для продакшена с человеческой верификацией.