fix(subscription): forward subscription_id to purchaseTariff to fix renew race

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).
This commit is contained in:
Fringg
2026-05-16 16:10:32 +03:00
parent 8c336d16c7
commit e44a09312e
3 changed files with 25 additions and 3 deletions

View File

@@ -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;
},

View File

@@ -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);
}

View File

@@ -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'] });