ЮKassa, первый платёж в AI-продукте, купоны, анти-даблклик и запрет дублей подписки

ЮKassa создает платеж по запросу backend. Сервер передает сумму, валюту, режим capture, данные подтверждения, описание и Idempotence-Key. При confirmation.type=redirect ЮKassa возвращает адрес страницы оплаты. Frontend переводит пользователя на этот адрес.
Купон, проверка активной подписки и блокировка повторного POST работают на стороне приложения. В ЮKassa уходит уже рассчитанная сумма.
Ниже используется реализация из проекта AI-Chat, платежи в профиле пользователя, Render просыпается пару минут. Frontend написан на Next.js, платежный backend на Django REST Framework.
Первый платеж в ЮKassa
Для первого платежа сервер формирует объект с суммой в RUB, confirmation, return_url, описанием и внутренним идентификатором платежа в metadata.
В проекте первый платеж создается с capture=false. После оплаты он проходит через двухстадийный сценарий с состоянием waiting_for_capture. Для тарифов monthly и yearly сервер добавляет save_payment_method=true. У forever способ оплаты для следующих списаний не сохраняется.
def create_payment_data(
amount,
description,
local_payment_id,
subscription_type,
receipt,
payment_method_id=None
):
save_pm = subscription_type in ("monthly", "yearly")
data = {
"amount": {
"value": str(amount),
"currency": "RUB",
},
"capture": False,
"confirmation": {
"type": "redirect",
"return_url": f"{settings.FRONT_URL}/payment/success",
},
"description": description,
"receipt": receipt,
"metadata": {
"payment_id": local_payment_id,
"subscription_type": subscription_type,
},
}
if save_pm:
data["save_payment_method"] = True
return data
metadata.payment_id связывает платеж ЮKassa с локальной записью. subscription_type остается доступен при дальнейшей обработке уведомлений.
Сумма платежа
Frontend не передает итоговую сумму в process-kassa. Он отправляет тип покупки и код купона.
const resp = await post(
"/api/payment/process-kassa/",
{
subscription_type: paymentType,
coupon_code: couponCode,
},
freshSession.accessToken
);
Django сам получает базовую цену.
BASE_PRICES = {
"monthly": Decimal("300"),
"yearly": Decimal("2500"),
"forever": Decimal("5000"),
}
def get_base_amount(subscription_type: str) -> Decimal:
if subscription_type not in BASE_PRICES:
raise ValueError("Unknown subscription_type")
return BASE_PRICES[subscription_type]
Сумма из React state не участвует в создании платежа. Изменение DOM, локального BASE_PRICES или значения preview не меняет сумму, которую Django передаст в ЮKassa.
Купон
Купон хранится в Django. Модель содержит код, процент скидки, срок действия, флаг active и необязательную привязку к типу подписки.
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
discount = models.DecimalField(max_digits=5, decimal_places=2)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
active = models.BooleanField(default=True)
subscription_type = models.CharField(
choices=[
("monthly", "Monthly"),
("yearly", "Yearly"),
("forever", "Forever"),
],
max_length=10,
blank=True,
null=True,
)
Backend проверяет активность купона, срок действия и соответствие выбранному тарифу.
def apply_coupon(coupon_code, subscription_type):
try:
coupon = Coupon.objects.get(
code=coupon_code,
active=True,
)
if not coupon.apply_to_subscription(subscription_type):
raise ValueError(
"Этот купон не подходит для выбранной подписки."
)
if not coupon.is_valid():
raise ValueError("Купон просрочен.")
return coupon.discount
except Coupon.DoesNotExist:
raise ValueError("Неверный или неактивный купон.")
ЮKassa код купона не получает. Django передает в платеж конечное значение amount.
Preview суммы
Next.js отправляет отдельный запрос на /validate-coupon/. В запросе идут subscription_type и coupon_code.
const resp = await post(
"/api/payment/validate-coupon/",
{
subscription_type: paymentType,
coupon_code: couponCode,
},
freshSession.accessToken
);
Django возвращает базовую сумму, конечную сумму и процент скидки.
return Response({
"valid": True,
"base_amount": str(base_amount),
"final_amount": str(final_amount),
"discount_percentage": str(discount_percentage),
})
Скидка рассчитывается с точностью до копеек через ROUND_HALF_UP. Конечная сумма округляется до целого рубля.
discount_amount = (
base_amount
* Decimal(discount_percentage)
/ Decimal("100")
).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
final_amount = (
base_amount - discount_amount
).quantize(
Decimal("1"),
rounding=ROUND_HALF_UP,
)
process_kassa повторно проверяет купон и повторно рассчитывает сумму перед созданием платежа.
discount_percentage = 0
if coupon_code:
discount_percentage = apply_coupon(
coupon_code,
subscription_type,
)
amount, discount_amount = compute_final_amount(
base_amount,
discount_percentage,
)
Preview не фиксирует цену платежа. Между проверкой купона и нажатием оплаты купон может закончиться, стать неактивным или перестать соответствовать тарифу. Django проверяет его еще раз при process-kassa.
Preview в Next.js
PricePreview показывает серверные base_amount, final_amount и discount_percentage после проверки купона.
{!!couponCode && preview && preview.valid && (
<p className="text-gray-700">
Итого: <span className="line-through">
{preview.base_amount} ₽
</span>{" "}
<b>{preview.final_amount} ₽</b>{" "}
<span className="text-green-600">
({`−${preview.discount_percentage}%`})
</span>
</p>
)}
При смене тарифа PaymentContainer сбрасывает старый preview.
setPaymentType={t => {
setPaymentType(t);
setPreview(null);
}}
Текст купона хранится отдельно. В текущем варианте setCouponCode не сбрасывает preview. Пользователь может проверить один купон, затем изменить текст в поле и до следующей проверки видеть старую сумму.
Обработчик изменения купона можно записать со сбросом preview.
const changeCouponCode = (value: string) => {
setCouponCode(value);
setPreview(null);
};
CouponInput и быстрые кнопки купонов в этом варианте получают changeCouponCode вместо прямого setCouponCode.
Анти-даблклик в Next.js
handleSubmit ставит loading=true перед запросом на создание платежа.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const freshSession = await getSession();
if (!freshSession?.accessToken) {
setError("Нужно войти в аккаунт.");
setLoading(false);
return;
}
const resp = await post(
"/api/payment/process-kassa/",
{
subscription_type: paymentType,
coupon_code: couponCode,
},
freshSession.accessToken
);
if (resp.data.session_url) {
window.location.href = resp.data.session_url;
}
} catch (err) {
setLoading(false);
}
};
Кнопка использует тот же state.
<button
type="submit"
disabled={loading}
>
{loading ? "Обработка..." : "Оплата"}
</button>
Второй клик по этой кнопке в текущем экземпляре формы блокируется. Другой таб, повтор HTTP-запроса и два параллельных запроса на backend от состояния React не зависят.
Idempotence-Key и повторный клик
При создании платежа ЮKassa получает Idempotence-Key. В проекте Django генерирует новый UUID для каждого вызова process_kassa.
idem = str(uuid.uuid4())
kassa_payment = Payment.create(payment_data, idem)
Два отдельных POST на process-kassa получат два разных UUID. Для приложения это две разные операции создания платежа.
Backend проверяет повторный запуск до вызова Payment.create.
Inflight payment в Django
recent_inflight_payment_exists ищет платеж того же пользователя и того же типа за последние 90 секунд.
def recent_inflight_payment_exists(
user,
subscription_type: str,
window_seconds: int = 90,
) -> bool:
window_start = timezone.now() - timedelta(
seconds=window_seconds
)
return (
KassaPayment.objects.filter(
user=user,
subscription_type=subscription_type,
created_at__gte=window_start,
status="pending",
).exists()
or
KassaPayment.objects.filter(
user=user,
subscription_type=subscription_type,
created_at__gte=window_start,
kassa_payment_status="waiting_for_capture",
).exists()
)
process_kassa вызывает эту функцию до создания следующего локального платежа.
if recent_inflight_payment_exists(
request.user,
subscription_type,
window_seconds=90,
):
return Response(
{
"error":
"Платёж уже создаётся, подождите пару секунд "
"и проверьте ссылку на оплату."
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
Первый запрос создает локальную запись со статусом pending. Следующий запрос того же типа попадает под проверку.
Тест фиксирует два последовательных POST. Первый возвращает 200 и session_url, второй получает 429.
Создание локальной записи
Django создает KassaPayment перед запросом в ЮKassa.
payment = KassaPayment.objects.create(
user=request.user,
amount=amount,
subscription_type=subscription_type,
coupon_code=coupon_code or "",
discount=int(discount_amount),
status="pending",
)
Локальный id сразу попадает в metadata удаленного платежа.
После ответа ЮKassa backend записывает kassa_payment_id.
kassa_payment = Payment.create(payment_data, idem)
payment.kassa_payment_id = kassa_payment.id
payment.save(
update_fields=[
"kassa_payment_id",
"updated_at",
]
)
При ошибке Payment.create локальная запись удаляется.
except Exception:
payment.delete()
return Response(
{"error": "Ошибка при создании платежа"},
status=500,
)
Следующий запрос после такого сбоя не видит оставшийся pending от несуществующего платежа ЮKassa.
Активная monthly или yearly подписка
Перед созданием платежа Django проверяет активную подписку выбранного типа.
existing = Subscription.objects.filter(
user=request.user,
plan=subscription_type,
status="active",
next_charge_at__gt=timezone.now(),
).first()
Проверка применяется к monthly и yearly. Она сравнивает конкретный plan. Активный yearly блокирует новую покупку yearly, активный monthly блокирует новую покупку monthly.
Backend возвращает 409, название плана и дату следующего списания.
if existing:
return Response({
"error":
"У вас уже есть активная подписка этого типа.",
"plan": existing.plan,
"next_charge_at":
existing.next_charge_at.isoformat(),
}, status=status.HTTP_409_CONFLICT)
Новый объект ЮKassa при таком ответе не создается.
Forever
forever не входит в модель Subscription. Повторная покупка проверяется по истории платежей.
def has_active_forever_purchase(user) -> bool:
return KassaPayment.objects.filter(
user=user,
subscription_type="forever",
kassa_payment_status="succeeded",
).exclude(
status="refund",
).exists()
Успешный forever без полного возврата блокирует новый платеж.
if subscription_type == "forever":
if has_active_forever_purchase(request.user):
return Response({
"error":
"Бессрочная покупка уже оформлена. "
"Повторная оплата не требуется."
}, status=status.HTTP_409_CONFLICT)
После записи со статусом refund проверка перестает считать такую покупку действующей.
Ответы Next.js
Next.js разбирает HTTP-статусы process-kassa.
409 с next_charge_at относится к активной monthly или yearly подписке. Frontend форматирует дату следующего списания и показывает ее пользователю.
if (status === 409 && data?.next_charge_at) {
const when = new Date(
data.next_charge_at
).toLocaleString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
setError(
`У вас уже есть активная подписка ${data.plan}. ` +
`Следующее списание: ${when}.`
);
}
409 от повторного forever не содержит next_charge_at. Frontend показывает error, который вернул Django.
429 относится к недавнему inflight payment.
else if (status === 429) {
setError(
data?.error ||
"Платёж уже создаётся. Подождите пару секунд."
);
}
400 используется для неизвестного типа подписки и ошибок купона.
Redirect на ЮKassa
После всех проверок Django создает объект платежа и возвращает confirmation_url.
return Response(
{
"session_url":
kassa_payment.confirmation.confirmation_url
},
status=200,
)
Next.js не строит платежную форму для карты. Он меняет адрес страницы.
if (data.session_url) {
window.location.href = data.session_url;
}
Дальше пользователь работает со страницей подтверждения ЮKassa. return_url ведет обратно на /payment/success.
Параллельные запросы
Проверка inflight построена как exists() перед KassaPayment.objects.create(). Первый запрос обычно успевает создать локальную строку до второго POST.
Два запросa, пришедшие одновременно до первого INSERT, могут оба пройти exists(). В текущем коде между проверкой и созданием записи нет блокировки строки или уникального checkout identifier.
Строгая защита для такого случая переносится на уровень базы или идентификатора операции. Сервер может принимать checkout_id, создавать его один раз и использовать один Idempotence-Key для повторов одной попытки оплаты. Другой вариант использует транзакцию и отдельную запись попытки с уникальным ограничением.
Клиентский loading и 90-секундный inflight закрывают обычный повторный клик. Они не задают взаимное исключение для двух одновременных транзакций.
Последовательность первого платежа
Next.js хранит выбранный тариф и купон. Django проверяет купон и возвращает preview. При оплате Django заново получает базовую цену, заново проверяет купон и считает конечную сумму.
Django проверяет активную подписку, успешный forever и недавний inflight payment. После проверок создается локальный KassaPayment.
Сервер формирует receipt и объект платежа ЮKassa с той же суммой. ЮKassa возвращает confirmation_url. Next.js переводит браузер на этот адрес.
Для monthly и yearly первый платеж содержит save_payment_method=true. Для forever этот параметр не добавляется.
После первого POST кнопка Next.js остается disabled до redirect или ошибки. Повторный запрос, дошедший до Django после создания локальной записи, получает 429. Активная подписка выбранного типа и повторный forever получают 409.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.