Files
bedolaga-cabinet/src/api/subscription.ts
c0mrade 574da67ca0 feat(yandex-conv): forward CID through remaining paid API methods
Pair with the bot's #558449 follow-up. Pass getYandexCid() through
the four cabinet API methods that previously omitted it:

  - switchTraffic (PUT /traffic)
  - activateTrial (POST /trial — body added, was previously empty POST)
  - updateCountries (POST /countries)
  - switchTariff (POST /tariff/switch)

Without these the backend now-typed yandex_cid field would silently
stay None for these paid flows even when the user had a CID cached.
2026-05-28 16:43:22 +03:00

614 lines
18 KiB
TypeScript

import apiClient from './client';
import { getYandexCid } from '../utils/yandexCid';
import type {
Subscription,
SubscriptionStatusResponse,
SubscriptionListItem,
SubscriptionsListResponse,
RenewalOption,
TrafficPackage,
TrialInfo,
PurchaseOptions,
PurchaseSelection,
PurchasePreview,
AppConfig,
} from '../types';
/** Helper: build query params with optional subscription_id */
const withSubId = (subscriptionId?: number, extra?: Record<string, unknown>) => ({
params: {
...(subscriptionId != null && { subscription_id: subscriptionId }),
...extra,
},
});
/**
* Helper for POST/PATCH/PUT: returns [body, axiosConfig] tuple.
* subscription_id goes as query param (backend expects Query(...)), NOT in body.
*/
const bodyWithSubId = (
body: Record<string, unknown>,
subscriptionId?: number,
): [Record<string, unknown>, { params?: Record<string, unknown> }] => [
body,
subscriptionId != null ? { params: { subscription_id: subscriptionId } } : {},
];
export const subscriptionApi = {
// ── Multi-tariff endpoints ──────────────────────────────────────────
getSubscriptions: async (): Promise<SubscriptionsListResponse> => {
const response = await apiClient.get<SubscriptionsListResponse>('/cabinet/subscriptions');
return response.data;
},
getSubscriptionById: async (subscriptionId: number): Promise<SubscriptionListItem> => {
const response = await apiClient.get<SubscriptionListItem>(
`/cabinet/subscriptions/${subscriptionId}`,
);
return response.data;
},
deleteSubscription: async (subscriptionId: number): Promise<{ message: string }> => {
const response = await apiClient.delete(`/cabinet/subscriptions/${subscriptionId}`);
return response.data;
},
// ── Legacy single-subscription status ───────────────────────────────
getSubscription: async (subscriptionId?: number): Promise<SubscriptionStatusResponse> => {
const response = await apiClient.get<SubscriptionStatusResponse>(
'/cabinet/subscription',
withSubId(subscriptionId),
);
return response.data;
},
// ── Renewal ─────────────────────────────────────────────────────────
getRenewalOptions: async (subscriptionId?: number): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>(
'/cabinet/subscription/renewal-options',
withSubId(subscriptionId),
);
return response.data;
},
renewSubscription: async (
periodDays: number,
subscriptionId?: number,
): Promise<{
message: string;
new_end_date: string;
amount_paid_kopeks: number;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/renew',
...bodyWithSubId(
{ period_days: periodDays, yandex_cid: getYandexCid() || undefined },
subscriptionId,
),
);
return response.data;
},
// ── Traffic ─────────────────────────────────────────────────────────
getTrafficPackages: async (subscriptionId?: number): Promise<TrafficPackage[]> => {
const response = await apiClient.get<TrafficPackage[]>(
'/cabinet/subscription/traffic-packages',
withSubId(subscriptionId),
);
return response.data;
},
purchaseTraffic: async (
gb: number,
subscriptionId?: number,
): Promise<{
message: string;
gb_added: number;
amount_paid_kopeks: number;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/traffic',
...bodyWithSubId({ gb, yandex_cid: getYandexCid() || undefined }, subscriptionId),
);
return response.data;
},
switchTraffic: async (
gb: number,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
old_traffic_gb: number;
new_traffic_gb: number;
charged_kopeks: number;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.put(
'/cabinet/subscription/traffic',
...bodyWithSubId({ gb, yandex_cid: getYandexCid() || undefined }, subscriptionId),
);
return response.data;
},
saveTrafficCart: async (trafficGb: number, subscriptionId?: number): Promise<void> => {
await apiClient.post(
'/cabinet/subscription/traffic/save-cart',
...bodyWithSubId({ gb: trafficGb }, subscriptionId),
);
},
refreshTraffic: async (
subscriptionId?: number,
): Promise<{
success: boolean;
cached: boolean;
rate_limited?: boolean;
retry_after_seconds?: number;
source?: string;
traffic_used_bytes: number;
traffic_used_gb: number;
traffic_limit_bytes: number;
traffic_limit_gb: number;
traffic_used_percent: number;
is_unlimited: boolean;
lifetime_used_bytes?: number;
lifetime_used_gb?: number;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/refresh-traffic',
{},
withSubId(subscriptionId),
);
return response.data;
},
// ── Devices ─────────────────────────────────────────────────────────
purchaseDevices: async (
devices: number,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
devices_added: number;
new_device_limit: number;
price_kopeks: number;
price_label: string;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/devices/purchase',
...bodyWithSubId({ devices, yandex_cid: getYandexCid() || undefined }, subscriptionId),
);
return response.data;
},
getDevicePrice: async (
devices: number = 1,
subscriptionId?: number,
): Promise<{
available: boolean;
reason?: string;
devices?: number;
price_per_device_kopeks?: number;
price_per_device_label?: string;
total_price_kopeks?: number;
total_price_label?: string;
current_device_limit?: number;
max_device_limit?: number;
can_add?: number;
days_left?: number;
base_device_price_kopeks?: number;
original_price_per_device_kopeks?: number;
base_total_price_kopeks?: number;
discount_percent?: number;
discount_kopeks?: number;
}> => {
const response = await apiClient.get('/cabinet/subscription/devices/price', {
params: {
devices,
...(subscriptionId != null && { subscription_id: subscriptionId }),
},
});
return response.data;
},
saveDevicesCart: async (devices: number, subscriptionId?: number): Promise<void> => {
await apiClient.post(
'/cabinet/subscription/devices/save-cart',
...bodyWithSubId({ devices }, subscriptionId),
);
},
getDeviceReductionInfo: async (
subscriptionId?: number,
): Promise<{
available: boolean;
reason?: string;
current_device_limit: number;
min_device_limit: number;
can_reduce: number;
connected_devices_count: number;
}> => {
const response = await apiClient.get(
'/cabinet/subscription/devices/reduction-info',
withSubId(subscriptionId),
);
return response.data;
},
reduceDevices: async (
newDeviceLimit: number,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
old_device_limit: number;
new_device_limit: number;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/devices/reduce',
...bodyWithSubId({ new_device_limit: newDeviceLimit }, subscriptionId),
);
return response.data;
},
getDevices: async (
subscriptionId?: number,
): Promise<{
devices: Array<{
hwid: string;
platform: string;
device_model: string;
created_at: string | null;
local_name?: string | null;
}>;
total: number;
device_limit: number;
}> => {
const response = await apiClient.get(
'/cabinet/subscription/devices',
withSubId(subscriptionId),
);
return response.data;
},
/**
* Persist a user-local alias for a device. Pass an empty/null `name` to
* clear the alias. Returns the final `local_name` after server-side
* normalization (trimmed + length-capped).
*
* Scope is per-(user, hwid), so the same alias appears across all of
* the user's subscriptions in multi-tariff mode.
*/
renameDevice: async (
hwid: string,
name: string | null,
subscriptionId?: number,
): Promise<{ hwid: string; local_name: string | null }> => {
const response = await apiClient.patch(
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`,
{ name },
withSubId(subscriptionId),
);
return response.data;
},
deleteDevice: async (
hwid: string,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
deleted_hwid: string;
}> => {
const response = await apiClient.delete(
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`,
withSubId(subscriptionId),
);
return response.data;
},
deleteAllDevices: async (
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
deleted_count: number;
}> => {
const response = await apiClient.delete(
'/cabinet/subscription/devices',
withSubId(subscriptionId),
);
return response.data;
},
// ── Autopay ─────────────────────────────────────────────────────────
updateAutopay: async (
enabled: boolean,
daysBefore?: number,
subscriptionId?: number,
): Promise<{
message: string;
autopay_enabled: boolean;
autopay_days_before: number;
}> => {
const response = await apiClient.patch(
'/cabinet/subscription/autopay',
{ enabled, days_before: daysBefore },
withSubId(subscriptionId),
);
return response.data;
},
// ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => {
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
return response.data;
},
activateTrial: async (): Promise<Subscription> => {
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial', {
yandex_cid: getYandexCid() || undefined,
});
return response.data;
},
// ── Purchase ────────────────────────────────────────────────────────
getPurchaseOptions: async (subscriptionId?: number): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>(
'/cabinet/subscription/purchase-options',
withSubId(subscriptionId),
);
return response.data;
},
previewPurchase: async (
selection: PurchaseSelection,
subscriptionId?: number,
): Promise<PurchasePreview> => {
const response = await apiClient.post<PurchasePreview>(
'/cabinet/subscription/purchase-preview',
...bodyWithSubId({ selection }, subscriptionId),
);
return response.data;
},
submitPurchase: async (
selection: PurchaseSelection,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
subscription: Subscription;
was_trial_conversion: boolean;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/purchase',
...bodyWithSubId({ selection, yandex_cid: getYandexCid() || undefined }, subscriptionId),
);
return response.data;
},
purchaseTariff: async (
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;
subscription: Subscription;
tariff_id: number;
tariff_name: string;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post('/cabinet/subscription/purchase-tariff', {
tariff_id: tariffId,
period_days: periodDays,
traffic_gb: trafficGb,
subscription_id: subscriptionId,
yandex_cid: getYandexCid() || undefined,
});
return response.data;
},
// ── Countries / Servers ─────────────────────────────────────────────
getCountries: async (
subscriptionId?: number,
): Promise<{
countries: Array<{
uuid: string;
name: string;
country_code: string | null;
base_price_kopeks: number;
price_kopeks: number;
price_per_month_kopeks: number;
price_rubles: number;
is_available: boolean;
is_connected: boolean;
has_discount: boolean;
discount_percent: number;
}>;
connected_count: number;
has_subscription: boolean;
days_left: number;
discount_percent: number;
}> => {
const response = await apiClient.get(
'/cabinet/subscription/countries',
withSubId(subscriptionId),
);
return response.data;
},
updateCountries: async (
countries: string[],
subscriptionId?: number,
): Promise<{
message: string;
added: string[];
removed: string[];
amount_paid_kopeks: number;
connected_squads: string[];
}> => {
const response = await apiClient.post(
'/cabinet/subscription/countries',
...bodyWithSubId({ countries, yandex_cid: getYandexCid() || undefined }, subscriptionId),
);
return response.data;
},
// ── Connection ──────────────────────────────────────────────────────
getConnectionLink: async (
subscriptionId?: number,
): Promise<{
subscription_url: string | null;
display_link: string | null;
happ_redirect_link: string | null;
happ_scheme_link: string | null;
happ_cryptolink?: string | null;
happ_crypto_link?: string | null;
happ_link?: string | null;
connect_mode: string;
hide_link: boolean;
instructions: {
steps: string[];
};
}> => {
const response = await apiClient.get(
'/cabinet/subscription/connection-link',
withSubId(subscriptionId),
);
return response.data;
},
getHappDownloads: async (): Promise<{
platforms: Record<
string,
{
name: string;
icon: string;
link: string;
}
>;
happ_enabled: boolean;
}> => {
const response = await apiClient.get('/cabinet/subscription/happ-downloads');
return response.data;
},
getAppConfig: async (subscriptionId?: number): Promise<AppConfig> => {
const response = await apiClient.get<AppConfig>(
'/cabinet/subscription/app-config',
withSubId(subscriptionId),
);
return response.data;
},
// ── Tariff switch ───────────────────────────────────────────────────
previewTariffSwitch: async (
tariffId: number,
subscriptionId?: number,
): Promise<{
can_switch: boolean;
current_tariff_id: number | null;
current_tariff_name: string | null;
new_tariff_id: number;
new_tariff_name: string;
remaining_days: number;
upgrade_cost_kopeks: number;
upgrade_cost_label: string;
balance_kopeks: number;
balance_label: string;
has_enough_balance: boolean;
missing_amount_kopeks: number;
missing_amount_label: string;
is_upgrade: boolean;
base_upgrade_cost_kopeks?: number;
discount_percent?: number;
discount_kopeks?: number;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/tariff/switch/preview',
...bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId),
);
return response.data;
},
switchTariff: async (
tariffId: number,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
subscription: Subscription;
old_tariff_name: string;
new_tariff_id: number;
new_tariff_name: string;
charged_kopeks: number;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/tariff/switch',
...bodyWithSubId(
{ tariff_id: tariffId, period_days: 30, yandex_cid: getYandexCid() || undefined },
subscriptionId,
),
);
return response.data;
},
// ── Revoke (reissue) ────────────────────────────────────────────────
revokeSubscription: async (subscriptionId?: number) => {
const response = await apiClient.post(
'/cabinet/subscription/revoke',
undefined,
withSubId(subscriptionId),
);
return response.data;
},
// ── Daily subscription ──────────────────────────────────────────────
togglePause: async (
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
is_paused: boolean;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post(
'/cabinet/subscription/pause',
undefined,
withSubId(subscriptionId),
);
return response.data;
},
};