diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index df2639288..c1ed61b95 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -243,6 +243,11 @@ success-notification, and canonical denial handling through
`frontend-modern/src/utils/trialStartAction.ts` instead of carrying local
`startProTrial()` redirect/error branches that drift from backend commercial
truth.
+That same rule also covers the self-hosted pricing page in
+`frontend-modern/src/pages/PricingV6.tsx`: pricing CTA state may choose when to
+switch from trial to upgrade presentation, but the actual hosted handoff and
+backend denial classification must still flow through the shared trial-start
+owner instead of parsing raw trial-start status codes inline.
The hosted trial handoff page is part of that same boundary as well. It may
still use a secure hosted Stripe-backed session internally, but the customer
copy must present the flow as starting a trial for the originating Pulse
diff --git a/frontend-modern/src/pages/PricingV6.tsx b/frontend-modern/src/pages/PricingV6.tsx
index 78bc85149..d09a0767f 100644
--- a/frontend-modern/src/pages/PricingV6.tsx
+++ b/frontend-modern/src/pages/PricingV6.tsx
@@ -14,11 +14,10 @@ import {
entitlements,
getUpgradeActionUrlOrFallback,
loadLicenseStatus,
- startProTrial,
} from '@/stores/license';
import { showToast } from '@/utils/toast';
import { logger } from '@/utils/logger';
-import { getTrialStartErrorMessage } from '@/utils/upgradePresentation';
+import { getTrialStartErrorKind } from '@/utils/upgradePresentation';
import {
SELF_HOSTED_FEATURE_ROWS,
SELF_HOSTED_PLAN_BY_TIER,
@@ -26,6 +25,7 @@ import {
type SelfHostedPlanDefinition,
type SelfHostedTierKey,
} from '@/utils/selfHostedPlans';
+import { runStartProTrialAction } from '@/utils/trialStartAction';
// ---------------------------------------------------------------------------
// Shared sub-components
@@ -181,22 +181,20 @@ export default function PricingV6() {
setStartingTrial(true);
try {
- const result = await startProTrial();
- if (result.outcome === 'redirect') {
- if (typeof window !== 'undefined') {
- window.location.href = result.actionUrl;
- }
- return;
+ const outcome = await runStartProTrialAction({
+ successMessage: 'Pro trial started (14 days).',
+ showSuccess: (message) => showToast('success', message),
+ showError: setTrialMessage,
+ onError: (error) => {
+ if (getTrialStartErrorKind(error) === 'already_used') {
+ setTrialCtaMode('upgrade');
+ }
+ logger.warn('[PricingV6] Trial start request failed', error);
+ },
+ });
+ if (outcome === 'activated') {
+ await loadLicenseStatus(true);
}
- showToast('success', 'Pro trial started (14 days).');
- await loadLicenseStatus(true);
- } catch (error) {
- const trialError = error as { code?: string } | null;
- if (trialError?.code === 'trial_already_used') {
- setTrialCtaMode('upgrade');
- }
- logger.warn('[PricingV6] Trial start request failed', error);
- setTrialMessage(getTrialStartErrorMessage(error));
} finally {
setStartingTrial(false);
}
diff --git a/frontend-modern/src/pages/__tests__/PricingV6.test.tsx b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx
index 2ef5e8c87..48642730c 100644
--- a/frontend-modern/src/pages/__tests__/PricingV6.test.tsx
+++ b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx
@@ -1,10 +1,19 @@
-import { describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@solidjs/testing-library';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import PricingV6 from '../PricingV6';
import pricingV6Source from '../PricingV6.tsx?raw';
+const entitlementsState = {
+ subscription_state: 'expired',
+ tier: 'free',
+ trial_eligible: false,
+};
+const startProTrialMock = vi.fn();
+const loadLicenseStatusMock = vi.fn().mockResolvedValue(undefined);
+const showToastMock = vi.fn();
+
vi.mock('@/components/shared/Card', () => ({
Card: (props: { children?: JSX.Element }) =>
{props.children}
,
}));
@@ -28,14 +37,10 @@ vi.mock('@/components/shared/PageHeader', () => ({
}));
vi.mock('@/stores/license', () => ({
- entitlements: () => ({
- subscription_state: 'expired',
- tier: 'free',
- trial_eligible: false,
- }),
+ entitlements: () => entitlementsState,
getUpgradeActionUrlOrFallback: (tier: string) => `/${tier}`,
- loadLicenseStatus: vi.fn().mockResolvedValue(undefined),
- startProTrial: vi.fn(),
+ loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args),
+ startProTrial: (...args: unknown[]) => startProTrialMock(...args),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
@@ -43,7 +48,7 @@ vi.mock('@/utils/upgradeMetrics', () => ({
}));
vi.mock('@/utils/toast', () => ({
- showToast: vi.fn(),
+ showToast: (...args: unknown[]) => showToastMock(...args),
}));
vi.mock('@/utils/logger', () => ({
@@ -54,6 +59,16 @@ vi.mock('@/utils/logger', () => ({
}));
describe('PricingV6', () => {
+ beforeEach(() => {
+ entitlementsState.subscription_state = 'expired';
+ entitlementsState.tier = 'free';
+ entitlementsState.trial_eligible = false;
+ startProTrialMock.mockReset();
+ loadLicenseStatusMock.mockReset();
+ loadLicenseStatusMock.mockResolvedValue(undefined);
+ showToastMock.mockReset();
+ });
+
it('renders self-hosted plan tiers from the shared pricing model', () => {
render(() => );
@@ -74,8 +89,32 @@ describe('PricingV6', () => {
it('imports the shared self-hosted pricing model instead of redefining it locally', () => {
expect(pricingV6Source).toContain("@/utils/selfHostedPlans");
expect(pricingV6Source).toContain("@/utils/upgradePresentation");
+ expect(pricingV6Source).toContain("@/utils/trialStartAction");
expect(pricingV6Source).not.toContain('const TIERS =');
expect(pricingV6Source).not.toContain('const FEATURE_ROWS');
expect(pricingV6Source).not.toContain("setTrialMessage('Trial already used.')");
+ expect(pricingV6Source).not.toContain('startProTrial()');
+ });
+
+ it('switches Pro pricing CTA to upgrade when trial is already used', async () => {
+ entitlementsState.trial_eligible = true;
+ startProTrialMock.mockRejectedValue({
+ status: 409,
+ code: 'trial_already_used',
+ message: 'Trial already used',
+ });
+
+ render(() => );
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Start Free 14-day Trial' })[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Trial already used')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('link', { name: 'Upgrade to Pro' })).toHaveAttribute(
+ 'href',
+ '/upgrade',
+ );
});
});
diff --git a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts
index a8dad05e1..63ab8069c 100644
--- a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts
+++ b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts
@@ -76,4 +76,26 @@ describe('runStartProTrialAction', () => {
expect(showSuccess).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
+
+ it('invokes the optional error hook with the raw backend error payload', async () => {
+ const onError = vi.fn();
+ const error = {
+ status: 409,
+ code: 'trial_already_used',
+ message: 'Trial already used',
+ };
+ startProTrialMock.mockRejectedValue(error);
+
+ await expect(
+ runStartProTrialAction({
+ showSuccess,
+ showError,
+ navigate,
+ onError,
+ }),
+ ).resolves.toBe('error');
+
+ expect(onError).toHaveBeenCalledWith(error);
+ expect(showError).toHaveBeenCalledWith('Trial already used');
+ });
});
diff --git a/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts b/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts
index 085aa03fc..5b9104da6 100644
--- a/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
+ getTrialStartErrorKind,
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
+ normalizeTrialStartError,
getTrialTryAgainLaterMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
@@ -28,6 +30,14 @@ describe('upgradePresentation', () => {
expect(getTrialStartErrorMessage('temporary failure')).toBe('temporary failure');
expect(getTrialStartErrorMessage({ code: 'trial_already_used' })).toBe('Trial already used');
expect(getTrialStartErrorMessage({ status: 429 })).toBe('Try again later');
+ expect(getTrialStartErrorKind({ code: 'trial_already_used' })).toBe('already_used');
+ expect(getTrialStartErrorKind({ status: 429 })).toBe('retry_later');
+ expect(getTrialStartErrorKind({ message: 'temporary failure' })).toBe('other');
+ expect(normalizeTrialStartError({ status: 409, code: 'trial_not_available' })).toEqual({
+ status: 409,
+ code: 'trial_not_available',
+ message: undefined,
+ });
expect(
getTrialStartErrorMessage({
code: 'trial_not_available',
diff --git a/frontend-modern/src/utils/trialStartAction.ts b/frontend-modern/src/utils/trialStartAction.ts
index 1af7ce051..3a3f4eeef 100644
--- a/frontend-modern/src/utils/trialStartAction.ts
+++ b/frontend-modern/src/utils/trialStartAction.ts
@@ -5,6 +5,7 @@ export type StartProTrialActionOutcome = 'activated' | 'redirect' | 'error';
export interface RunStartProTrialActionOptions {
branded?: boolean;
+ onError?: (error: unknown) => void;
successMessage?: string;
showSuccess: (message: string) => void;
showError: (message: string) => void;
@@ -32,6 +33,7 @@ export async function runStartProTrialAction(
showSuccess(successMessage ?? getProTrialStartedMessage());
return 'activated';
} catch (error) {
+ options.onError?.(error);
showError(getTrialStartErrorMessage(error, { branded }));
return 'error';
}
diff --git a/frontend-modern/src/utils/upgradePresentation.ts b/frontend-modern/src/utils/upgradePresentation.ts
index b7cb2db61..dc3493651 100644
--- a/frontend-modern/src/utils/upgradePresentation.ts
+++ b/frontend-modern/src/utils/upgradePresentation.ts
@@ -20,6 +20,8 @@ export interface TrialStartErrorLike {
message?: string;
}
+export type TrialStartErrorKind = 'already_used' | 'retry_later' | 'other';
+
export function getProTrialStartedMessage(): string {
return 'Pro trial started';
}
@@ -32,7 +34,7 @@ export function getTrialTryAgainLaterMessage(): string {
return 'Try again later';
}
-function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
+export function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
if (!error) return null;
if (typeof error === 'string') return { message: error };
if (typeof error !== 'object') return null;
@@ -45,15 +47,27 @@ function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
};
}
+export function getTrialStartErrorKind(error?: unknown): TrialStartErrorKind {
+ const normalized = normalizeTrialStartError(error);
+ if (normalized?.code === 'trial_already_used') {
+ return 'already_used';
+ }
+ if (normalized?.status === 429) {
+ return 'retry_later';
+ }
+ return 'other';
+}
+
export function getTrialStartErrorMessage(
error?: unknown,
options: TrialStartErrorOptions = {},
): string {
const normalized = normalizeTrialStartError(error);
- if (normalized?.code === 'trial_already_used') {
+ const kind = getTrialStartErrorKind(normalized);
+ if (kind === 'already_used') {
return getTrialAlreadyUsedMessage();
}
- if (normalized?.status === 429) {
+ if (kind === 'retry_later') {
return getTrialTryAgainLaterMessage();
}
if (normalized?.message?.trim()) {