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

Что обычно идёт не так с мультивалютными счётами
Мультивалютные счета ломаются скучными и дорогими способами. Цифры в UI выглядят верно, затем кто-то экспортирует PDF, бухгалтерия импортирует его, и итоги больше не совпадают со строками.
Корень проблемы прост: расчёты с деньгами — это не просто умножение на курс. Налоги, округление и точный момент фиксации курса влияют на результат. Если модель данных цены не делает эти решения явными, разные части системы будут «полезно» пересчитывать и получать разные ответы.
Должны соглашаться три представления, даже если они показывают разные валюты:
- Вид для клиента: понятные цены в валюте клиента, с итогами, которые сходятся.
- Вид для бухгалтерии: согласованные базовые суммы для отчётности и сверки.
- Аудит-вид: бумажный след, показывающий, какой курс и правила округления применялись при формировании счёта.
Несоответствия обычно возникают из-за мелких решений, принятых в разных местах. Одна команда округляет каждую строку; другая округляет только итог. Одна страница использует текущий курс; другая — курс на дату счёта. Некоторые налоги начисляются до скидок, другие — после. Некоторые налоги включены в цену; другие добавляются сверху.
Конкретный пример: вы продаёте товар за 19.99 EUR, выставляете счёт в GBP и отчёт в USD. Если вы конвертируете по строкам и округляете до 2 знаков, вы можете получить другой итог налога, чем если сначала просуммировать, а затем один раз конвертировать. Оба подхода могут иметь смысл, но только один должен быть вашим правилом.
Цель — предсказуемые расчёты и понятные сохранённые значения. Каждый счёт должен без домыслов отвечать: какие суммы были введены, в какой валюте они вводились, какой курс использован (и когда), что было округлено (и как), и какие налоговые правила применялись. Такая ясность сохраняет итоги стабильными в UI, PDF, экспортах и при аудите.
Ключевые термины, с которыми стоит согласиться до проектирования схемы
Прежде чем рисовать таблицы, убедитесь, что все используют одни и те же слова. Большинство мультивалютных багов — это не технические, а баги «мы имели в виду разное». Чистая схема начинается с определений, которые принимают продукт, финансы и инженерия.
Валютные термины, влияющие на вашу базу данных
Для каждого денежного потока согласуйте три валюты:
- Transactional currency: валюта, которую видит и в которой соглашается клиент (прайс-лист, корзина, отображение счёта).
- Settlement currency: валюта, в которой вы фактически получаете оплату (ту, в которой проводит платёжный провайдер или банк).
- Reporting currency: валюта для дашбордов и сводной бухгалтерской отчётности.
Также определите minor units. У USD — 2 (центы), у JPY — 0, у KWD — 3. Это важно, потому что хранение «12.34» как числа с плавающей точкой приведёт к дрейфу, тогда как хранение целого в минорных единицах (например, 1234 цента) остаётся точным и делает округление предсказуемым.
Налоговые термины, которые меняют итоги
Налоги требуют такого же уровня согласования. Решите, являются ли цены tax-inclusive (показанная цена уже включает налог) или tax-exclusive (налог добавляется сверху). Также выберите, считается ли налог per line item (каждая строка отдельно, затем суммируется) или per invoice (сначала суммируете, затем считаете налог). Эти выборы влияют на округление и могут изменить итоговую сумму к оплате на несколько минорных единиц.
Наконец, решите, что нужно хранить, а что можно выводить заново:
- Храните то, что юридически и финансово важно: согласованные цены, применённые ставки налога, конечные округлённые итоги и используемую валюту.
- Выводите заново то, что можно безопасно пересчитать: форматированные строки, конверсии только для отображения и большинство промежуточных вычислений.
Основные поля для денег: что хранить и как
Начните с решения, какие числа — это факты, которые вы храните, а какие — результаты, которые можно пересчитать. Смешение этих двух подходов — причина, по которой счета показывают один итог на экране и другой в экспорте.
Храните деньги как целые в минорных единицах (например, центы) и всегда храните рядом код валюты. Сумма без валюты — неполные данные. Целые числа также избегают мелких ошибок с плавающей точкой, которые проявляются при суммировании множества строк.
Практический паттерн — хранить и исходные вводимые данные, и вычисленные результаты. Вводы объясняют, что ввёл пользователь. Выходы объясняют, что вы выставили к оплате. Когда кто-то оспаривает счёт через месяцы, вам нужны оба.
Для строк счёта чистый, долговечный набор полей может выглядеть так:
unit_price_minor+unit_currencyquantity(иuom, если нужно)line_subtotal_minor(до налога/скидки)line_discount_minorline_tax_minor(или разбивка по типам налогов)line_total_minor(финальная сумма по строке)
Округление — не просто деталь UI. Фиксируйте используемый метод округления и точность, особенно если вы поддерживаете валюты с разными минорными единицами (JPY vs USD) или правила округления наличных. Небольшая запись «calculation context» может содержать calc_precision, rounding_mode и флаг, происходит ли округление по строкам или только на сумме счёта.
Держите форматирование для отображения отдельно от сохранённых значений. Хранимые значения должны быть простыми числами и кодами; форматирование (символы валют, разделители, локализованный формат) — задача слоя представления. Например, храните 12345 + EUR, а UI решает, показывать «€123.45» или «123,45 €».
Курс обмена: таблицы, временные метки и след аудита
Трактуйте курсы обмена как временные данные с явным источником. «Сегодняшний курс» нельзя безопасно пересчитать позже.
Практическая таблица курсов обычно включает:
base_currency(от которой конвертировать, например USD)quote_currency(в которую конвертировать, например EUR)rate(котировка за 1 единицу базы, храните с высокой точностью)effective_at(временная метка, для которой курс валиден)source(провайдер) иsource_ref(их ID или хеш полезной нагрузки)
Эта информация источника важна в аудитах. Если клиент оспаривает сумму, вы сможете указать, откуда именно взялось число.
Далее выберите одно правило, какой курс использует счёт, и придерживайтесь его. Распространённые варианты: курс на момент заказа, на момент отгрузки или на момент выставления счёта. Лучший выбор зависит от бизнеса. Важно — последовательность и документация.
Каким бы ни был ваш выбор, сохраните точный курс, использованный в счёте (часто и на строке счёта). Не полагайтесь на повторный поиск позже. Добавьте поля вроде fx_rate, fx_rate_effective_at и fx_rate_source, чтобы счёт можно было воссоздать точно.
Для отсутствующих курсов (выходные, праздники, сбои провайдера) сделайте поведение явным. Типичные подходы: использовать последний доступный предыдущий курс, блокировать выставление счёта до появления курса или разрешать ручной курс с флагом одобрения.
Пример: заказ сделан в субботу, отгружен в понедельник, выставлен счёт в понедельник. Если ваше правило — курс на момент выставления, а провайдер не публикует курсы в выходные, вы можете использовать курс пятницы и зафиксировать effective_at = Пятница 23:59, вместе с source_ref для трассировки.
Конвертация валюты и правила округления, которые должны быть постоянными
Проблемы с округлением редко выглядят как очевидные баги. Они проявляются как разрыв в 1 цент между итогом счёта и суммой строк или как небольшие разницы налогов между тем, что вы показываете, и тем, что ожидает платёжный провайдер. Хорошие модели делают округление правилом, которое можно объяснить, а не сюрпризом, который потом латаете.
Точно определите, где происходит округление
Выберите точки, где допускаете округление, и сохраняйте всё остальное с повышенной точностью. Распространённые точки округления включают:
- Умножение в строке (количество × цена за единицу, после скидок)
- Каждая сумма налога (по строке или по счёту, в зависимости от юрисдикции)
- Финальный итог счёта
Если вы не определите эти точки, разные части системы будут округлять, когда им удобно, и итоги будут дрейфовать.
Используйте один режим округления, с явными исключениями для налогов
Выберите режим округления (half-up или bankers) и применяйте его последовательно. Half-up проще объяснить клиентам. Bankers rounding (округление банка) может уменьшать смещение при больших объёмах. Любой из них может подойти, но API, UI, экспорты и бухгалтерские отчёты должны использовать один режим.
Сохраняйте дополнительную точность при конвертации и промежуточных шагах (например, храните FX-курсы с множеством знаков), затем округляйте только в выбранных точках.
Скидки тоже нуждаются в едином правиле: применять скидки до налога (обычно для купонов) или после налога (иногда требуется для отдельных сборов). Пропишите это и внедрите один раз.
Некоторые юрисдикции требуют округления налога по строкам, по каждому налогу или на сумме счёта. Вместо того чтобы встраивать разрозненные решения в кодовую базу, храните настройку «rounding policy» (по стране/штату/налоговому режиму) и заставьте расчёты следовать этой политике.
Простая проверка: если вы восстанавливаете тот же счёт завтра с теми же сохранёнными курсами и политикой, вы должны получить те же центы каждый раз.
Налоговые поля: шаблоны для НДС, sales tax и множественных налогов
Налоги быстро усложняются, потому что зависят от местоположения покупателя, того, что вы продаёте, и того, показана ли цена с налогом или без. Чистая модель делает налоги явными, а не подразумеваемыми.
Сделайте базу налогообложения недвусмысленной. Храните, налогооблагается ли цена как NET (налог добавляется сверху) или как GROSS (налог включён). Затем сохраните и применённую ставку, и вычисленную сумму налога как снимок, чтобы будущие изменения правил не переписывали историю.
На каждой строке счёта минимальный набор, который останется понятным через годы:
tax_basis(NET или GROSS)tax_rate(десятичный, например 0.20)taxable_amount_minor(база, с которой вы считали налог)tax_amount_minortax_method(PER_LINE или ON_SUBTOTAL)
Если может применяться более одного налога (например НДС плюс городской сбор), добавьте отдельную таблицу разбивки, например InvoiceLineTax, с одной строкой на применённый налог. Каждая запись должна включать tax_code, tax_rate, taxable_amount_minor, tax_amount_minor, валюту и подсказки по юрисдикции, использованные при расчёте (страна, регион, почтовый индекс, когда релевантно).
Храните снимок применённых деталей правила на счёте или строке счёта, например rule_version или JSON-блоб с входными данными решения (статус налогоплательщика, reverse charge, льготы). Если правила НДС изменятся в следующем году, старые счета всё равно должны соответствовать тому, что вы фактически взяли.
Пример: подписка SaaS, проданная клиенту в Германии, может применять 19% НДС к NET-цене строки плюс 1% местного сбора. Храните итоговые суммы по строкам и разбивку по каждому налогу для показа и аудита.
Как проектировать таблицы шаг за шагом
Здесь меньше хитрой математики и больше фиксации правильных фактов в нужный момент. Цель — чтобы счёт можно было открыть через месяцы и он показывал те же числа.
Начните с решения, где хранится истина по ценам товара. Многие команды хранят базовую цену в базовой валюте на продукт и опционально добавляют переопределения по рынку (например, отдельные строки цен для USD и EUR). Что бы вы ни выбрали, сделайте это явным в схеме, чтобы не смешивать «цены каталога» с «конвертированной ценой».
Простая последовательность, которая делает таблицы понятными:
- Продукты и цены:
product_id,price_amount_minor,price_currency,effective_from(если цены меняются со временем). - Заголовки заказов и счетов:
document_currency,customer_locale,billing_countryи временные метки (issued_at,tax_point_at). - Строки:
unit_price_amount_minor,quantity,discount_amount_minor,tax_amount_minor,line_total_amount_minor, и валюта для каждого сохранённого денежного поля. - Снимок обменного курса: используемый точный курс (
rate_value,rate_provider,rate_timestamp), на который ссылается заказ или счёт. - Записи разбивки налогов: одна строка на налог (
tax_type,rate_percent,taxable_base_minor,tax_amount_minor) плюс флагcalculation_method.
Не полагайтесь на перерасчёт позже. Когда вы создаёте счёт, скопируйте окончательные цены за единицу, скидки и итоги в строки счёта, даже если они пришли из заказа.
Для трассируемости добавьте calculation_version (или calc_hash) в счёт и небольшую таблицу calculation_log, которая фиксирует, кто инициировал перерасчёт и почему (например, "курс обновлён перед выпуском").
Локализованный показ счёта, не ломая числа
Локализация должна менять внешний вид счёта, а не его смысл. Выполняйте все расчёты на основе сохранённых числовых значений (минорные единицы или фиксированная точность), затем применяйте локализованное форматирование в самом конце.
Держите настройки отображения счёта в самом счёте, а не только в профиле клиента. Клиенты меняют страны, контакты и предпочтения со временем. Счёт — юридический снимок. Храните invoice_language, invoice_locale и флаги форматирования (например, показывать ли завершающие нули) с документом, чтобы перепечатка через полгода соответствовала оригиналу.
Символы валюты — дело представления. В одних локалях символ ставится перед суммой, в других — после. Где-то нужен пробел, где-то нет. Обрабатывайте размещение символа, пробелы, десятичные разделители и группировку тысяч на этапе рендеринга, исходя из локали и валюты счёта. Не встраивайте символы в сохраняемые поля и не парсите форматированные строки обратно в числа.
Если нужен отчёт во второй валюте (обычно в домашней валюте, например USD или EUR), показывайте его явно как вторичный итог, а не заменяйте основной. Валюта документа остаётся юридическим источником истины.
Практическая настройка вывода счёта:
- Показывайте строки и итоги в валюте документа, используя форматирование по локали счёта.
- Опционально показывайте вторичный итог для отчётности с указанием источника курса и временной метки.
- Показывайте разбивку налогов отдельными строками (налогооблагаемая база, каждый налог, общий налог), а не смешанную сумму.
- Рендерьте PDF и письма из тех же сохранённых итогов, чтобы числа не дрейфовали.
Пример: французский клиент выставлен счёт в CHF. Локаль счёта использует запятую как десятичный разделитель и ставит валюту после суммы, но расчёты всё ещё используют сохранённые суммы в CHF и сохранённые налоговые итоги. Отображение меняется; числа — нет.
Частые ошибки и ловушки, которых следует избегать
Самый быстрый путь сломать мультивалютные счета — обращаться с деньгами как с обычным числом. Типы с плавующей точкой для цен, налогов и итогов создают мелкие ошибки, которые позже проявляются как «разница $0.01». Храните суммы как целые в минорных единицах (цены) или используйте фиксированную десятичную типизацию с ясной шкалой, и применяйте её последовательно.
Ещё одна классическая ловушка — непреднамеренное изменение истории. Если вы перерасчитываете старый счёт по сегодняшнему курсу или по обновлённым налоговым правилам, вы больше не имеете документ, который клиент видел и оплатил. Счета должны быть неизменяемыми: после выпуска сохраняйте точный курс, правила округления и метод налогообложения и не пересчитывайте сохранённые итоги.
Смешивание валют внутри одной строки — тихий баг схемы. Если цена за единицу в EUR, скидка в USD, а налог считают в GBP, позже вы не сможете объяснить математику. Выберите одну валюту документа для отображения и расчёта, и одну базовую валюту для внутренней отчётности (если нужно). Каждая сохранённая сумма должна иметь явную валюту.
Ошибки округления часто возникают из-за слишком частого округления. Если вы округляете цену за единицу, затем итог по строке, затем налог по строке, затем снова сумму, итоги могут перестать совпадать с суммой строк.
Типичные ловушки, за которыми стоит следить:
- Использование float для денег или курсов без фиксированной точности
- Повторный пересчёт старых счетов по новым курсам вместо использования сохранённых курсов
- Позволять одной строке содержать суммы в разных валютах
- Округление на множестве шагов вместо в чётко определённых точках
- Не хранить метку времени курса, режим округления и метод налогообложения для документа
Пример: вы создаёте счёт в CAD, конвертируете сервис, номинированный в EUR, затем позже обновляете таблицу курсов. Если вы сохранили только EUR-значение и конвертируете при показе, итог в CAD изменится на следующей неделе. Храните EUR-значение, применённый FX-курс (и время) и финальные CAD-суммы, использованные в счёте.
Быстрый чек-лист перед релизом
Прежде чем объявлять мультивалютные счета «готовыми», сделайте финальную проверку на согласованность. Большинство багов здесь — несложные. Они возникают из несоответствий между тем, что вы храните, что показываете и что суммируете.
Используйте это как контроль перед релизом:
- В каждом счёте есть ровно одна валюта документа в заголовке, и каждая сохранённая сумма на счёте находится в этой валюте.
- Каждое денежное значение хранится как целое в минорных единицах, включая итоги строк, суммы налогов, скидки и доставку.
- Счёт хранит точный обменный курс, использованный (как точный десятичный), плюс временную метку и источник курса.
- Правила округления документированы и реализованы в одном общем месте.
- Если может применяться более одного налога, вы храните разбивку налогов по строке (и опционально по юрисдикции), а не только одну сводную сумму в заголовке.
После проверки схемы проверьте математику так, как это сделал бы аудитор. Итоги счёта должны равняться сумме сохранённых итогов строк и сохранённых сумм налогов. Не пересчитывайте итоги из отображаемых значений или форматированных строк.
Практический тест: выберите счёт минимум с тремя строками, примените скидку и включите два налога в одной строке. Затем распечатайте его в другой локали (другие разделители и символ валюты) и подтвердите, что сохранённые числа не изменились.
Пример сценария: один заказ, три валюты и налоги
Клиент из США выставлен счёт в USD, ваш EU-поставщик выставляет вам счёт в EUR, а финансовая команда отчитывается в GBP. Здесь модель либо остаётся спокойной, либо превращается в кучу расхождений в 1 цент.
Заказ: 3 единицы продукта.
- Цена для клиента: $19.99 за единицу (USD)
- Скидка: 10% на строку
- Налог продаж в США: 8.25% (налог начисляется после скидки)
- Себестоимость поставщика: EUR 12.40 за единицу (EUR)
- Валюта отчётности: GBP
Разбор: что происходит и когда конвертировать
Выберите один момент конвертации и придерживайтесь его. Во многих системах выставления счётов безопасный выбор — конвертировать на момент выпуска счёта и затем сохранить использованный точный курс.
При создании счёта:
- Вычислите подитог по строке в USD: 3 × 19.99 = 59.97 USD.
- Примените скидку: 59.97 × 10% = 5.997, округлённое до 6.00 USD.
- Чистая по строке: 59.97 − 6.00 = 53.97 USD.
- Налог: 53.97 × 8.25% = 4.452525, округлён до 4.45 USD.
- Итого: 53.97 + 4.45 = 58.42 USD.
Округление происходит только в заранее определённых точках (скидка, каждая сумма налога, итоги строк). Сохраняйте эти округлённые результаты и всегда суммируйте сохранённые значения. Это предотвращает классическую проблему, когда в PDF показано 58.42, а экспорт пересчитывает 58.43.
Что сохранять, чтобы воспроизвести счёт позднее
В счёте (и в строках) храните код валюты (USD), суммы в минорных единицах (центы), разбивку налогов по типам и идентификаторы записей обменных курсов, использованных для конвертации USD в GBP для отчётности. Для себестоимости поставщика храните EUR-стоимость и её собственную запись курса, если вы также конвертируете затраты в GBP.
Клиент видит чистый счёт в USD (цены, скидка, налог, итог). Финансы экспортируют суммы в USD плюс замороженные эквиваленты в GBP и точные временные метки курсов, чтобы месячные отчёты совпадали, даже если курсы изменятся завтра.
Следующие шаги: реализовать, протестировать и поддерживать
Запишите вашу минимальную схему как краткий контракт: какие суммы сохраняются (оригинал, конвертированные, налог), в какой валюте каждая сумма хранится, какое правило округления применяется и какая временная метка закрепляет обменный курс для счёта. Делайте это скучно и конкретно.
Прежде чем делать UI-экраны, напишите тесты. Не тестируйте только обычные счета. Добавьте крайние случаи, которые достаточно малы, чтобы обнаружить шум округления, и достаточно большие, чтобы выявить проблемы агрегации.
Набор стартовых тест-кейсов:
- Крошечные цены за единицу (например, 0.01) при больших количествах
- Скидки, создающие периодические десятичные дроби после конверсии
- Изменения курса между датой заказа и датой счёта
- Смешанные налоговые правила (цена с налогом vs цена без налога) в одном типе счёта
- Возвраты и кредит-ноты, которые должны совпадать с оригинальным округлением
Чтобы сократить количество обращений в поддержку, добавьте просмотр аудита, который объясняет каждое число в счёте: сохранённые суммы, коды валют, ID и время курса обмена и метод округления. Когда кто-то спрашивает «почему итог другой?», вы сможете ответить по сохранённым фактам.
Если вы строите внутренний биллинг-инструмент, no-code платформа вроде AppMaster (appmaster.io) может помочь сохранить согласованность, поместив схему в одно место и логику расчётов в один переиспользуемый рабочий процесс, чтобы веб- и мобильные экраны не выполняли свою собственную версию математики.
Наконец, назначьте владельца. Решите, кто обновляет курсы, кто обновляет налоговые правила и кто утверждает изменения, влияющие на выпущенные счета. Стабильность — это процесс, а не только схема.


