From 124a68ff023813772300062c4b9f7e96187c3856 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 01:21:17 +0100 Subject: [PATCH] fix(web): an org with no plan can reach billing settings to link itself to the org that pays The plan gate sent every route but onboarding to the plan picker, so an org with no plan never saw "Bill through another organization". Billing settings now renders without a plan, and the plan picker links to it. --- .../src/page/onboarding/bill-through-link.ts | 18 +++++++++++ .../web/src/page/onboarding/plan-gate.test.ts | 30 +++++++++++++++---- apps/web/src/page/onboarding/plan-gate.ts | 6 +++- .../page/onboarding/quick-start-team-view.ts | 2 ++ apps/web/src/page/onboarding/select-plan.ts | 2 ++ 5 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/page/onboarding/bill-through-link.ts diff --git a/apps/web/src/page/onboarding/bill-through-link.ts b/apps/web/src/page/onboarding/bill-through-link.ts new file mode 100644 index 0000000000..48bee0e9c1 --- /dev/null +++ b/apps/web/src/page/onboarding/bill-through-link.ts @@ -0,0 +1,18 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { AppRoute, hrefOf } from "../../route" + +/** Under the plan picker: an org another org pays for links itself in billing settings instead. */ +export const billThroughAnotherOrgLink = (h: HtmlBuilder): Html => + h.p( + [h.Class("text-center text-xs text-muted-foreground")], + [ + "Billed through another organization? ", + h.a( + [ + h.Href(hrefOf(AppRoute.Settings({ search: { tab: "billing" } }))), + h.Class("text-foreground underline-offset-4 hover:underline"), + ], + ["Link it in billing settings"], + ), + ], + ) diff --git a/apps/web/src/page/onboarding/plan-gate.test.ts b/apps/web/src/page/onboarding/plan-gate.test.ts index 7323241312..7d2107ac29 100644 --- a/apps/web/src/page/onboarding/plan-gate.test.ts +++ b/apps/web/src/page/onboarding/plan-gate.test.ts @@ -41,15 +41,32 @@ describe("plan gate", () => { test("an org that never held a plan is sent to onboarding with its destination", () => { expect(gate(home, customerWith(null)).redirect).toEqual( - Option.some({ url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), isReplace: true }), + Option.some({ + url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), + isReplace: true, + }), ) }) + test("an org with no plan reaches billing settings, where it links to the org that pays", () => { + const billing = AppRoute.Settings({ search: { tab: "billing" } }) + expect(gate(billing, customerWith(null)).redirect).toEqual(Option.none()) + expect(gate(billing, loading).isSplash).toBe(false) + expect( + Option.isSome( + gate(AppRoute.Settings({ search: { tab: "members" } }), customerWith(null)).redirect, + ), + ).toBe(true) + }) + test("a subscriber finishes the onboarding steps after the plan, and leaves the plan picker", () => { - expect(gate(AppRoute.QuickStart({ search: {} }), customerWith("active")).redirect).toEqual(Option.none()) - expect(gate(AppRoute.SelectPlan({ search: { redirect_url: "/traces" } }), customerWith("active")).redirect).toEqual( - Option.some({ url: "/traces", isReplace: true }), + expect(gate(AppRoute.QuickStart({ search: {} }), customerWith("active")).redirect).toEqual( + Option.none(), ) + expect( + gate(AppRoute.SelectPlan({ search: { redirect_url: "/traces" } }), customerWith("active")) + .redirect, + ).toEqual(Option.some({ url: "/traces", isReplace: true })) }) test("a failed customer read fails open", () => { @@ -78,7 +95,10 @@ describe("plan gate", () => { expect(at(elsewhere, AppRoute.CliLogin({ search: {} })).wrongRegion).toBe("eu") const open: ClerkOrganization = { ...elsewhere, isRegionOpen: true } expect(at(open, home).redirect).toEqual( - Option.some({ url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), isReplace: true }), + Option.some({ + url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), + isReplace: true, + }), ) expect(at(open, home).wrongRegion).toBeNull() }) diff --git a/apps/web/src/page/onboarding/plan-gate.ts b/apps/web/src/page/onboarding/plan-gate.ts index 09eb916946..45ef1ea7bf 100644 --- a/apps/web/src/page/onboarding/plan-gate.ts +++ b/apps/web/src/page/onboarding/plan-gate.ts @@ -67,7 +67,11 @@ export const saveSelectedPlanKnown = (orgId: string, mayRenderApp: boolean): Eff // GATE /** Routes that render their own onboarding or billing UI: never sent away for want of a plan. */ -const isAllowedWithoutPlan = AppRoute.isAnyOf(["SelectPlan", "QuickStart", "CliLogin", "McpAuthorize"]) +const isPlanFreeRoute = AppRoute.isAnyOf(["SelectPlan", "QuickStart", "CliLogin", "McpAuthorize"]) + +// Billing settings too: an org with no plan links itself to the org that pays for it there. +const isAllowedWithoutPlan = (route: AppRoute): boolean => + isPlanFreeRoute(route) || (route._tag === "Settings" && route.search.tab === "billing") /** Public paths that never read org data (`isOrgIndependentPath`). */ const isOrgIndependent = AppRoute.isAnyOf(["Unsubscribe"]) diff --git a/apps/web/src/page/onboarding/quick-start-team-view.ts b/apps/web/src/page/onboarding/quick-start-team-view.ts index dc2962d9e0..a5863c189f 100644 --- a/apps/web/src/page/onboarding/quick-start-team-view.ts +++ b/apps/web/src/page/onboarding/quick-start-team-view.ts @@ -10,6 +10,7 @@ import { skeleton } from "../../ui/skeleton" import { icon, type IconName } from "../../ui/icon" import { input } from "../../ui/input" import { label } from "../../ui/label" +import { billThroughAnotherOrgLink } from "./bill-through-link" import { Message } from "./quick-start" import { STEP_BODY_CLASS, STEP_FOOTER_CLASS } from "./step-layout" import { isAlreadyInvited, type Team, TeamMessage } from "./quick-start-team" @@ -299,6 +300,7 @@ export const planStep = (h: H, isCustomerLoading: boolean, pricing: Html): Html ], ), isCustomerLoading ? pricingSkeleton(h) : pricing, + h.div([h.Class("mt-8")], [billThroughAnotherOrgLink(h)]), ], ), ], diff --git a/apps/web/src/page/onboarding/select-plan.ts b/apps/web/src/page/onboarding/select-plan.ts index 37a681013a..8a641d3d60 100644 --- a/apps/web/src/page/onboarding/select-plan.ts +++ b/apps/web/src/page/onboarding/select-plan.ts @@ -8,6 +8,7 @@ import { defineMessageUnion } from "foldkit/message" import type { Resources } from "../../platform/resources" import { badge } from "../../ui/display" import { icon } from "../../ui/icon" +import { billThroughAnotherOrgLink } from "./bill-through-link" import * as PricingCards from "./pricing-cards" import * as PricingCardsView from "./pricing-cards-view" @@ -142,6 +143,7 @@ export const view = (h: H, model: Model, customer: BillingCustomer | undefined): }), ], ), + billThroughAnotherOrgLink(h), ], ), ],