Перейти к содержанию

Деньги, заказ и платеж — разные объекты

«Пространство» уже разделяет компании и их данные. Теперь «Север» хочет приобрести тариф Team с расширенным экспортом. До обращения к платежному сервису нужно определить, что именно покупается, сколько стоит и какое подтверждение меняет доступ. Начнём с модели денег и различия между заказом, платежом и подпиской.

Курс продолжает SaaS для нескольких компаний. Он использует ASP.NET Core 10, Npgsql 10.0.0 и PostgreSQL 18. Все денежные действия выполняет локальный fixture-адаптер: он не списывает деньги и не отправляет запросы ЮKassa. Приложение при подготовке не запускалось, статус выполнения примеров — not_run. Любые ответы провайдера в уроках ожидаемые.

Сумма всегда имеет валюту

Число 990 само по себе недостаточно. Это могут быть рубли, копейки или сумма в другой валюте. В нашем контракте Money содержит целое количество минимальных единиц и код валюты. Для RUB единицей хранения является копейка, поэтому цена 990 рублей записывается как 99000.

Ниже приведено целое определение учебного типа из Money.cs. Пример намеренно поддерживает только RUB. Добавление других валют потребует справочника их разрядности и правил отображения; нельзя автоматически считать, что любая валюта имеет две дробные цифры.

public readonly record struct Money(long Minor, string Currency)
{
    public string ToProviderValue()
    {
        if (Currency != "RUB" || Minor < 0)
            throw new ArgumentException("Unsupported money");
        return (Minor / 100m).ToString("0.00", CultureInfo.InvariantCulture);
    }
}

decimal используется только для точного представления перед сериализацией. В базе остаётся bigint минимальных единиц. Тип double не подходит для прямого сравнения денежных итогов, потому что двоичное представление десятичных долей может дать неожиданную разницу. Даже с decimal нужно определить округление, если появятся скидки и налоги; тип числа не задаёт предметное правило сам.

InvariantCulture сохраняет точку в строке провайдера независимо от языка машины. В интерфейсе можно показать 990,00 ₽, но транспортный формат нельзя менять вслед за локалью пользователя. В первичном справочнике API ЮKassa сумма представлена отдельными полями value и currency; наш адаптер сохраняет ту же ясность, не реализуя реальный вызов.

Заказ фиксирует предложение компании

Заказ отвечает на вопрос: «какую услугу компания согласилась купить на каких условиях». Он содержит снимок тарифа, сумму, валюту и оплачиваемый период. Если завтра цена Team изменится, сегодняшний заказ сохраняет прежнюю цену. Иначе подтверждение старого платежа оказалось бы связано с новым предложением, которое компания не принимала.

Платеж отвечает на другой вопрос: «какая попытка передачи денег относится к этому заказу». Он содержит идентификатор провайдера, ключ повторяемого создания и состояние. Заказ может существовать до создания платежа. Неудачная попытка не должна уничтожать сам заказ или переписывать его стоимость; повтор можно связать с тем же намерением покупки по отдельному правилу.

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

Объект Предметное значение Что не является доказательством
Заказ Снимок покупки Нажатая кнопка оформления
Платеж Состояние попытки у провайдера Возвращение браузера
Подписка Период и правило продления Наличие незавершённого платежа
Entitlement Разрешённая функция на срок Название тарифа в интерфейсе

Принадлежность денег остаётся компанией

Все прикладные финансовые строки содержат organization_id. Заказ «Севера» не становится заказом Анны лично, хотя Анна инициировала покупку. При смене владельца компании история остаётся у компании. Инициатор сохраняется для аудита, но не заменяет владельца денег и доступа.

Платеж ссылается на заказ составным внешним ключом (organization_id,order_id). Это продолжает модель предыдущего курса: нельзя правильно назначить платежу «Юг», но связать его с заказом «Севера». Локальный адаптер имеет отдельную техническую таблицу, где хранится имитация провайдера. Она не выдаётся пользователям как общий список платежей всех клиентов.

Роль billing в примере не вводится отдельно: финансовыми командами управляет owner. Для настоящего продукта можно добавить финансового участника, но его права должны быть конкретными. Администратор проектов не обязан автоматически получать право делать возвраты. Сначала определите действия, затем расширяйте матрицу ролей.

Что означает локальная имитация

Fixture-адаптер хранит созданные платежи в PostgreSQL и меняет их состояние через loopback-маршруты. Это позволяет изучить перезапуск процесса, повтор команды и уведомление без реквизитов настоящего магазина. Простой словарь в памяти потерял бы состояния после остановки и сделал бы уроки сверки менее содержательными.

Ни один маршрут курса не принимает номер карты, CVV либо банковские данные. Сохранённый способ оплаты обозначается вымышленным fixture-method. Он нужен только расписанию будущего урока. Подключение тестового магазина ЮKassa возможно как отдельная адаптация после чтения официального раздела тестирования, но не требуется для прохождения и не выполнялось.

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

Сравнение суммы выполняется в одной единице

Если интерфейс показывает рубли, а база хранит копейки, преобразование делается в одном контролируемом месте. Не принимайте одинаково поля amount и amountMinor: два похожих имени могут скрыть различие в сто раз. В нашем контракте запрос возврата использует AmountMinor, а transport провайдера при необходимости получает точную строку. При вычислении большого итога используйте проверяемую арифметику и пределы предметных значений, чтобы переполнение не превратило положительную сумму в отрицательную. Ограничение базы дополняет эту проверку, но понятный отказ лучше сформировать до сохранения.

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

Оглавление серии