From e44a09312edb936104ae9f4641f3e0452f275e79 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 16:10:32 +0300 Subject: [PATCH] fix(subscription): forward subscription_id to purchaseTariff to fix renew race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to bedolaga-bot a527df23. Bot's purchase-tariff endpoint now accepts optional subscription_id to resolve the EXACT target row by ID instead of doing a (user_id, tariff_id) re-lookup that races with concurrent panel webhooks (produces 'Тариф уже активен' + refund + no extension). Frontend changes: * subscriptionApi.purchaseTariff() — new optional subscriptionId arg appended after trafficGb. Forwarded as subscription_id in POST body. * SubscriptionPurchase.tsx — forwards URL searchParam subscriptionId (set when user lands here from 'Renew this subscription'). * SubscriptionCardExpired.tsx — passes subscription.id when renewing an expired daily tariff (the .purchaseTariff(tariff_id, 1) call). --- src/api/subscription.ts | 9 +++++++++ src/components/dashboard/SubscriptionCardExpired.tsx | 7 +++++-- src/pages/SubscriptionPurchase.tsx | 12 +++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index c80bad4..83b103c 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -398,6 +398,14 @@ export const subscriptionApi = { tariffId: number, periodDays: number, trafficGb?: number, + /** + * Subscription ID being renewed. Pass this when the user clicked + * "Renew" on an existing subscription so the backend can resolve + * the target row by ID instead of doing a (user_id, tariff_id) + * re-lookup that races with concurrent panel webhooks. Omit when + * this is a fresh purchase from the catalog. + */ + subscriptionId?: number, ): Promise<{ success: boolean; message: string; @@ -411,6 +419,7 @@ export const subscriptionApi = { tariff_id: tariffId, period_days: periodDays, traffic_gb: trafficGb, + subscription_id: subscriptionId, }); return response.data; }, diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 4709ded..5d86ce7 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -59,8 +59,11 @@ export default function SubscriptionCardExpired({ // Resume daily subscription via toggle pause endpoint await subscriptionApi.togglePause(subscription.id); } else if (isDaily && subscription.tariff_id) { - // Expired daily tariff — purchase for 1 day - await subscriptionApi.purchaseTariff(subscription.tariff_id, 1); + // Expired daily tariff — purchase for 1 day. Pass subscription.id + // so the backend resolves the EXACT row instead of doing a + // (user_id, tariff_id) re-lookup that races with concurrent + // panel webhooks (would surface as "Тариф уже активен" + refund). + await subscriptionApi.purchaseTariff(subscription.tariff_id, 1, undefined, subscription.id); } else { await subscriptionApi.renewSubscription(30, subscription.id); } diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 8221ece..300cd52 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -305,7 +305,17 @@ export default function SubscriptionPurchase() { : selectedTariffPeriod?.days || 30; const trafficGb = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined; - return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb); + // Forward the subscription_id when the user landed here via the + // "Renew this subscription" flow (?subscriptionId=N). The backend + // uses it to resolve the exact target row by ID, avoiding the + // race with concurrent panel webhooks that would otherwise hit + // the partial UNIQUE on uq_subscriptions_user_tariff_active. + return subscriptionApi.purchaseTariff( + selectedTariff.id, + days, + trafficGb, + subscriptionId ?? undefined, + ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] });