From 5b123f5da034f22ed8d59700cf60d79213d4ea8c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 3 Jun 2026 19:02:52 +0300 Subject: [PATCH] fix(switch-tariff): route trial & expired subs to the purchase flow (#629889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../subscription/sheets/SwitchTariffSheet.tsx | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/components/subscription/sheets/SwitchTariffSheet.tsx b/src/components/subscription/sheets/SwitchTariffSheet.tsx index f84e039..97d8f12 100644 --- a/src/components/subscription/sheets/SwitchTariffSheet.tsx +++ b/src/components/subscription/sheets/SwitchTariffSheet.tsx @@ -26,6 +26,25 @@ import type { Tariff } from '../../../types'; // 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 { open: boolean; tariffId: number | null; @@ -69,22 +88,15 @@ export function SwitchTariffSheet({ navigate('/subscriptions', { replace: true }); }, onError: (error: unknown) => { - // Backend signal: the subscription lapsed mid-flight. Hand the - // selected tariff back to the parent so it can open the regular - // purchase form instead. - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - if ( - typeof detail === 'object' && - detail?.error_code === 'subscription_expired' && - detail?.use_purchase_flow === true - ) { - const targetTariff = tariffs.find((tariff) => tariff.id === tariffId); - if (targetTariff) { - onClose(); - onExpiredFallback(targetTariff); - queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); - } + // Backend signal: this subscription can't be switched (it lapsed, or it's + // a trial). Hand the selected tariff back to the parent so it opens the + // regular purchase form instead. + if (shouldUsePurchaseFlow(error)) { + 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 && (() => { - // Suppress the toast when the subscription_expired - // fallback already triggered: the parent is now - // showing the regular purchase form, surfacing the - // raw axios message here would be misleading. - const detail = - switchMutation.error instanceof AxiosError - ? switchMutation.error.response?.data?.detail - : null; - if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') { + // Suppress the toast when the purchase-flow fallback already + // triggered (expired / trial): the parent is now showing the + // regular purchase form, so the raw axios message would mislead. + if (shouldUsePurchaseFlow(switchMutation.error)) { return null; } return (