fix(switch-tariff): route trial & expired subs to the purchase flow (#629889)

The switch sheet's fallback checked detail.error_code, but the backend
sends detail.code — so the expired-fallback was effectively dead. Centralize
it into shouldUsePurchaseFlow(), which accepts either key and now also
handles the new trial_cannot_switch code: a trial has no paid value to
prorate, so the backend rejects the switch and the sheet hands the tariff to
the regular purchase form instead of surfacing a raw error.

The switch button is already hidden for trials via canSwitch; this is the
server-side backstop for races / direct calls, and it un-breaks the expired
fallback at the same time.
This commit is contained in:
c0mrade
2026-06-03 19:02:52 +03:00
parent ce7a3eaedf
commit 5b123f5da0

View File

@@ -26,6 +26,25 @@ import type { Tariff } from '../../../types';
// TariffPurchaseForm instead of attempting another switch. // TariffPurchaseForm instead of attempting another switch.
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// The backend rejects a switch that must instead go through the purchase flow:
// the subscription lapsed (`subscription_expired`), or it is a trial that has no
// paid value to prorate and would otherwise be handed a full target period
// (`trial_cannot_switch`, bug #629889). Both arrive as detail.code +
// use_purchase_flow=true; some payloads use the legacy `error_code` key, so we
// accept either.
function shouldUsePurchaseFlow(error: unknown): boolean {
if (!(error instanceof AxiosError)) return false;
const detail = error.response?.data?.detail as
| { code?: string; error_code?: string; use_purchase_flow?: boolean }
| undefined;
if (!detail || typeof detail !== 'object') return false;
const code = detail.code ?? detail.error_code;
return (
(code === 'subscription_expired' || code === 'trial_cannot_switch') &&
detail.use_purchase_flow === true
);
}
export interface SwitchTariffSheetProps { export interface SwitchTariffSheetProps {
open: boolean; open: boolean;
tariffId: number | null; tariffId: number | null;
@@ -69,22 +88,15 @@ export function SwitchTariffSheet({
navigate('/subscriptions', { replace: true }); navigate('/subscriptions', { replace: true });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
// Backend signal: the subscription lapsed mid-flight. Hand the // Backend signal: this subscription can't be switched (it lapsed, or it's
// selected tariff back to the parent so it can open the regular // a trial). Hand the selected tariff back to the parent so it opens the
// purchase form instead. // regular purchase form instead.
if (error instanceof AxiosError) { if (shouldUsePurchaseFlow(error)) {
const detail = error.response?.data?.detail; const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
if ( if (targetTariff) {
typeof detail === 'object' && onClose();
detail?.error_code === 'subscription_expired' && onExpiredFallback(targetTariff);
detail?.use_purchase_flow === true queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
) {
const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
if (targetTariff) {
onClose();
onExpiredFallback(targetTariff);
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
}
} }
} }
}, },
@@ -213,15 +225,10 @@ export function SwitchTariffSheet({
{switchMutation.isError && {switchMutation.isError &&
(() => { (() => {
// Suppress the toast when the subscription_expired // Suppress the toast when the purchase-flow fallback already
// fallback already triggered: the parent is now // triggered (expired / trial): the parent is now showing the
// showing the regular purchase form, surfacing the // regular purchase form, so the raw axios message would mislead.
// raw axios message here would be misleading. if (shouldUsePurchaseFlow(switchMutation.error)) {
const detail =
switchMutation.error instanceof AxiosError
? switchMutation.error.response?.data?.detail
: null;
if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') {
return null; return null;
} }
return ( return (