Make billing recovery route-owned and unblock governed commits

This commit is contained in:
rcourtman 2026-04-08 22:11:10 +01:00
parent 5925750da3
commit f03c8d3441
12 changed files with 546 additions and 168 deletions

View file

@ -110,7 +110,7 @@ agreement, and cloud-specific enforcement rules.
14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts`
15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
16. Add or change self-hosted Pro plan, trial, recovery, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
17. Add or change monitored-system ledger presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts`
17. Add or change monitored-system ledger, disclosure, or admission-preview presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts`
18. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`, and `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts`
19. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx`
20. Add contract tests where runtime and pricing need to stay aligned
@ -628,6 +628,10 @@ limit-warning banner, so the settings panel, Pro usage section, counting-rules
disclosure, and shared warning-banner model must consume that helper instead
of redefining customer-facing monitored-system copy inline or keeping a
parallel copy in generic self-hosted plan utilities.
That same helper also owns preview-impact copy and current/projected
source-label wording for pre-save monitored-system admission UI, so the
TrueNAS and VMware settings panels do not drift into provider-local billing
phrasing when they surface canonical preview results before save.
That same disclosure surface must not accept arbitrary caller-supplied
monitored-system summary strings as its primary API. When the disclosure needs
to show brief summary copy, it should render the canonical helper-owned brief
@ -699,6 +703,15 @@ for self-hosted plan comparison and checkout before returning through the
runtime-owned activation callback. Pulse product routes keep ownership of
license status, usage, and activation state; `Pulse Account` owns the commerce
flow itself.
That same router-owned billing contract now also includes recovery as a plan
detail state instead of a fragment alias. The canonical recovery arrival is
`/settings/system/billing/plan?details=recovery`, while
`#pulse-pro-recovery` remains a compatibility deep link that must normalize
into that owned query state. `purchase=failed` arrival handling on the
self-hosted billing surface must consume the one-shot purchase result, preserve
the owned plan intent when present, and replace the URL with the canonical
recovery detail route so `Open recovery` lands on a first-class billing state
instead of a hash that settings-shell normalization strips away.
That same ownership split is explicit in the governed registry as well:
`CommercialBillingSections.tsx` is part of the shared commercial shell/model
surface, while `SelfHostedCommercialRecoverySection.tsx` stays on the

View file

@ -59,27 +59,43 @@ export const ProLicensePanel: Component = () => {
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionTitle}
description={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionDescription}
>
<ProLicensePlanSection
commercialMigrationNotice={state.commercialMigrationNotice()}
commercialPlanModel={state.commercialPlanModel()}
entitlements={state.entitlements()}
formattedFeatures={state.formattedFeatures()}
grandfatheredPriceNotice={state.grandfatheredPriceNotice()}
hasLicenseDetails={state.hasLicenseDetails()}
hasPaidFeatures={state.hasPaidFeatures()}
loading={state.loading()}
monitoredSystemContinuityNotice={state.monitoredSystemContinuityNotice()}
onReload={() => void state.loadPanelData()}
purchaseActivationAction={state.purchaseActivationAction()}
purchaseActivationNotice={state.purchaseActivationNotice()}
showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()}
showTrialStart={state.showTrialStart()}
startingTrial={state.startingTrial()}
statusPresentation={state.statusPresentation()}
onStartTrial={() => void state.handleStartTrial()}
trialActivationNotice={state.trialActivationNotice()}
trialEnded={state.trialEnded()}
/>
<div class="space-y-6">
<ProLicensePlanSection
commercialMigrationNotice={state.commercialMigrationNotice()}
commercialPlanModel={state.commercialPlanModel()}
entitlements={state.entitlements()}
formattedFeatures={state.formattedFeatures()}
grandfatheredPriceNotice={state.grandfatheredPriceNotice()}
hasLicenseDetails={state.hasLicenseDetails()}
hasPaidFeatures={state.hasPaidFeatures()}
loading={state.loading()}
monitoredSystemContinuityNotice={state.monitoredSystemContinuityNotice()}
onReload={() => void state.loadPanelData()}
purchaseActivationAction={state.purchaseActivationAction()}
purchaseActivationNotice={state.purchaseActivationNotice()}
showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()}
showTrialStart={state.showTrialStart()}
startingTrial={state.startingTrial()}
statusPresentation={state.statusPresentation()}
onStartTrial={() => void state.handleStartTrial()}
trialActivationNotice={state.trialActivationNotice()}
trialEnded={state.trialEnded()}
/>
<SelfHostedCommercialRecoverySection
sectionId={SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID}
open={state.showRecoveryByDefault()}
licenseKey={state.licenseKey()}
activating={state.activating()}
clearing={state.clearing()}
loading={state.loading()}
hasLicenseDetails={state.hasLicenseDetails()}
looksLikeLegacyLicenseKey={state.looksLikeLegacyLicenseKey()}
onLicenseKeyInput={state.setLicenseKey}
onActivate={state.handleActivate}
onClear={state.handleClear}
/>
</div>
</CommercialSection>
</Show>
@ -95,19 +111,6 @@ export const ProLicensePanel: Component = () => {
/>
</CommercialSection>
</Show>
<SelfHostedCommercialRecoverySection
sectionId={SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID}
licenseKey={state.licenseKey()}
activating={state.activating()}
clearing={state.clearing()}
loading={state.loading()}
hasLicenseDetails={state.hasLicenseDetails()}
looksLikeLegacyLicenseKey={state.looksLikeLegacyLicenseKey()}
onLicenseKeyInput={state.setLicenseKey}
onActivate={state.handleActivate}
onClear={state.handleClear}
/>
</div>
</CommercialBillingShell>
</div>

View file

@ -7,6 +7,7 @@ import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresent
interface SelfHostedCommercialRecoverySectionProps {
sectionId?: string;
open?: boolean;
licenseKey: string;
activating: boolean;
clearing: boolean;
@ -26,7 +27,7 @@ export const SelfHostedCommercialRecoverySection: Component<
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.recoverySectionTitle}
description={SELF_HOSTED_PRO_BILLING_PRESENTATION.recoverySectionDescription}
>
<details class="group rounded-md border border-border bg-surface-alt p-4">
<details class="group rounded-md border border-border bg-surface-alt p-4" open={props.open}>
<summary class="cursor-pointer list-none">
<div class="flex items-start justify-between gap-3">
<div>

View file

@ -9,6 +9,7 @@ import proLicensePanelStateSource from '../useProLicensePanelState.ts?raw';
import proLicensePlanSectionSource from '../ProLicensePlanSection.tsx?raw';
import selfHostedCommercialRecoverySectionSource from '../SelfHostedCommercialRecoverySection.tsx?raw';
import {
getSelfHostedBillingHref,
getPublicPricingUrl,
getSelfHostedPurchaseStartUrl,
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
@ -18,6 +19,7 @@ import {
SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED,
SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED,
SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID,
@ -482,9 +484,14 @@ describe('ProLicensePanel', () => {
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
title: 'Activation needs attention',
actionLabel: 'Open recovery',
actionHref: SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF,
actionHref: getSelfHostedBillingHref('plan', {
detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
}),
redirectedHref: getSelfHostedBillingHref('plan', {
detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
}),
},
])('shows the purchase arrival notice for $purchase', async ({ purchase, title, actionLabel, actionHref }) => {
])('shows the purchase arrival notice for $purchase', async ({ purchase, title, actionLabel, actionHref, redirectedHref = SELF_HOSTED_PRO_BILLING_PLAN_HREF }) => {
useLocationMock.mockReturnValue({
search: `?purchase=${purchase}`,
pathname: '/settings/system/billing/plan',
@ -499,7 +506,7 @@ describe('ProLicensePanel', () => {
} else {
expect(screen.queryByRole('link', { name: 'Review usage' })).not.toBeInTheDocument();
}
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_HREF, {
expect(navigateMock).toHaveBeenCalledWith(redirectedHref, {
replace: true,
scroll: false,
});
@ -522,6 +529,21 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('Need a higher monitored-system cap?')).not.toBeInTheDocument();
});
it('opens recovery by default when the billing route requests the recovery detail', async () => {
useLocationMock.mockReturnValue({
search: '?details=recovery',
pathname: '/settings/system/billing/plan',
hash: '',
});
renderPanel();
const recoveryDisclosure = screen
.getByText('Redeem existing key')
.closest('details');
expect(recoveryDisclosure).toHaveAttribute('open');
});
it('focuses the usage billing section when the usage route requests counting rules', async () => {
useLocationMock.mockReturnValue({
search: '?details=counting-rules',

View file

@ -23,6 +23,8 @@ import {
isDisplayableLicenseFeature,
} from '@/utils/licensePresentation';
import {
getSelfHostedBillingHref,
getSelfHostedBillingPlanDetail,
getSelfHostedBillingPlanIntent,
getSelfHostedBillingPurchaseArrival,
getSelfHostedBillingUsageDetail,
@ -30,11 +32,12 @@ import {
resolveSelfHostedPurchaseStartDestination,
SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL,
SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT,
SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF,
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED,
SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED,
SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
@ -93,6 +96,15 @@ export function useProLicensePanelState() {
if (purchaseResult) {
setPurchaseActivationResult(purchaseResult);
params.delete(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM);
if (
purchaseResult === SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED &&
getSelfHostedBillingPlanDetail(location.search) !== SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL
) {
params.set(
SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
);
}
}
if (trialResult || purchaseResult) {
const nextSearch = params.toString();
@ -130,6 +142,11 @@ export function useProLicensePanelState() {
getSelfHostedBillingUsageDetail(location.search) ===
SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL,
);
const showRecoveryByDefault = createMemo(
() =>
activeSection() === 'plan' &&
getSelfHostedBillingPlanDetail(location.search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
);
const showTrialStart = createMemo(() => {
const current = entitlements();
@ -295,7 +312,12 @@ export function useProLicensePanelState() {
case SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED:
return {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseFailedActionLabel,
destination: resolveUpgradeDestination(SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF),
destination: resolveUpgradeDestination(
getSelfHostedBillingHref('plan', {
intent,
detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
}),
),
};
default:
return null;
@ -416,6 +438,7 @@ export function useProLicensePanelState() {
setLicenseKey,
showCountingRulesByDefault,
showMonitoredSystemUpgradeArrival,
showRecoveryByDefault,
showTrialStart,
startingTrial,
statusPresentation,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
getSelfHostedPurchaseStartUrl,
getSelfHostedBillingHref,
getSelfHostedBillingPlanDetail,
getSelfHostedBillingPlanIntent,
getSelfHostedBillingPurchaseArrival,
getSelfHostedBillingUsageDetail,
@ -17,7 +18,9 @@ import {
SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL,
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF,
SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
SELF_HOSTED_PRO_BILLING_ROUTE,
SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
@ -75,12 +78,21 @@ describe('pricingHandoff', () => {
).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF}&purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}`,
);
expect(
getSelfHostedBillingHref('plan', {
intent: SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT,
detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
}),
).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF}&details=recovery`);
});
it('canonicalizes legacy self-hosted billing aliases to route-owned states', () => {
expect(
resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-usage'),
).toBe(SELF_HOSTED_PRO_BILLING_USAGE_HREF);
expect(
resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-recovery'),
).toBe(SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF);
expect(resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE)).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
);
@ -95,6 +107,9 @@ describe('pricingHandoff', () => {
expect(
getSelfHostedBillingPlanIntent('?intent=' + SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT),
).toBe(SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT);
expect(getSelfHostedBillingPlanDetail('?details=' + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL)).toBe(
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
);
expect(
getSelfHostedBillingPurchaseArrival(
'?purchase=' + SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,

View file

@ -14,10 +14,15 @@ export const SELF_HOSTED_PRO_BILLING_USAGE_ROUTE = `${SELF_HOSTED_PRO_BILLING_RO
export const SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID = 'pulse-pro-plan';
export const SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID = 'pulse-pro-usage';
export const SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID = 'pulse-pro-recovery';
export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM = 'details';
export const SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM = 'details';
export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM =
SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM;
export const SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM =
SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM;
export const SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM = 'intent';
export const SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM = 'purchase';
export const SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL = 'counting-rules';
export const SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL = 'recovery';
export const SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT = 'max_monitored_systems';
export const SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED = 'activated';
export const SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED = 'cancelled';
@ -26,6 +31,8 @@ export const SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED = 'failed';
export type SelfHostedBillingSection = 'plan' | 'usage';
export type SelfHostedBillingUsageDetail = typeof SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL;
export type SelfHostedBillingPlanDetail = typeof SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL;
export type SelfHostedBillingDetail = SelfHostedBillingUsageDetail | SelfHostedBillingPlanDetail;
export type SelfHostedBillingPlanIntent = typeof SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT;
export type SelfHostedBillingPurchaseArrival =
| typeof SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED
@ -35,7 +42,7 @@ export type SelfHostedBillingPurchaseArrival =
export const SELF_HOSTED_PRO_BILLING_PLAN_HREF = SELF_HOSTED_PRO_BILLING_PLAN_ROUTE;
export const SELF_HOSTED_PRO_BILLING_USAGE_HREF = SELF_HOSTED_PRO_BILLING_USAGE_ROUTE;
export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}#${SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID}`;
export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL}`;
export const SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF = `${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}?${SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`;
export const SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT}`;
@ -144,6 +151,13 @@ export function getSelfHostedBillingUsageDetail(
return detail === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL ? detail : null;
}
export function getSelfHostedBillingPlanDetail(search: string): SelfHostedBillingPlanDetail | null {
const detail = billingSearch(search)
.get(SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM)
?.trim();
return detail === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL ? detail : null;
}
export function getSelfHostedBillingPlanIntent(search: string): SelfHostedBillingPlanIntent | null {
const intent = billingSearch(search).get(SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM)?.trim();
return intent === SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT ? intent : null;
@ -167,7 +181,7 @@ export function getSelfHostedBillingPurchaseArrival(
export function getSelfHostedBillingHref(
section: SelfHostedBillingSection,
options: {
detail?: SelfHostedBillingUsageDetail | null;
detail?: SelfHostedBillingDetail | null;
intent?: SelfHostedBillingPlanIntent | null;
purchase?: SelfHostedBillingPurchaseArrival | null;
} = {},
@ -190,6 +204,13 @@ export function getSelfHostedBillingHref(
);
}
if (section === 'plan' && options.detail === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL) {
params.set(
SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
);
}
if (options.purchase) {
params.set(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, options.purchase);
}
@ -222,9 +243,15 @@ export function resolveSelfHostedBillingSection(
if (normalizedHash === SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID) {
return 'plan';
}
if (normalizedHash === SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID) {
return 'plan';
}
if (getSelfHostedBillingUsageDetail(search) === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL) {
return 'usage';
}
if (getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL) {
return 'plan';
}
return 'plan';
}
@ -239,18 +266,33 @@ export function resolveCanonicalSelfHostedBillingHref(
}
const section = resolveSelfHostedBillingSection(normalizedPath, search, hash);
const canonicalPath =
section === 'usage' ? SELF_HOSTED_PRO_BILLING_USAGE_ROUTE : SELF_HOSTED_PRO_BILLING_PLAN_ROUTE;
const normalizedSearch = normalizeSearch(search);
const normalizedHash = normalizeHash(hash);
const usageDetail =
section === 'usage' &&
getSelfHostedBillingUsageDetail(search) === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL
? SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL
: null;
const planIntent = section === 'plan' ? getSelfHostedBillingPlanIntent(search) : null;
const planDetail =
section === 'plan' &&
(getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL ||
normalizedHash === SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID)
? SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL
: null;
const purchase = section === 'plan' ? getSelfHostedBillingPurchaseArrival(search) : null;
return `${canonicalPath}${normalizedSearch ? `?${normalizedSearch}` : ''}`;
return getSelfHostedBillingHref(section, {
detail: section === 'usage' ? usageDetail : planDetail,
intent: planIntent,
purchase,
});
}
export function scopeSelfHostedBillingDestination(
destination: UpgradeDestination,
section: SelfHostedBillingSection,
options: {
detail?: SelfHostedBillingUsageDetail | null;
detail?: SelfHostedBillingDetail | null;
intent?: SelfHostedBillingPlanIntent | null;
purchase?: SelfHostedBillingPurchaseArrival | null;
} = {},

View file

@ -354,14 +354,23 @@ func (c *LicenseServerClient) parseError(resp *http.Response) error {
// Try to parse structured error response from the license server.
if len(body) > 0 {
var parsed struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
Code string `json:"code"`
LegacyCode string `json:"error"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
if json.Unmarshal(body, &parsed) == nil && parsed.Code != "" {
apiErr.Code = parsed.Code
apiErr.Message = parsed.Message
apiErr.Retryable = parsed.Retryable
if json.Unmarshal(body, &parsed) == nil {
code := strings.TrimSpace(parsed.Code)
if code == "" {
code = strings.TrimSpace(parsed.LegacyCode)
}
if code != "" {
apiErr.Code = code
if strings.TrimSpace(parsed.Message) != "" {
apiErr.Message = parsed.Message
}
apiErr.Retryable = parsed.Retryable
}
}
}

View file

@ -144,6 +144,43 @@ func TestClientActivate(t *testing.T) {
}
})
t.Run("server returns legacy error field", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_activation",
"message": "Activation key could not be redeemed",
"retryable": false,
})
}))
defer server.Close()
client := NewLicenseServerClient(server.URL)
_, err := client.Activate(context.Background(), ActivateInstallationRequest{
ActivationKey: "ppk_live_bad",
})
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*LicenseServerError)
if !ok {
t.Fatalf("expected *LicenseServerError, got %T", err)
}
if apiErr.StatusCode != 400 {
t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode)
}
if apiErr.Code != "invalid_activation" {
t.Errorf("Code = %q, want invalid_activation", apiErr.Code)
}
if apiErr.Message != "Activation key could not be redeemed" {
t.Errorf("Message = %q, want Activation key could not be redeemed", apiErr.Message)
}
if apiErr.Retryable {
t.Error("expected Retryable=false for 400")
}
})
t.Run("server error is retryable", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)

View file

@ -56,6 +56,9 @@ def git_diff_name_only(*args: str) -> list[str]:
def is_worktree_sensitive_governance_path(path: str) -> bool:
if path in STAGED_EXECUTION_EXACT_FILES:
return False
filename = path.rsplit("/", 1)[-1]
if filename.endswith("_test.py") or filename.endswith("_test.go"):
return False
if path in WORKTREE_SENSITIVE_EXACT_FILES:
return True
return any(path.startswith(prefix) for prefix in WORKTREE_SENSITIVE_PREFIXES)

View file

@ -28,12 +28,21 @@ class GovernanceStageGuardTest(unittest.TestCase):
def test_is_worktree_sensitive_governance_path_matches_expected_scope(self) -> None:
self.assertTrue(is_worktree_sensitive_governance_path(".husky/pre-commit"))
self.assertTrue(is_worktree_sensitive_governance_path("docs/release-control/control_plane.json"))
self.assertTrue(is_worktree_sensitive_governance_path("internal/repoctl/canonical_development_protocol_test.go"))
self.assertTrue(is_worktree_sensitive_governance_path("scripts/release_control/status_audit.py"))
self.assertFalse(
is_worktree_sensitive_governance_path(
"internal/repoctl/canonical_development_protocol_test.go"
)
)
self.assertFalse(is_worktree_sensitive_governance_path(".github/workflows/canonical-governance.yml"))
self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/internal/CONTROL_PLANE.md"))
self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/v6/internal/status.json"))
self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md"))
self.assertFalse(
is_worktree_sensitive_governance_path(
"scripts/release_control/subsystem_lookup_test.py"
)
)
self.assertFalse(
is_worktree_sensitive_governance_path(
"scripts/release_control/release_promotion_policy_test.py"
@ -48,7 +57,9 @@ class GovernanceStageGuardTest(unittest.TestCase):
"docs/release-control/control_plane.json",
"docs/release-control/v6/internal/status.json",
"docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md",
"internal/repoctl/canonical_development_protocol_test.go",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/subsystem_lookup_test.py",
"internal/api/slo.go",
"scripts/release_control/status_audit.py",
]

View file

@ -14,9 +14,16 @@ const PURCHASE_RETURN_URL = `${DEV_SERVER_URL}/auth/license-purchase-activate`;
const PORTAL_HANDOFF_ID = "cph_checkout_return";
const ACTIVATED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=activated`;
const CANCELLED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=cancelled`;
const EXPIRED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=expired`;
const FAILED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=failed`;
const FINAL_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems`;
const USAGE_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/usage`;
const RECOVERY_BILLING_HREF =
"/settings/system/billing/plan?intent=max_monitored_systems&details=recovery";
const RECOVERY_BILLING_URL = `${DEV_SERVER_URL}${RECOVERY_BILLING_HREF}`;
const PURCHASE_RETURN_TOKEN = "prt_signed_checkout_return";
const CHECKOUT_SESSION_ID = "cs_upgrade_return";
const MONITORED_SYSTEM_FEATURE = "max_monitored_systems";
const MONITORED_SYSTEM_ENTITLEMENTS = {
capabilities: [],
@ -108,6 +115,184 @@ function escapeAttribute(value: string): string {
.replaceAll(">", "&gt;");
}
function buildPortalHandoffUrl(portalHandoffID = PORTAL_HANDOFF_ID): string {
return `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${portalHandoffID}`;
}
function buildPurchaseReturnUrl(options: {
sessionID?: string;
portalHandoffID?: string;
feature?: string;
purchaseReturnToken?: string;
} = {}): string {
const {
sessionID = CHECKOUT_SESSION_ID,
portalHandoffID = PORTAL_HANDOFF_ID,
feature = MONITORED_SYSTEM_FEATURE,
purchaseReturnToken = PURCHASE_RETURN_TOKEN,
} = options;
const url = new URL(PURCHASE_RETURN_URL);
url.searchParams.set("session_id", sessionID);
url.searchParams.set("portal_handoff_id", portalHandoffID);
url.searchParams.set("feature", feature);
url.searchParams.set("purchase_return_token", purchaseReturnToken);
return url.toString();
}
function renderTimedRedirectPage(title: string, body: string, redirectUrl: string): string {
return (
"<!doctype html><html><body>" +
`<h1>${title}</h1>` +
`<p>${body}</p>` +
`<script>setTimeout(function(){window.location.replace(${JSON.stringify(redirectUrl)});},150);</script>` +
"</body></html>"
);
}
function renderActivationBridgePage(options: {
sessionID?: string;
portalHandoffID?: string;
feature?: string;
purchaseReturnToken?: string;
title?: string;
} = {}): string {
const {
sessionID = CHECKOUT_SESSION_ID,
portalHandoffID = PORTAL_HANDOFF_ID,
feature = MONITORED_SYSTEM_FEATURE,
purchaseReturnToken = PURCHASE_RETURN_TOKEN,
title = "Finalizing Pulse Pro upgrade",
} = options;
return (
"<!doctype html><html><body>" +
`<h1>${title}</h1>` +
`<form id="purchase-activation-continue-form" method="POST" action="${escapeAttribute(PURCHASE_RETURN_URL)}">` +
`<input type="hidden" name="session_id" value="${escapeAttribute(sessionID)}">` +
`<input type="hidden" name="portal_handoff_id" value="${escapeAttribute(portalHandoffID)}">` +
`<input type="hidden" name="feature" value="${escapeAttribute(feature)}">` +
`<input type="hidden" name="purchase_return_token" value="${escapeAttribute(purchaseReturnToken)}">` +
"</form>" +
'<script>setTimeout(function(){document.getElementById("purchase-activation-continue-form")?.submit();},50);</script>' +
"</body></html>"
);
}
function renderActivationCompletionPage(title: string, redirectUrl: string): string {
return (
"<!doctype html><html><body>" +
`<h1>${title}</h1>` +
`<script>(function(){var redirectPath=${JSON.stringify(redirectUrl)};if(window.opener&&!window.opener.closed){window.opener.location.assign(redirectPath);window.close();return;}window.location.replace(redirectPath);}());</script>` +
"</body></html>"
);
}
async function configurePurchaseStartRoute(
context: BrowserContext,
onRequest?: (requestUrl: URL) => void,
) {
await context.route(`${PURCHASE_START_URL}**`, async (route) => {
const requestUrl = new URL(route.request().url());
onRequest?.(requestUrl);
expect(requestUrl.searchParams.get("feature")).toBe(MONITORED_SYSTEM_FEATURE);
await route.fulfill({
status: 303,
headers: {
location: buildPortalHandoffUrl(),
},
body: "",
});
});
}
async function configurePortalRedirectRoute(
context: BrowserContext,
options: {
title?: string;
body: string;
redirectUrl: string;
},
) {
await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => {
const requestUrl = new URL(route.request().url());
expect(requestUrl.searchParams.get("service")).toBeNull();
expect(requestUrl.searchParams.get("feature")).toBeNull();
expect(requestUrl.searchParams.get("return_url")).toBeNull();
expect(requestUrl.searchParams.get("purchase_handoff_url")).toBeNull();
expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(PORTAL_HANDOFF_ID);
expect(requestUrl.searchParams.get("checkout_intent_id")).toBeNull();
await route.fulfill({
status: 200,
contentType: "text/html",
body: renderTimedRedirectPage(
options.title ?? "Pulse Account",
options.body,
options.redirectUrl,
),
});
});
}
async function configurePurchaseReturnRoute(
context: BrowserContext,
options: {
sessionID?: string;
portalHandoffID?: string;
feature?: string;
purchaseReturnToken?: string;
bridgeTitle?: string;
completionTitle: string;
finalRedirectUrl: string;
},
) {
const {
sessionID = CHECKOUT_SESSION_ID,
portalHandoffID = PORTAL_HANDOFF_ID,
feature = MONITORED_SYSTEM_FEATURE,
purchaseReturnToken = PURCHASE_RETURN_TOKEN,
bridgeTitle,
completionTitle,
finalRedirectUrl,
} = options;
await context.route(`${PURCHASE_RETURN_URL}**`, async (route) => {
const request = route.request();
if (request.method() === "GET") {
const requestUrl = new URL(request.url());
expect(requestUrl.searchParams.get("session_id")).toBe(sessionID);
expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(portalHandoffID);
expect(requestUrl.searchParams.get("feature")).toBe(feature);
expect(requestUrl.searchParams.get("purchase_return_token")).toBe(
purchaseReturnToken,
);
await route.fulfill({
status: 200,
contentType: "text/html",
body: renderActivationBridgePage({
sessionID,
portalHandoffID,
feature,
purchaseReturnToken,
title: bridgeTitle,
}),
});
return;
}
expect(request.method()).toBe("POST");
const formData = new URLSearchParams(request.postData() || "");
expect(formData.get("session_id")).toBe(sessionID);
expect(formData.get("portal_handoff_id")).toBe(portalHandoffID);
expect(formData.get("feature")).toBe(feature);
expect(formData.get("purchase_return_token")).toBe(purchaseReturnToken);
await route.fulfill({
status: 200,
contentType: "text/html",
body: renderActivationCompletionPage(completionTitle, finalRedirectUrl),
});
});
}
async function configureBillingFixtures(context: BrowserContext, page: Page) {
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
@ -187,85 +372,16 @@ test.describe("Self-hosted upgrade return flow", () => {
await configureBillingFixtures(context, page);
let purchaseStartURL = "";
await context.route(`${PURCHASE_START_URL}**`, async (route) => {
const requestUrl = new URL(route.request().url());
await configurePurchaseStartRoute(context, (requestUrl) => {
purchaseStartURL = requestUrl.toString();
expect(requestUrl.searchParams.get("feature")).toBe(
"max_monitored_systems",
);
await route.fulfill({
status: 303,
headers: {
location: `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`,
},
body: "",
});
});
await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => {
const requestUrl = new URL(route.request().url());
expect(requestUrl.searchParams.get("service")).toBeNull();
expect(requestUrl.searchParams.get("feature")).toBeNull();
expect(requestUrl.searchParams.get("return_url")).toBeNull();
expect(requestUrl.searchParams.get("purchase_handoff_url")).toBeNull();
expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(
PORTAL_HANDOFF_ID,
);
expect(requestUrl.searchParams.get("checkout_intent_id")).toBeNull();
await route.fulfill({
status: 200,
contentType: "text/html",
body:
"<!doctype html><html><body>" +
"<h1>Pulse Account</h1>" +
"<p>Checkout complete. Returning to Pulse Pro.</p>" +
`<script>setTimeout(function(){window.location.replace(${JSON.stringify(
`${PURCHASE_RETURN_URL}?session_id=cs_upgrade_return&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`,
)});},150);</script>` +
"</body></html>",
});
await configurePortalRedirectRoute(context, {
body: "Checkout complete. Returning to Pulse Pro.",
redirectUrl: buildPurchaseReturnUrl(),
});
await context.route(`${PURCHASE_RETURN_URL}**`, async (route) => {
const request = route.request();
if (request.method() === "GET") {
const requestUrl = new URL(request.url());
expect(requestUrl.searchParams.get("session_id")).toBe(
"cs_upgrade_return",
);
expect(requestUrl.searchParams.get("purchase_return_token")).toBe(
PURCHASE_RETURN_TOKEN,
);
await route.fulfill({
status: 200,
contentType: "text/html",
body:
"<!doctype html><html><body>" +
"<h1>Finalizing Pulse Pro upgrade</h1>" +
`<form id="purchase-activation-continue-form" method="POST" action="${escapeAttribute(PURCHASE_RETURN_URL)}">` +
'<input type="hidden" name="session_id" value="cs_upgrade_return">' +
`<input type="hidden" name="purchase_return_token" value="${escapeAttribute(PURCHASE_RETURN_TOKEN)}">` +
"</form>" +
'<script>setTimeout(function(){document.getElementById("purchase-activation-continue-form")?.submit();},50);</script>' +
"</body></html>",
});
return;
}
expect(request.method()).toBe("POST");
const formData = new URLSearchParams(request.postData() || "");
expect(formData.get("session_id")).toBe("cs_upgrade_return");
expect(formData.get("purchase_return_token")).toBe(PURCHASE_RETURN_TOKEN);
await route.fulfill({
status: 200,
contentType: "text/html",
body:
"<!doctype html><html><body>" +
"<h1>Pulse Pro activated</h1>" +
`<script>(function(){var redirectPath=${JSON.stringify(ACTIVATED_BILLING_URL)};if(window.opener&&!window.opener.closed){window.opener.location.assign(redirectPath);window.close();return;}window.location.replace(redirectPath);}());</script>` +
"</body></html>",
});
await configurePurchaseReturnRoute(context, {
completionTitle: "Pulse Pro activated",
finalRedirectUrl: ACTIVATED_BILLING_URL,
});
await openMonitoredSystemUpgradeArrival(page);
@ -282,12 +398,7 @@ test.describe("Self-hosted upgrade return flow", () => {
.poll(() => purchaseStartURL)
.toBe(`${PURCHASE_START_URL}?feature=max_monitored_systems`);
await page.goto(
`${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`,
{
waitUntil: "domcontentloaded",
},
);
await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Pulse Account" }),
).toBeVisible();
@ -322,29 +433,10 @@ test.describe("Self-hosted upgrade return flow", () => {
const context = page.context();
await configureBillingFixtures(context, page);
await context.route(`${PURCHASE_START_URL}**`, async (route) => {
await route.fulfill({
status: 303,
headers: {
location: `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`,
},
body: "",
});
});
await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body:
"<!doctype html><html><body>" +
"<h1>Pulse Account</h1>" +
"<p>Checkout cancelled. Returning to Pulse Pro.</p>" +
`<script>setTimeout(function(){window.location.replace(${JSON.stringify(
CANCELLED_BILLING_URL,
)});},150);</script>` +
"</body></html>",
});
await configurePurchaseStartRoute(context);
await configurePortalRedirectRoute(context, {
body: "Checkout cancelled. Returning to Pulse Pro.",
redirectUrl: CANCELLED_BILLING_URL,
});
await openMonitoredSystemUpgradeArrival(page);
@ -352,12 +444,7 @@ test.describe("Self-hosted upgrade return flow", () => {
const comparePlansLink = page.getByRole("link", { name: "Compare plans" });
await comparePlansLink.click();
await page.goto(
`${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`,
{
waitUntil: "domcontentloaded",
},
);
await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(CANCELLED_BILLING_URL);
await expect(page.getByText("Checkout cancelled", { exact: true })).toBeVisible();
@ -366,4 +453,116 @@ test.describe("Self-hosted upgrade return flow", () => {
`${PURCHASE_START_PATH}?feature=max_monitored_systems`,
);
});
test("returns an already-consumed checkout back to the activated billing state", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop-only billing continuity",
);
const context = page.context();
await configureBillingFixtures(context, page);
await configurePurchaseStartRoute(context);
await configurePortalRedirectRoute(context, {
body: "Checkout complete. Returning to Pulse Pro.",
redirectUrl: buildPurchaseReturnUrl(),
});
await configurePurchaseReturnRoute(context, {
bridgeTitle: "Confirming Pulse Pro activation state",
completionTitle: "Purchase activation already completed",
finalRedirectUrl: ACTIVATED_BILLING_URL,
});
await openMonitoredSystemUpgradeArrival(page);
await page.getByRole("link", { name: "Compare plans" }).click();
await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(FINAL_BILLING_URL);
await expect(
page.getByText("Pulse Pro activated", { exact: true }),
).toBeVisible();
await expect(page.getByRole("link", { name: "Review usage" })).toHaveAttribute(
"href",
"/settings/system/billing/usage",
);
});
test("returns an expired or mismatched checkout state to restart the owned upgrade flow", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop-only billing continuity",
);
const context = page.context();
await configureBillingFixtures(context, page);
await configurePurchaseStartRoute(context);
await configurePortalRedirectRoute(context, {
body: "Secure upgrade state expired. Returning to Pulse Pro.",
redirectUrl: buildPurchaseReturnUrl(),
});
await configurePurchaseReturnRoute(context, {
bridgeTitle: "Verifying Pulse Pro checkout return",
completionTitle: "Upgrade return expired",
finalRedirectUrl: EXPIRED_BILLING_URL,
});
await openMonitoredSystemUpgradeArrival(page);
await page.getByRole("link", { name: "Compare plans" }).click();
await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(EXPIRED_BILLING_URL);
await expect(
page.getByText("Upgrade return expired", { exact: true }),
).toBeVisible();
const restartUpgradeLink = page.getByRole("link", { name: "Restart upgrade" });
await expect(restartUpgradeLink).toHaveAttribute(
"href",
`${PURCHASE_START_PATH}?feature=max_monitored_systems`,
);
await expect(restartUpgradeLink).toHaveAttribute("target", "_blank");
});
test("returns local activation failures to the billing recovery entry point", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop-only billing continuity",
);
const context = page.context();
await configureBillingFixtures(context, page);
await configurePurchaseStartRoute(context);
await configurePortalRedirectRoute(context, {
body: "Checkout complete, but Pulse Pro needs local recovery.",
redirectUrl: buildPurchaseReturnUrl(),
});
await configurePurchaseReturnRoute(context, {
bridgeTitle: "Finishing local Pulse Pro activation",
completionTitle: "Activation needs attention",
finalRedirectUrl: FAILED_BILLING_URL,
});
await openMonitoredSystemUpgradeArrival(page);
await page.getByRole("link", { name: "Compare plans" }).click();
await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(FAILED_BILLING_URL);
await expect(
page.getByText("Activation needs attention", { exact: true }),
).toBeVisible();
const recoveryLink = page.getByRole("link", { name: "Open recovery" });
await expect(recoveryLink).toHaveAttribute("href", RECOVERY_BILLING_HREF);
await expect(recoveryLink).not.toHaveAttribute("target", "_blank");
await recoveryLink.click();
await expect(page).toHaveURL(RECOVERY_BILLING_URL);
await expect(page.locator("#pulse-pro-recovery")).toBeVisible();
});
});