Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion apps/api/src/routes/internal/billing.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { Env } from "@/platform/Env"
import {
CUSTOMER_CACHE_BUCKET,
CUSTOMER_CACHE_TTL_SECONDS,
CUSTOMER_CACHE_LAPSED_TTL_SECONDS,
CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS,
readCustomerCached,
resolveAttachConflict,
responseHasActivePlan,
responseHasPlanHistory,
} from "@/services/billing/autumn-client"
import { AutumnClient, type AutumnResult } from "@/services/billing/autumn-http"
import {
Expand Down Expand Up @@ -47,6 +49,10 @@ const activePlanResponse = {
subscriptions: [{ planId: "startup", status: "active", trialEndsAt: 9_999_999_999_000, addOn: false }],
}
const noPlanResponse = { id: ORG, subscriptions: [] }
const lapsedPlanResponse = {
id: ORG,
subscriptions: [{ planId: "startup", status: "expired", addOn: false }],
}

// `AutumnClient` reads its credentials from `Env` and captures the HttpClient at
// layer build; the fetch stub is provided as the `FetchHttpClient.Fetch`
Expand Down Expand Up @@ -238,7 +244,7 @@ describe("readCustomerCached", () => {
}),
)

it.effect("caches a planless customer for the short TTL so the gate re-checks soon", () =>
it.effect("caches a never-subscribed customer for the short TTL — checkout is imminent", () =>
Effect.gen(function* () {
const { cache, puts } = makeRecordingBackend()
const run = Effect.succeed({ statusCode: 200, response: noPlanResponse })
Expand All @@ -247,6 +253,15 @@ describe("readCustomerCached", () => {
}),
)

it.effect("caches a lapsed customer for the middle TTL — durably planless, not mid-signup", () =>
Effect.gen(function* () {
const { cache, puts } = makeRecordingBackend()
const run = Effect.succeed({ statusCode: 200, response: lapsedPlanResponse })
yield* readCustomerCached(cache, ORG, run)
assert.deepStrictEqual(puts, [CUSTOMER_CACHE_LAPSED_TTL_SECONDS])
}),
)

it.effect("treats an error-shaped 200 (no subscriptions array) as unsettled → short TTL", () =>
Effect.gen(function* () {
const { cache, puts } = makeRecordingBackend()
Expand Down Expand Up @@ -361,6 +376,22 @@ describe("responseHasActivePlan", () => {
})
})

describe("responseHasPlanHistory", () => {
it("separates a lapsed customer from one that never subscribed", () => {
assert.isTrue(responseHasPlanHistory(lapsedPlanResponse))
assert.isTrue(responseHasPlanHistory(activePlanResponse))
assert.isFalse(responseHasPlanHistory(noPlanResponse))
assert.isFalse(responseHasPlanHistory({ id: ORG }))
})

it("ignores add-on, auto-enabled and free rows — they never gated anything", () => {
assert.isFalse(
responseHasPlanHistory({ subscriptions: [{ planId: "byoc", status: "expired", addOn: true }] }),
)
assert.isFalse(responseHasPlanHistory({ subscriptions: [{ planId: "free", status: "expired" }] }))
})
})

describe("resolveAttachConflict", () => {
const conflict = new BillingConflictError({
message: "Customer already has this plan",
Expand Down
70 changes: 54 additions & 16 deletions apps/api/src/services/billing/autumn-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Effect, Schema } from "effect"
import type { EdgeCacheServiceApi } from "@maple/cache"
import { isActivePlanSubscription } from "@maple/domain/billing"
import {
isActivePlanSubscription,
isPlanSubscription,
type PlanGatingSubscription,
} from "@maple/domain/billing"
import {
BillingConflictError,
BillingCustomer,
Expand Down Expand Up @@ -29,21 +33,59 @@ import type { AutumnResult, AutumnTransportFailure } from "./autumn-http"
export const CUSTOMER_CACHE_BUCKET = "autumn-customer"
export const CUSTOMER_CACHE_TTL_SECONDS = 300

// Short TTL for a customer with no active plan: caching that "no plan" snapshot
// for the full 5 min would strand a just-subscribed user on the /quick-start
// gate until the post-checkout Stripe→Autumn sync lands. Re-check soon instead.
// Short TTL for a customer with no plan history at all: this org is mid-signup
// and about to check out, and caching that "no plan" snapshot for the full 5 min
// would strand it on the /quick-start gate until the post-checkout Stripe→Autumn
// sync lands. Re-check almost immediately instead.
export const CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS = 5

// A lapsed customer (held a plan, holds none now) is planless *durably* — that
// state normally persists for weeks, so the 5s unsettled TTL would send every
// one of their page loads upstream (p50 ~94ms, p95 ~603ms) for an answer that
// hasn't changed. They browse the app now rather than being parked on the
// onboarding gate, so this is a real hot path. A minute keeps a resubscribe
// visible promptly — `attach` also invalidates the entry outright — while
// cutting the upstream call rate ~12x.
export const CUSTOMER_CACHE_LAPSED_TTL_SECONDS = 60

/**
* Does this raw `getOrCreateCustomer` response carry an active, non-add-on,
* non-free plan? Delegates to the shared `isActivePlanSubscription` gate
* (@maple/domain/billing) so the cache TTL can't drift from the web redirect gate.
*/
export const responseHasActivePlan = (response: unknown): boolean => {
if (typeof response !== "object" || response === null) return false
export const responseHasActivePlan = (response: unknown): boolean =>
subscriptionsOf(response).some(isActivePlanSubscription)

/**
* Has this org ever held a real plan, whatever its status? Mirrors the web
* `hasLapsedPlan` gate — together with `responseHasActivePlan` it separates a
* never-subscribed org (short TTL, checkout imminent) from a lapsed one
* (durably planless, so worth caching).
*/
export const responseHasPlanHistory = (response: unknown): boolean =>
subscriptionsOf(response).some(isPlanSubscription)

// Every field of `PlanGatingSubscription` is optional, so "is an object" is the
// whole shape check — the gating predicates answer for themselves what a missing
// `status` or `planId` means. Anything else in the array is not a subscription.
const isSubscriptionLike = (value: unknown): value is PlanGatingSubscription =>
typeof value === "object" && value !== null

const subscriptionsOf = (response: unknown): ReadonlyArray<PlanGatingSubscription> => {
if (typeof response !== "object" || response === null) return []
const subscriptions = (response as { subscriptions?: unknown }).subscriptions
if (!Array.isArray(subscriptions)) return false
return subscriptions.some((sub) => isActivePlanSubscription(sub))
return Array.isArray(subscriptions) ? subscriptions.filter(isSubscriptionLike) : []
}

/**
* How long a `getOrCreateCustomer` response stays cached. Three tiers, because
* "no active plan" covers two states with opposite cache economics: an org
* seconds away from its first checkout, and one that lapsed weeks ago.
*/
const customerCacheTtl = (response: unknown): number => {
if (responseHasActivePlan(response)) return CUSTOMER_CACHE_TTL_SECONDS
if (responseHasPlanHistory(response)) return CUSTOMER_CACHE_LAPSED_TTL_SECONDS
return CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS
}

// Sentinel keeping non-200 Autumn responses out of the edge cache: the compute
Expand All @@ -58,10 +100,9 @@ class UncacheableAutumnResult extends Schema.TaggedError<UncacheableAutumnResult
) {}

/**
* Run `getOrCreateCustomer` through the per-org edge cache (200-only). Active-plan
* 200s get the full TTL; planless ones a short TTL so the gate re-checks soon
* after a post-checkout sync. Returns the resolved result plus whether it came
* from the cache (for span annotation).
* Run `getOrCreateCustomer` through the per-org edge cache (200-only), on the
* tiered TTL above. Returns the resolved result plus whether it came from the
* cache (for span annotation).
*/
export const readCustomerCached = (
edgeCache: Pick<EdgeCacheServiceApi, "getOrCompute">,
Expand All @@ -73,10 +114,7 @@ export const readCustomerCached = (
{
bucket: CUSTOMER_CACHE_BUCKET,
key: orgId,
ttlSeconds: (result: AutumnResult) =>
responseHasActivePlan(result.response)
? CUSTOMER_CACHE_TTL_SECONDS
: CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS,
ttlSeconds: (result: AutumnResult) => customerCacheTtl(result.response),
},
runAutumn.pipe(
Effect.flatMap((res) =>
Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/atoms/selected-plan-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { Schema } from "effect"
import { localStorageRuntime } from "@/lib/services/common/storage-runtime"

/**
* Per-org memory of "this org was last seen holding an active selected plan",
* used by the `__root` gate to optimistically render the dashboard while the
* Autumn customer query is still loading. Keyed by orgId so a brand-new org
* (fresh signup) starts with no record — taking the no-flash "wait for the plan
* to settle" path — while a returning paid org skips straight to the dashboard.
* Cleared the moment an org is seen planless (e.g. after unsubscribing), so that
* case flashes the dashboard at most once and then reverts to the wait path.
* See MAP-45.
* Per-org memory of "this org was last seen entitled to render the app", used by
* the `__root` gate to optimistically render the dashboard while the Autumn
* customer query is still loading. Keyed by orgId so a brand-new org (fresh
* signup) starts with no record — taking the no-flash "wait for the plan to
* settle" path — while a returning org skips straight to the dashboard.
*
* "Entitled" is broader than "holds an active plan": a lapsed subscriber also
* renders the app (behind the reactivation banner), so unsubscribing no longer
* clears the flag. Only an org seen with no plan history at all clears it, which
* is exactly the org the gate sends to onboarding. See MAP-45.
*/
const selectedPlanKnownAtomFamily = Atom.family((orgId: string) =>
Atom.kvs({
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/billing/subscription-ended-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Link } from "@tanstack/react-router"
import { useMapleCustomer } from "@/hooks/use-maple-customer"

import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert"
import { Button } from "@maple/ui/components/ui/button"
import { CircleWarningIcon } from "@/components/icons"
import { getLapsedPlan } from "@/lib/billing/plan-gating"

// Dev-only escape hatch so the banner can be eyeballed without a lapsed Autumn
// customer: load any page with `?subscription_ended_preview=1`. Compiled out of
// production builds (import.meta.env.DEV).
function previewLapsed(): boolean {
if (!import.meta.env.DEV || typeof window === "undefined") return false
return new URLSearchParams(window.location.search).get("subscription_ended_preview") === "1"
}

/**
* Critical alert shown in the app shell when the org held a plan and no longer
* does — cancelled, expired, or otherwise lapsed. These are returning customers,
* so they keep the app (their history is still here to read); what they've lost
* is ingestion, which the gateway rejects with a 402 until a plan is active
* again. Non-dismissible — it stays put until they resubscribe.
*/
export function SubscriptionEndedBanner() {
const { data: customer } = useMapleCustomer()
const lapsed = getLapsedPlan(customer)
const preview = previewLapsed()

if (!lapsed && !preview) return null

const planName = lapsed?.plan?.name ?? lapsed?.planId ?? null

return (
<div className="px-4 pt-3">
<Alert variant="error">
<CircleWarningIcon size={16} />
<AlertTitle>Subscription ended</AlertTitle>
<AlertDescription>
{planName ? `Your ${planName} plan has ended.` : "Your plan has ended."} Your existing
data is still here, but new telemetry is being rejected until you pick a plan.
</AlertDescription>
<AlertAction>
<Button size="sm" render={<Link to="/select-plan" />}>
Choose a plan
</Button>
</AlertAction>
</Alert>
</div>
)
}
2 changes: 2 additions & 0 deletions apps/web/src/components/layout/dashboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { openGlobalChat } from "@/components/chat/global-chat-sheet"
import { ConnectButton } from "@/components/header/connect-button"
import { QuotaBanner } from "@/components/billing/quota-banner"
import { PaymentFailedBanner } from "@/components/billing/payment-failed-banner"
import { SubscriptionEndedBanner } from "@/components/billing/subscription-ended-banner"
import { AppUpdateBanner } from "@/components/layout/app-update-banner"
import { Link, defaultParseSearch } from "@tanstack/react-router"
import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode"
Expand Down Expand Up @@ -164,6 +165,7 @@ function Body({ children }: { children: React.ReactNode }) {
whether or not the deployment uses Clerk, and self-hosted installs
have the same long-lived-tab problem. */}
<AppUpdateBanner />
{isClerkAuthEnabled && <SubscriptionEndedBanner />}
{isClerkAuthEnabled && <PaymentFailedBanner />}
{isClerkAuthEnabled && <QuotaBanner />}
<PageLayout.Body>{children}</PageLayout.Body>
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/lib/billing/plan-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"
import type { BillingBalance, BillingCustomer, BillingSubscription, CatalogPlan } from "@maple/domain/http"
import {
getFeatureQuotas,
getLapsedPlan,
getLegacyPlanInfo,
getPastDueSubscription,
getQuotaStatus,
hasBringYourOwnCloudAddOn,
hasLapsedPlan,
hasSelectedPlan,
isLegacyPlan,
isUsableCustomer,
Expand Down Expand Up @@ -110,6 +112,60 @@ describe("hasSelectedPlan", () => {
})
})

describe("getLapsedPlan / hasLapsedPlan", () => {
it("returns nothing for an org that never held a plan", () => {
expect(hasLapsedPlan(buildCustomer([]))).toBe(false)
expect(hasLapsedPlan(buildCustomer([buildSubscription({ status: "expired", addOn: true })]))).toBe(
false,
)
expect(
hasLapsedPlan(buildCustomer([buildSubscription({ status: "expired", autoEnable: true })])),
).toBe(false)
expect(
hasLapsedPlan(
buildCustomer([
buildSubscription({
status: "expired",
planId: "free",
plan: { name: "Free", archived: false },
}),
]),
),
).toBe(false)
})

it("returns the plan for an org whose subscription lapsed", () => {
const expired = buildCustomer([buildSubscription({ status: "expired" })])
const canceled = buildCustomer([buildSubscription({ status: "canceled" })])

expect(hasLapsedPlan(expired)).toBe(true)
expect(hasLapsedPlan(canceled)).toBe(true)
expect(getLapsedPlan(expired)?.planId).toBe("starter")
})

it("returns nothing while an active plan exists, even alongside an expired one", () => {
const customer = buildCustomer([
buildSubscription({ planId: "old", status: "expired" }),
buildSubscription({ planId: "startup", status: "active" }),
])
expect(hasLapsedPlan(customer)).toBe(false)
})

it("picks the most recently ended subscription", () => {
const customer = buildCustomer([
buildSubscription({ planId: "old", status: "expired", currentPeriodEnd: 1_000 }),
buildSubscription({ planId: "recent", status: "expired", currentPeriodEnd: 5_000 }),
])
expect(getLapsedPlan(customer)?.planId).toBe("recent")
})

it("returns nothing when there is no customer at all", () => {
expect(hasLapsedPlan(null)).toBe(false)
expect(hasLapsedPlan(undefined)).toBe(false)
expect(getLapsedPlan(null)).toBeNull()
})
})

describe("hasBringYourOwnCloudAddOn", () => {
it("returns false when customer is missing", () => {
expect(hasBringYourOwnCloudAddOn(null)).toBe(false)
Expand Down Expand Up @@ -161,6 +217,10 @@ describe("malformed / error-shaped customer payloads", () => {
it("gating helpers never throw on an error payload and fail closed", () => {
expect(() => hasSelectedPlan(errorPayload)).not.toThrow()
expect(hasSelectedPlan(errorPayload)).toBe(false)
// An Autumn error must never read as "this org used to have a plan" — that
// would wave a brand-new org past the onboarding gate.
expect(() => hasLapsedPlan(errorPayload)).not.toThrow()
expect(hasLapsedPlan(errorPayload)).toBe(false)
expect(hasBringYourOwnCloudAddOn(errorPayload)).toBe(false)
expect(isUsageBasedPlan(errorPayload)).toBe(false)
expect(getQuotaStatus(errorPayload)).toBe("ok")
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/lib/billing/plan-gating.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isActivePlanSubscription } from "@maple/domain/billing"
import { isActivePlanSubscription, isPlanSubscription } from "@maple/domain/billing"
import type { BillingBalance, BillingCustomer, BillingSubscription, CatalogPlan } from "@maple/domain/http"

type Customer = BillingCustomer
Expand Down Expand Up @@ -72,6 +72,39 @@ export function hasSelectedPlan(customer: Customer | null | undefined): boolean
return getActivePlan(customer) !== null
}

/**
* The plan this org used to hold and no longer does — cancelled, expired, or
* otherwise lapsed. Non-null only when there is no active plan, so a returning
* customer is distinguishable from a brand-new org whose customer carries no
* plan history at all. That distinction is the whole point: without it the
* `__root` gate bounces a lapsed subscriber into the new-user onboarding wizard.
*
* Autumn keeps lapsed subscription rows on the customer, so this reads history
* straight off the payload. When several have lapsed, the one that ended last
* wins — it's the plan the customer actually remembers being on.
*/
export function getLapsedPlan(customer: Customer | null | undefined): Subscription | null {
if (!isUsableCustomer(customer)) return null
if (getActivePlan(customer) !== null) return null

let lapsed: Subscription | null = null
for (const sub of customer.subscriptions) {
if (!isPlanSubscription(sub)) continue
if (lapsed === null) {
lapsed = sub
continue
}
// `currentPeriodEnd` is optional upstream; a row that carries one is
// always a better answer than one that doesn't.
if ((sub.currentPeriodEnd ?? -1) > (lapsed.currentPeriodEnd ?? -1)) lapsed = sub
}
return lapsed
}

export function hasLapsedPlan(customer: Customer | null | undefined): boolean {
return getLapsedPlan(customer) !== null
}

export interface TrialStatus {
isTrialing: boolean
daysRemaining: number | null
Expand Down
Loading
Loading