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

Версия тарифа и неизменяемая цена заказа

Клиенту удобно отправить название тарифа, а серверу — найти предложение и создать заказ. Если клиент передаст сумму самостоятельно, он сможет заменить 990 рублей на одну копейку. Даже добросовестный клиент может держать старую вкладку после изменения цены. Поэтому каталог и снимок заказа должны иметь явную версию.

В «Пространстве» каталог содержит Team версии 1: 99000 копеек RUB за календарный месяц доступа к расширенному экспорту. Это учебная цена, а не рекомендация по стоимости продукта. Полный SQL sql/04-billing.sql и обработчики находятся в архиве. Каталог при подготовке не загружался в работающую базу; ответы являются ожидаемыми.

Тариф — общее предложение

Справочник планов общий для компаний. В нём сохраняются ключ, версия, цена, валюта и признак доступности новым заказам. Поле active означает доступность предложения, но не отменяет уже оплаченные периоды. Если снять Team v1 с продажи, старый заказ продолжает ссылаться на свой снимок.

Ниже приведён полный фрагмент определения таблицы каталога. Его составной ключ допускает несколько версий одного названия. Конкретная версия не обновляется для изменения цены: новое предложение создаётся новой строкой.

CREATE TABLE plans (
  code text NOT NULL,
  version integer NOT NULL,
  amount_minor bigint NOT NULL CHECK(amount_minor>0),
  currency text NOT NULL CHECK(currency='RUB'),
  active boolean NOT NULL,
  PRIMARY KEY(code,version)
);
INSERT INTO plans VALUES ('team',1,99000,'RUB',true);

Приложение получает право SELECT каталога, но не право менять цену через обычный пользовательский API. Иначе owner одной компании мог бы превратить общий справочник в своё предложение для всех клиентов. Управление каталогом относится к оператору продукта и отдельной редакционной процедуре, которая не реализована в учебной форме оплаты.

Версия помогает объяснить изменение предложения пользователю. Интерфейс получает Team v1 и отправляет именно этот номер. Если версия уже недоступна, сервер возвращает предметный 409 и предлагает перечитать каталог. Молчаливый переход на новую цену неудобен: человек подтвердил одно предложение, а заказ создался на другое.

Заказ копирует условия покупки

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

Целое тело создания в нашем контракте выглядит так. OrganizationId находится в адресе и проверяется существующим tenant-фильтром. Amount отсутствует, потому что клиент выбирает предложение, а не назначает цену.

{ "planCode": "team", "planVersion": 1 }

Ожидаемая строка заказа содержит amount_minor: 99000, currency: RUB. Добавленное клиентом поле amount: 1 не используется. Здесь, как и с компанией в прошлом курсе, защита состоит в источнике значения: SQL получает цену из найденного серверного предложения. Игнорирование лишнего JSON-поля само по себе не является финансовой проверкой.

После сохранения заказа его цену не редактируют обычным PATCH. Если владелец выбрал другое предложение до оплаты, создаётся новое намерение по согласованному правилу. Незаметная смена суммы уже существующего заказа разрушила бы связь с созданной попыткой провайдера. Особенно опасно менять цену, когда клиент уже находится на странице подтверждения.

Оплачиваемый период не зависит от скорости браузера

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

Если подтверждение задержалось, доступ не должен произвольно начинаться со старого истёкшего момента без объяснения. В нашем контракте первоначальная покупка рассчитывается на момент создания, а при слишком старом неоплаченном заказе требуется новое предложение. Полный пример ограничивает срок pending-заказа локальным правилом. Для настоящего продукта возможен другой выбор, но он обязан быть согласован с пользователем.

Продление будет иметь отдельный source key, связанный с очередной границей периода. Его условия берутся из сохранённой подписки, а не из открытой вкладки. Это позволяет повторять scheduler после сбоя и получать тот же заказ. Изменение цены действующей подписки потребовало бы явного процесса уведомления и принятия новой версии, поэтому курс его не скрывает внутри обычного продления.

Скидка тоже является серверным правилом

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

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

Нулевой бесплатный план также не должен создавать фиктивный успешный платеж. Бесплатное право можно выдавать собственной продуктовой командой, отдельно от получения денег. Мы оставляем базовые проекты доступными без Team, а платный экспорт появится как конкретное entitlement. Это позволит позднее отличить доступ компании от текущего платежного состояния.

Изменение каталога после оформления

Для ручного наблюдения создайте заказ Team v1, затем добавьте новую строку Team v2 с другой ценой и снимите v1 с продажи. Ожидаемо старый заказ сохраняет 99000, а новый запрос v1 получает отказ предложения. Существующая попытка оплаты не начинает внезапно требовать цену v2.

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

После этого шага у покупки появились серверная цена и стабильные условия. Следующий урок добавит идемпотентное создание заказа, чтобы повтор одного и того же намерения после потери HTTP-ответа не создавал второй заказ на ту же покупку.

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