mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Make billing recovery route-owned and unblock governed commits
This commit is contained in:
parent
5925750da3
commit
f03c8d3441
12 changed files with 546 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} = {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(">", ">");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue