diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6d79a7edb..19f69dbb2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5945,3 +5945,11 @@ unified resources) and the pinned thresholds (`TestContract_BusinessScaleEstateThresholds`) are identical; only the source of the numbers moved, so the card cohort and the telemetry cohort cannot drift. +### Purchase-start attribution does not touch agent routes + +The `source` attribution parameter added to +`internal/api/licensing_handlers.go` (an extension point of this subsystem's +`internal/api/` surface) is confined to the self-hosted purchase handoff: +it is read from the purchase-start query, validated, and passed to the +license-server portal handoff. No agent enrollment, report, ack, or update +route reads or emits it, and the agent-facing payload shapes are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ee69e9089..cb27b89da 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -8984,3 +8984,20 @@ router echoes it end-to-end on enrollment acks (`TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart`). Agents use it to trigger an immediate self-update check after a server upgrade. The field is additive: agents that predate it ignore it. +### Checkout start forwards attribution in the handoff body, never the portal URL + +`GET /auth/license-purchase-start` accepts an optional `source` query +parameter alongside `feature`. It is validated in +`internal/api/licensing_handlers.go` against the same closed kebab vocabulary +the frontend emits (`normalizeSelfHostedPurchaseSource`); anything else is +dropped rather than forwarded. Like `feature`, `source` is added to the +skip list in `pulseAccountUpgradeURLForRequest`, so it never reaches the +browser-visible Pulse Account portal redirect; it travels only inside the +`CreateCheckoutPortalHandoff` request body, where `omitempty` keeps +source-less installs compatible with a license server that predates the +field. The cancelled-checkout return URL echoes a valid source back onto the +plan route (`licensePurchaseActivationRedirectPathWithSource`) so a retry +keeps the surface that started it. Unrelated query parameters keep their +existing pass-through behavior. +`TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal` pins +both the forwarding and the drop of a malformed value. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 43f5d5254..d0914729b 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1405,6 +1405,29 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`. scope-limited session gets the temporary header reveal instead. The blocked-route redirect and the banner block must stay active for a scope-limited session even when kiosk is off. +30. Keep checkout source attribution closed-vocabulary, authenticated-only, + and out of browser-visible portal URLs. In-app upgrade CTAs stamp a + `source` token (`gate-`, `estate-card`, or the `plans-page` + default) onto the owned billing plan route and the authenticated + `/auth/license-purchase-start` URL + (`frontend-modern/src/utils/pricingHandoff.ts`); the public `/pricing` + route and the public pricing URL never carry it, and + `getSelfHostedPurchaseStartUrl` scrubs it from forwarded query strings so + a crafted website link cannot claim in-app attribution. Server-side, + `HandleCheckoutStart` validates the token against the closed kebab + pattern, sends it only inside the `CreateCheckoutPortalHandoff` body + (`omitempty`, so source-less requests stay compatible with older license + servers), skip-lists it from the Pulse Account portal redirect query, and + echoes it onto the cancel return so a retry keeps its origin + (`TestHandleCheckoutStart_SourceAttributionReachesHandoffNeverPortal`). + The license server persists it on the checkout intent and stamps Stripe + session metadata `checkout_source`, with `checkout_origin` now honestly + split: `pulse_app` for intent-resolved sessions, `pulserelay_landing` + only for the public landing funnel + (`TestHandleCheckoutSessionCreate_CarriesSourceAttributionToStripe`, + `TestCreateStripeCheckoutSessionKeepsLandingOriginWithoutIntent`). + Deploy ordering: the license server must carry the `source` field before + a Pulse release that sends it, because the handoff body decodes strictly. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8ecef4c5d..abc7fec29 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5045,3 +5045,12 @@ schema-v8 telemetry `business_estate` field. The capability read path stays a pure in-memory classification over monitor state — no storage surface, store handle, or persistence behavior is introduced or altered (`TestContract_BusinessScaleEstateThresholds` pins the unchanged thresholds). +### Purchase-start attribution persists nothing on the install + +The `source` attribution parameter handled in +`internal/api/licensing_handlers.go` (named in this contract's extension +points) is request-scoped: it is validated, forwarded in the portal-handoff +body, and echoed onto the cancel return URL. It is never written to config +persistence, the license store, or any recovery artifact, so the install's +storage surface is unchanged. Attribution is retained only by the commercial +backend, on the checkout intent it already owns. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d450b722e..dd88b6c01 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,17 +1,25 @@ { "version": 1, "result": "passed", - "base_sha": "2aa43058b7bb700dcd19b8068178e92d14ded583", - "verified_at": "2026-08-07T14:03:33Z", + "base_sha": "5b07bdc3d8eab45ee004c3c71f22fd2f4d318371", + "verified_at": "2026-08-07T18:40:21Z", "changed_paths": [ - "frontend-modern/src/api/settings.ts" + "frontend-modern/src/components/BusinessEstateCard.tsx", + "frontend-modern/src/components/Settings/useProLicensePanelState.ts", + "frontend-modern/src/utils/pricingHandoff.ts" ], "content_sha256": { - "frontend-modern/src/api/settings.ts": "de9f9d21e26270464bc7402a969dd56f356494e976e4bb483884850d8b148901" + "frontend-modern/src/components/BusinessEstateCard.tsx": "a3717b19a728ef33f775ccce714080b6affd0a56a3a3d8d7a678625a9a6af900", + "frontend-modern/src/components/Settings/useProLicensePanelState.ts": "bfed84fd6aefa15e7b2f7b3ba2c91d8f6e2379172c45bbce3822c7cb1151f776", + "frontend-modern/src/utils/pricingHandoff.ts": "7e5c6178f8811179d64894ab136a2ff4263181b61bdc7298a2d341aca9e9b0cc" }, "routes": [ "/", - "/settings (System > General panel via settings search)" + "/settings (Security > Roles, RBAC inline gate)", + "/settings (System > General, branding gate)", + "/settings/pulse-intelligence/billing/plan?source=gate-rbac", + "/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan&source=gate-rbac", + "/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan" ], "viewports": [ { @@ -24,13 +32,18 @@ } ], "states": [ - "free Community tier served by an isolated worktree backend (PULSE_MOCK_MODE=true, 6 mock PVE nodes) on port 7698", - "sessionCapabilities.businessEstate=true confirmed via /api/security/status after the threshold helper moved to internal/monitoring", - "telemetry preview payload rendered in Settings > System > General showing schema_version 8 with business_estate: true alongside pve_nodes: 6 and paid_license: false" + "free Community tier with presentationPolicy.hideUpgrade=false, served by an isolated worktree backend on port 7698 with 6 mock PVE nodes", + "RBAC inline feature gate rendered on the Roles panel for an unentitled install", + "plan route reached with gate attribution present, and separately with no source at all to exercise the plans-page default", + "business-estate card in its eligible state (star prompt already dismissed, first-seen recorded on a prior day)", + "branding gate for white_label, an unmapped feature key that routes straight to purchase-start rather than the plan page" ], "interactions": [ - "logged in as admin, opened Settings, searched for General, opened the Usage data and privacy section", - "clicked Preview payload and read the full rendered CURRENT HEARTBEAT PAYLOAD JSON including the new business_estate field", - "re-exercised the plan route and workspace at 375x812 to confirm the settings surfaces still render at narrow width" + "clicked the RBAC gate 'View plans' CTA and confirmed its href carried ?source=gate-rbac, then confirmed the source survived canonical billing-route resolution on arrival", + "read the purchase-start hrefs rendered on the plan page: /auth/license-purchase-start?source=gate-rbac&feature=self_hosted_plan", + "loaded the same plan route with no source parameter and confirmed the purchase-start hrefs fell back to source=plans-page", + "clicked 'See business plans' on the business-estate card and confirmed it navigated to the plan route with ?source=estate-card and dismissed the card permanently", + "inspected the branding gate href and confirmed unmapped keys carry attribution too: /auth/license-purchase-start?source=gate-white-label&feature=white_label", + "re-exercised the attributed plan route at 375x812 and confirmed the purchase-start hrefs and layout hold at narrow width" ] } diff --git a/frontend-modern/src/components/BusinessEstateCard.tsx b/frontend-modern/src/components/BusinessEstateCard.tsx index 8e40238cf..be285a449 100644 --- a/frontend-modern/src/components/BusinessEstateCard.tsx +++ b/frontend-modern/src/components/BusinessEstateCard.tsx @@ -8,7 +8,10 @@ import { import { useWebSocket } from '@/contexts/appRuntime'; import { sessionCapabilities } from '@/stores/sessionCapabilities'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; -import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff'; +import { + getSelfHostedBillingHref, + PURCHASE_HANDOFF_SOURCE_ESTATE_CARD, +} from '@/utils/pricingHandoff'; import { ActionIconButton, Button } from '@/components/shared/Button'; import BriefcaseIcon from 'lucide-solid/icons/briefcase'; import XIcon from 'lucide-solid/icons/x'; @@ -104,7 +107,7 @@ export function BusinessEstateCard() { const handleSeePlans = () => { setDismissed(true); setShowCard(false); - navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE); + navigate(getSelfHostedBillingHref('plan', { source: PURCHASE_HANDOFF_SOURCE_ESTATE_CARD })); }; createEffect(() => { diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index e750f6bb3..7d0bc81ad 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -13,6 +13,7 @@ import { getSelfHostedBillingHref, getPublicPricingUrl, getSelfHostedPurchaseStartUrl, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, SELF_HOSTED_PRO_BILLING_PLAN_HREF, SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED, @@ -1150,7 +1151,11 @@ describe('ProLicensePanel', () => { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED, title: 'Checkout cancelled', actionLabel: 'View plans', - actionHref: getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT), + actionHref: getSelfHostedPurchaseStartUrl( + SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, + undefined, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, + ), redirectedHref: getSelfHostedBillingHref('plan', { intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, }), @@ -1159,7 +1164,11 @@ describe('ProLicensePanel', () => { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED, title: 'Upgrade return expired', actionLabel: 'View plans', - actionHref: getSelfHostedPurchaseStartUrl(), + actionHref: getSelfHostedPurchaseStartUrl( + undefined, + undefined, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, + ), }, { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED, @@ -1176,7 +1185,11 @@ describe('ProLicensePanel', () => { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE, title: 'Pulse Account unavailable', actionLabel: 'Try again', - actionHref: getSelfHostedPurchaseStartUrl(), + actionHref: getSelfHostedPurchaseStartUrl( + undefined, + undefined, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, + ), }, ])( 'shows the purchase arrival notice for $purchase', @@ -1432,7 +1445,11 @@ describe('ProLicensePanel', () => { compareLinks.some( (link) => link.getAttribute('href') === - getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT), + getSelfHostedPurchaseStartUrl( + SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, + undefined, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, + ), ), ).toBe(true); expect(screen.queryByRole('button', { name: 'Hide counting rules' })).not.toBeInTheDocument(); diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 39123b371..ded9c1886 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -32,7 +32,9 @@ import { getSelfHostedBillingHref, getSelfHostedBillingPlanDetail, getSelfHostedBillingPlanIntent, + getSelfHostedBillingPlanSource, getSelfHostedBillingPurchaseArrival, + PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, resolveSelfHostedBillingSection, resolveSelfHostedPurchaseStartDestination, SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, @@ -164,6 +166,12 @@ export function useProLicensePanelState() { resolveSelfHostedBillingSection(location.pathname, location.search, location.hash), ); + // Checkout attribution: a gate CTA or the estate card stamps ?source= onto + // the plan route; organic arrivals attribute to the plans page itself. + const purchaseHandoffSource = createMemo( + () => getSelfHostedBillingPlanSource(location.search) ?? PURCHASE_HANDOFF_SOURCE_PLANS_PAGE, + ); + const activeSection = createMemo(() => { return requestedSection() === 'usage' ? 'plan' : requestedSection(); }); @@ -207,6 +215,8 @@ export function useProLicensePanelState() { actionLabel: SELF_HOSTED_PRO_BILLING_PRESENTATION.planSelectionPromptActionLabel, actionDestination: resolveSelfHostedPurchaseStartDestination( SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, + undefined, + purchaseHandoffSource(), ), }; }); @@ -331,12 +341,20 @@ export function useProLicensePanelState() { case SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED: return { label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseCancelledActionLabel, - destination: resolveSelfHostedPurchaseStartDestination(intent), + destination: resolveSelfHostedPurchaseStartDestination( + intent, + undefined, + purchaseHandoffSource(), + ), }; case SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED: return { label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseExpiredActionLabel, - destination: resolveSelfHostedPurchaseStartDestination(intent), + destination: resolveSelfHostedPurchaseStartDestination( + intent, + undefined, + purchaseHandoffSource(), + ), }; case SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED: return { @@ -351,7 +369,11 @@ export function useProLicensePanelState() { case SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE: return { label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseUnavailableActionLabel, - destination: resolveSelfHostedPurchaseStartDestination(intent), + destination: resolveSelfHostedPurchaseStartDestination( + intent, + undefined, + purchaseHandoffSource(), + ), }; default: return null; @@ -410,6 +432,8 @@ export function useProLicensePanelState() { label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel, destination: resolveSelfHostedPurchaseStartDestination( SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, + undefined, + purchaseHandoffSource(), ), } : null, diff --git a/frontend-modern/src/components/__tests__/BusinessEstateCard.test.tsx b/frontend-modern/src/components/__tests__/BusinessEstateCard.test.tsx index b1d934945..27efe9575 100644 --- a/frontend-modern/src/components/__tests__/BusinessEstateCard.test.tsx +++ b/frontend-modern/src/components/__tests__/BusinessEstateCard.test.tsx @@ -175,7 +175,9 @@ describe('BusinessEstateCard', () => { fireEvent.click(screen.getByRole('button', { name: 'See business plans' })); - expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan'); + expect(mockNavigate).toHaveBeenCalledWith( + '/settings/pulse-intelligence/billing/plan?source=estate-card', + ); expect(queryCard()).toBeNull(); expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true); }); diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts index 96922ae26..329aace13 100644 --- a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts @@ -46,19 +46,27 @@ describe('pricingHandoff', () => { expect(getInProductPricingDestination('cloud')).toBeUndefined(); }); - it('routes paid self-hosted feature upgrades to the in-product billing plan page', () => { - expect(getUpgradeFallbackDestination('relay')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF); - expect(getUpgradeFallbackDestination('mobile_app')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF); + it('routes paid self-hosted feature upgrades to the in-product billing plan page with gate attribution', () => { + expect(getUpgradeFallbackDestination('relay')).toBe( + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-relay`, + ); + expect(getUpgradeFallbackDestination('mobile_app')).toBe( + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-mobile-app`, + ); expect(getUpgradeFallbackDestination('push_notifications')).toBe( - SELF_HOSTED_PRO_BILLING_PLAN_HREF, + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-push-notifications`, + ); + expect(getUpgradeFallbackDestination('ai_alerts')).toBe( + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-ai-alerts`, ); - expect(getUpgradeFallbackDestination('ai_alerts')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF); expect(getUpgradeFallbackDestination('ai_autofix')).toBe( - SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF, + `${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`, + ); + expect(getUpgradeFallbackDestination('rbac')).toBe( + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`, ); - expect(getUpgradeFallbackDestination('rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF); expect(getUpgradeFallbackDestination('advanced_reporting')).toBe( - SELF_HOSTED_PRO_BILLING_PLAN_HREF, + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-reporting`, ); }); @@ -83,15 +91,61 @@ describe('pricingHandoff', () => { 'agent_profiles', 'external_probe', ]); + const expectedGateSources: Record = { + relay: 'gate-relay', + mobile_app: 'gate-mobile-app', + push_notifications: 'gate-push-notifications', + ai_alerts: 'gate-ai-alerts', + ai_autofix: 'gate-ai-autofix', + long_term_metrics: 'gate-long-term-metrics', + rbac: 'gate-rbac', + audit_logging: 'gate-audit-logging', + advanced_reporting: 'gate-reporting', + agent_profiles: 'gate-agent-profiles', + external_probe: 'gate-external-probe', + }; for (const key of paidCatalogFeatureKeys) { expect(getUpgradeFallbackDestination(key)).toBe( key === 'ai_autofix' - ? SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF - : SELF_HOSTED_PRO_BILLING_PLAN_HREF, + ? `${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix` + : `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=${expectedGateSources[key]}`, ); } }); + it('threads checkout source attribution through owned billing hrefs and purchase start', () => { + // Plan hrefs carry a validated source; the usage section never does. + expect(getSelfHostedBillingHref('plan', { source: 'estate-card' })).toBe( + `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=estate-card`, + ); + expect(getSelfHostedBillingHref('usage', { source: 'estate-card' })).toBe( + SELF_HOSTED_PRO_BILLING_USAGE_HREF, + ); + // Canonical route resolution preserves a valid plan-route source. + expect( + resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_PLAN_HREF, '?source=gate-rbac'), + ).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`); + // An explicit source lands on the purchase-start URL. + expect(getSelfHostedPurchaseStartUrl('self_hosted_plan', undefined, 'plans-page')).toBe( + `${SELF_HOSTED_PURCHASE_START_PATH}?source=plans-page&feature=self_hosted_plan`, + ); + // A forwarded query string never smuggles attribution in (public /pricing). + expect( + getSelfHostedPurchaseStartUrl( + 'unknown_pro_feature', + new URLSearchParams('source=gate-rbac&utm_content=legacy-bookmark'), + ), + ).toBe( + `${SELF_HOSTED_PURCHASE_START_PATH}?utm_content=legacy-bookmark&feature=unknown_pro_feature`, + ); + // Malformed sources are dropped rather than forwarded. + expect(getSelfHostedPurchaseStartUrl('relay', undefined, 'Bad Source!')).toBe( + `${SELF_HOSTED_PURCHASE_START_PATH}?feature=relay`, + ); + // The public /pricing resolver stays unsourced for mapped features. + expect(getPricingRouteDestination('?feature=rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF); + }); + it('keeps retired trial-expired out of upgrade fallbacks while preserving neutral legacy arrival', () => { expect(getPricingRouteDestination('?feature=trial_expired')).toBe( SELF_HOSTED_PRO_BILLING_PLAN_HREF, diff --git a/frontend-modern/src/utils/pricingHandoff.ts b/frontend-modern/src/utils/pricingHandoff.ts index dcf1a262f..0ad6f728c 100644 --- a/frontend-modern/src/utils/pricingHandoff.ts +++ b/frontend-modern/src/utils/pricingHandoff.ts @@ -25,6 +25,7 @@ export const SELF_HOSTED_PRO_BILLING_USAGE_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_PLAN_SOURCE_QUERY_PARAM = 'source'; 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'; @@ -53,6 +54,49 @@ export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BIL 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_SELECTION_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`; +// Checkout source attribution. Gate CTAs stamp a closed-vocabulary `source` +// onto the billing plan route (or directly onto the purchase-start URL for +// unmapped keys); the plan page threads it into the purchase-start handoff so +// the license server can attribute the Stripe checkout to the surface that +// started it. The param is authenticated-session-only: the public /pricing +// route and the public pricing URL never carry it, and forwarded pre-auth +// query strings are scrubbed of it in getSelfHostedPurchaseStartUrl. +export const PURCHASE_HANDOFF_SOURCE_PLANS_PAGE = 'plans-page'; +export const PURCHASE_HANDOFF_SOURCE_ESTATE_CARD = 'estate-card'; + +const PURCHASE_HANDOFF_GATE_SOURCES: Record = { + rbac: 'gate-rbac', + advanced_reporting: 'gate-reporting', + mobile_app: 'gate-mobile-app', + push_notifications: 'gate-push-notifications', + ai_alerts: 'gate-ai-alerts', + ai_autofix: 'gate-ai-autofix', + relay: 'gate-relay', + audit_logging: 'gate-audit-logging', + agent_profiles: 'gate-agent-profiles', + external_probe: 'gate-external-probe', + long_term_metrics: 'gate-long-term-metrics', + white_label: 'gate-white-label', +}; + +const PURCHASE_HANDOFF_SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; + +export function normalizePurchaseHandoffSource( + source: string | null | undefined, +): string | undefined { + const normalized = source?.trim(); + if (!normalized || !PURCHASE_HANDOFF_SOURCE_PATTERN.test(normalized)) return undefined; + return normalized; +} + +export function getPurchaseHandoffGateSource( + feature: string | null | undefined, +): string | undefined { + const normalizedFeature = feature?.trim(); + if (!normalizedFeature) return undefined; + return PURCHASE_HANDOFF_GATE_SOURCES[normalizedFeature]; +} + const IN_PRODUCT_PRICING_DESTINATIONS: Record = { self_hosted_plan: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF, // Paid self-hosted feature keys: route to the owned billing plan page instead @@ -119,6 +163,7 @@ function normalizeFeatureKey(feature: string | null | undefined): string | undef export function getSelfHostedPurchaseStartUrl( feature?: string | null, searchParams?: URLSearchParams, + source?: string | null, ): string { const url = new URL(SELF_HOSTED_PURCHASE_START_PATH, INTERNAL_HREF_BASE); if (searchParams) { @@ -130,6 +175,15 @@ export function getSelfHostedPurchaseStartUrl( } } + // Attribution is set only by authenticated in-app callers via the explicit + // argument; a forwarded query string (the public /pricing route) never + // carries it through. + url.searchParams.delete(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM); + const normalizedSource = normalizePurchaseHandoffSource(source); + if (normalizedSource) { + url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalizedSource); + } + const normalizedFeature = normalizeFeatureKey(feature); if (normalizedFeature) { url.searchParams.set('feature', normalizedFeature); @@ -140,8 +194,9 @@ export function getSelfHostedPurchaseStartUrl( export function resolveSelfHostedPurchaseStartDestination( feature?: string | null, searchParams?: URLSearchParams, + source?: string | null, ): UpgradeDestination { - return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams), { + return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams, source), { hardNavigation: true, newTab: true, preserveOpener: true, @@ -174,12 +229,30 @@ export function getManagedHostingRequestUrl(): string { return DEFAULT_MANAGED_HOSTING_REQUEST_URL; } +// withPurchaseHandoffSource stamps a gate source onto an in-product billing +// plan href. Only the authenticated upgrade-CTA fallback below uses it; the +// public /pricing route resolves the same destinations unsourced so a website +// arrival is never attributed to an in-app gate. +function withPurchaseHandoffSource(destination: string, source: string | undefined): string { + const normalized = normalizePurchaseHandoffSource(source); + if (!normalized) return destination; + const url = new URL(destination, INTERNAL_HREF_BASE); + if (!isSelfHostedBillingPath(url.pathname)) return destination; + url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalized); + return `${url.pathname}${url.search}`; +} + export function getUpgradeFallbackDestination(feature?: string | null): string | undefined { if (isRetiredPricingFeature(feature)) return undefined; if (normalizeFeatureKey(feature) === 'cloud') { return getManagedHostingRequestUrl(); } - return getInProductPricingDestination(feature) || getSelfHostedPurchaseStartUrl(feature); + const gateSource = getPurchaseHandoffGateSource(feature); + const inProductDestination = getInProductPricingDestination(feature); + if (inProductDestination) { + return withPurchaseHandoffSource(inProductDestination, gateSource); + } + return getSelfHostedPurchaseStartUrl(feature, undefined, gateSource); } export function getSelfHostedBillingUsageDetail( @@ -206,6 +279,14 @@ export function getSelfHostedBillingPlanIntent(search: string): SelfHostedBillin return null; } +export function getSelfHostedBillingPlanSource(search: string): string | null { + return ( + normalizePurchaseHandoffSource( + billingSearch(search).get(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM), + ) ?? null + ); +} + export function getSelfHostedBillingPurchaseArrival( search: string, ): SelfHostedBillingPurchaseArrival | null { @@ -228,6 +309,7 @@ export function getSelfHostedBillingHref( detail?: SelfHostedBillingDetail | null; intent?: SelfHostedBillingPlanIntent | null; purchase?: SelfHostedBillingPurchaseArrival | null; + source?: string | null; } = {}, ): string { const baseRoute = @@ -259,6 +341,11 @@ export function getSelfHostedBillingHref( params.set(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, options.purchase); } + const source = section === 'plan' ? normalizePurchaseHandoffSource(options.source) : undefined; + if (source) { + params.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, source); + } + const search = params.toString(); return `${baseRoute}${search ? `?${search}` : ''}`; } @@ -326,6 +413,7 @@ export function resolveCanonicalSelfHostedBillingHref( ? SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL : null; const planIntent = section === 'plan' ? getSelfHostedBillingPlanIntent(search) : null; + const planSource = section === 'plan' ? getSelfHostedBillingPlanSource(search) : null; const planDetail = section === 'plan' && (getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL || @@ -338,6 +426,7 @@ export function resolveCanonicalSelfHostedBillingHref( detail: section === 'usage' ? usageDetail : planDetail, intent: planIntent, purchase, + source: planSource, }); } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f5ed59ce6..c880580c0 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -22520,3 +22520,73 @@ func TestUnifiedAgentHandlers_HandleReportAckOmitsEmptyServerVersion(t *testing. t.Fatalf("ack unexpectedly carries serverVersion: %v", ack["serverVersion"]) } } + +// Checkout source attribution is an in-app funnel signal, not a browser-visible +// one: it must reach the license server only inside the portal-handoff body and +// must never appear on the Pulse Account portal redirect the browser follows. +func TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal(t *testing.T) { + cases := []struct { + name string + querySource string + wantSource string + wantCancelURL string + }{ + { + name: "valid gate source", + querySource: "gate-rbac", + wantSource: "gate-rbac", + wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled&source=gate-rbac", + }, + { + name: "malformed source is dropped", + querySource: "Bad Source!", + wantSource: "", + wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler := createTestHandler(t) + handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) + var capturedReq struct { + Feature string `json:"feature"` + CancelURL string `json:"cancel_url"` + Source string `json:"source"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil { + t.Fatalf("decode checkout portal handoff request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"portal_handoff_id": "cph_test_source"}) + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + req := httptest.NewRequest( + http.MethodGet, + "https://pulse.example.com/auth/license-purchase-start?feature=rbac&source="+url.QueryEscape(tc.querySource), + nil, + ) + rec := httptest.NewRecorder() + handler.HandleCheckoutStart(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusSeeOther, rec.Body.String()) + } + redirectURL, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatalf("parse redirect location: %v", err) + } + if got := redirectURL.Query().Get("source"); got != "" { + t.Fatalf("source = %q, want omitted from portal query", got) + } + if capturedReq.Source != tc.wantSource { + t.Fatalf("handoff source = %q, want %q", capturedReq.Source, tc.wantSource) + } + if capturedReq.CancelURL != tc.wantCancelURL { + t.Fatalf("cancel_url = %q, want %q", capturedReq.CancelURL, tc.wantCancelURL) + } + }) + } +} diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 861595932..baa53985e 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "sync" "time" @@ -29,6 +30,7 @@ const ( licensePurchaseReturnTokenField = "purchase_return_token" pulseAccountUpgradeService = "upgrade" pulseAccountPortalFeatureQueryParam = "feature" + pulseAccountPortalSourceQueryParam = "source" pulseAccountPortalHandoffIDField = "portal_handoff_id" pulseAccountPortalHandoffURLQueryParam = "purchase_handoff_url" pulseAccountPortalServiceQueryParam = "service" @@ -292,6 +294,7 @@ func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values) for key, values := range query { switch key { case pulseAccountPortalFeatureQueryParam, + pulseAccountPortalSourceQueryParam, pulseAccountPortalHandoffIDField, pulseAccountPortalHandoffURLQueryParam, pulseAccountPortalServiceQueryParam, @@ -354,13 +357,36 @@ func normalizeSelfHostedPurchaseFeature(feature string) string { } } +// selfHostedPurchaseSourcePattern mirrors the frontend attribution vocabulary +// (pricingHandoff.ts): lowercase kebab tokens such as "gate-rbac", +// "estate-card", or "plans-page". Anything else is dropped, not forwarded. +var selfHostedPurchaseSourcePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`) + +func normalizeSelfHostedPurchaseSource(source string) string { + trimmed := strings.TrimSpace(source) + if !selfHostedPurchaseSourcePattern.MatchString(trimmed) { + return "" + } + return trimmed +} + func licensePurchaseActivationRedirectPath(feature, purchaseResult string) string { + return licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, "") +} + +// licensePurchaseActivationRedirectPathWithSource additionally carries the +// checkout source attribution back onto the plan route so a retry after a +// cancelled checkout keeps the surface that originally drove it. +func licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, source string) string { normalizedFeature := normalizeSelfHostedPurchaseFeature(feature) query := url.Values{} switch normalizedFeature { case "self_hosted_plan": query.Set("intent", "self_hosted_plan") } + if normalizedSource := normalizeSelfHostedPurchaseSource(source); normalizedSource != "" { + query.Set(pulseAccountPortalSourceQueryParam, normalizedSource) + } switch strings.TrimSpace(purchaseResult) { case selfHostedBillingPurchaseActivated, selfHostedBillingPurchaseCancelled, @@ -1075,6 +1101,9 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req feature := normalizeSelfHostedPurchaseFeature( r.URL.Query().Get(pulseAccountPortalFeatureQueryParam), ) + purchaseSource := normalizeSelfHostedPurchaseSource( + r.URL.Query().Get(pulseAccountPortalSourceQueryParam), + ) writeUnavailable := func(statusCode int, message string) { writeLicensePurchaseStartFailurePage(w, statusCode, feature, message) } @@ -1131,7 +1160,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req cancelURL, err := publicAbsoluteURLForPath( r, h.cfg, - licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseCancelled), + licensePurchaseActivationRedirectPathWithSource(feature, selfHostedBillingPurchaseCancelled, purchaseSource), ) if err != nil { log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account cancellation return url") @@ -1169,6 +1198,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req SuccessURL: activationURLTemplate, CancelURL: cancelURL, PurchaseReturnJTI: purchaseReturnJTI, + Source: purchaseSource, }) if err != nil { log.Error().Err(err).Str("feature", feature).Msg("Failed to create Pulse Account checkout portal handoff") diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index 2d6b4e85d..fea686414 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -49,6 +49,11 @@ type CheckoutPortalHandoffRequest struct { SuccessURL string `json:"success_url"` CancelURL string `json:"cancel_url"` PurchaseReturnJTI string `json:"purchase_return_jti"` + // Source is the closed-vocabulary in-app surface attribution for the + // checkout (e.g. "gate-rbac", "estate-card", "plans-page"). omitempty keeps + // requests without attribution compatible with license servers that predate + // the field, since the receiver decodes strictly. + Source string `json:"source,omitempty"` } // CheckoutPortalHandoffResponse is the canonical opaque portal handoff used to diff --git a/pkg/licensing/license_server_client_test.go b/pkg/licensing/license_server_client_test.go index a5f93150b..50c518fbb 100644 --- a/pkg/licensing/license_server_client_test.go +++ b/pkg/licensing/license_server_client_test.go @@ -523,6 +523,54 @@ func TestClientCreateCheckoutPortalHandoff(t *testing.T) { } } +func TestClientCreateCheckoutPortalHandoffSourceAttribution(t *testing.T) { + // The license server decodes this body strictly (DisallowUnknownFields), so + // an install with no attribution must omit `source` entirely rather than + // send an empty string. That keeps source-less handoffs working against a + // license server deployed either side of the field being added. + cases := []struct { + name string + source string + wantKey bool + wantSource string + }{ + {name: "attributed handoff carries the source", source: "gate-rbac", wantKey: true, wantSource: "gate-rbac"}, + {name: "unattributed handoff omits the key", source: "", wantKey: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(CheckoutPortalHandoffResponse{PortalHandoffID: "cph_test_source"}) + })) + defer server.Close() + + client := NewLicenseServerClient(server.URL) + if _, err := client.CreateCheckoutPortalHandoff(context.Background(), CheckoutPortalHandoffRequest{ + Feature: "rbac", + SuccessURL: "https://pulse.example.com/auth/license-purchase-activate?purchase_return_token=prt_signed&session_id={CHECKOUT_SESSION_ID}", + CancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled", + PurchaseReturnJTI: "purchase_return_jti_123", + Source: tc.source, + }); err != nil { + t.Fatalf("CreateCheckoutPortalHandoff failed: %v", err) + } + + got, present := body["source"] + if present != tc.wantKey { + t.Fatalf("source key present = %v, want %v (body=%v)", present, tc.wantKey, body) + } + if tc.wantKey && got != tc.wantSource { + t.Fatalf("source = %v, want %q", got, tc.wantSource) + } + }) + } +} + func TestClientRefreshGrant(t *testing.T) { t.Run("successful refresh", func(t *testing.T) { newGrantJWT := makeTestGrantJWT(t, &GrantClaims{