diff --git a/apps/api/src/routes/billing.http.test.ts b/apps/api/src/routes/billing.http.test.ts index acab868aad..ac9c4ad319 100644 --- a/apps/api/src/routes/billing.http.test.ts +++ b/apps/api/src/routes/billing.http.test.ts @@ -1,12 +1,40 @@ import { assert, describe, it } from "@effect/vitest" import { Effect } from "effect" -import { makeEdgeCacheService, makeMemoryBackend } from "@maple/query-engine/caching" -import { CUSTOMER_CACHE_BUCKET, readCustomerCached } from "./billing.http" +import { type EdgeCacheBackend, makeEdgeCacheService, makeMemoryBackend } from "@maple/query-engine/caching" +import { + CUSTOMER_CACHE_BUCKET, + CUSTOMER_CACHE_TTL_SECONDS, + CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS, + readCustomerCached, + responseHasActivePlan, +} from "./billing.http" const ORG = "org_test_123" const makeCache = () => makeEdgeCacheService(makeMemoryBackend()) +// Wrap the real memory backend so caching/expiry still works, but record the +// TTL handed to each `put` — lets us assert the content-dependent TTL policy. +const makeRecordingBackend = () => { + const inner = makeMemoryBackend() + const puts: number[] = [] + const backend: EdgeCacheBackend = { + get: inner.get, + put: (bucket, hash, value, ttlSeconds, nowMs) => { + puts.push(ttlSeconds) + return inner.put(bucket, hash, value, ttlSeconds, nowMs) + }, + delete: inner.delete, + } + return { cache: makeEdgeCacheService(backend), puts } +} + +const activePlanResponse = { + id: ORG, + subscriptions: [{ planId: "startup", status: "active", trialEndsAt: 9_999_999_999_000, addOn: false }], +} +const noPlanResponse = { id: ORG, subscriptions: [] } + describe("readCustomerCached", () => { it.effect("caches a 200 response: 2nd call hits the cache, upstream runs once", () => Effect.gen(function* () { @@ -81,4 +109,43 @@ describe("readCustomerCached", () => { assert.strictEqual(calls, 2) }), ) + + it.effect("caches an active-plan customer for the full TTL", () => + Effect.gen(function* () { + const { cache, puts } = makeRecordingBackend() + const run = Effect.succeed({ statusCode: 200, response: activePlanResponse }) + yield* readCustomerCached(cache, ORG, run) + assert.deepStrictEqual(puts, [CUSTOMER_CACHE_TTL_SECONDS]) + }), + ) + + it.effect("caches a planless customer for the short TTL so the gate re-checks soon", () => + Effect.gen(function* () { + const { cache, puts } = makeRecordingBackend() + const run = Effect.succeed({ statusCode: 200, response: noPlanResponse }) + yield* readCustomerCached(cache, ORG, run) + assert.deepStrictEqual(puts, [CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS]) + }), + ) + + it.effect("treats an error-shaped 200 (no subscriptions array) as unsettled → short TTL", () => + Effect.gen(function* () { + const { cache, puts } = makeRecordingBackend() + const run = Effect.succeed({ statusCode: 200, response: { error: "autumn_api_error" } }) + yield* readCustomerCached(cache, ORG, run) + assert.deepStrictEqual(puts, [CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS]) + }), + ) +}) + +describe("responseHasActivePlan", () => { + it("is true for an active (trialing) base-plan subscription", () => { + assert.isTrue(responseHasActivePlan(activePlanResponse)) + }) + + it("is false with no subscriptions, an empty list, or a non-active status", () => { + assert.isFalse(responseHasActivePlan(noPlanResponse)) + assert.isFalse(responseHasActivePlan({ id: ORG })) + assert.isFalse(responseHasActivePlan({ subscriptions: [{ planId: "startup", status: "expired" }] })) + }) }) diff --git a/apps/api/src/routes/billing.http.ts b/apps/api/src/routes/billing.http.ts index ca0680ee74..3a6e594fa3 100644 --- a/apps/api/src/routes/billing.http.ts +++ b/apps/api/src/routes/billing.http.ts @@ -3,6 +3,7 @@ import { HttpServerRequest } from "effect/unstable/http" import { Effect, Option, Redacted, Schema } from "effect" import { autumnHandler, type CustomerData } from "autumn-js/backend" import { EdgeCacheService, type EdgeCacheServiceShape } from "@maple/query-engine/caching" +import { isActivePlanSubscription } from "@maple/domain/billing" import { AttachResult, BillingCustomer, @@ -33,6 +34,23 @@ const AUTUMN_PATH_PREFIX = "/api/autumn" 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. +export const CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS = 5 + +/** + * 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 + const subscriptions = (response as { subscriptions?: unknown }).subscriptions + if (!Array.isArray(subscriptions)) return false + return subscriptions.some((sub) => isActivePlanSubscription(sub)) +} + // Sentinel keeping non-200 Autumn responses out of the edge cache: the compute // fails with this so `getOrCompute` never stores it, then the caller recovers it // into the normal path. Mirrors `AutumnHandlerResult` so `.result` stays typed. @@ -44,8 +62,10 @@ class UncacheableAutumnResult extends Schema.TaggedErrorClass, @@ -54,7 +74,14 @@ export const readCustomerCached = ( ): Effect.Effect<{ readonly result: AutumnResult; readonly hit: boolean }, BillingUpstreamError> => edgeCache .getOrCompute( - { bucket: CUSTOMER_CACHE_BUCKET, key: orgId, ttlSeconds: CUSTOMER_CACHE_TTL_SECONDS }, + { + bucket: CUSTOMER_CACHE_BUCKET, + key: orgId, + ttlSeconds: (result: AutumnResult) => + responseHasActivePlan(result.response) + ? CUSTOMER_CACHE_TTL_SECONDS + : CUSTOMER_CACHE_UNSETTLED_TTL_SECONDS, + }, runAutumn.pipe( Effect.flatMap((res) => res.statusCode === 200 diff --git a/apps/web/src/lib/billing/plan-gating.ts b/apps/web/src/lib/billing/plan-gating.ts index 29db7ae236..095589c683 100644 --- a/apps/web/src/lib/billing/plan-gating.ts +++ b/apps/web/src/lib/billing/plan-gating.ts @@ -1,3 +1,4 @@ +import { isActivePlanSubscription } from "@maple/domain/billing" import type { BillingBalance, BillingCustomer, @@ -69,14 +70,7 @@ export function isUsableCustomer(customer: Customer | null | undefined): custome export function getActivePlan(customer: Customer | null | undefined): Subscription | null { if (!isUsableCustomer(customer)) return null - - return ( - customer.subscriptions.find((sub) => { - if (sub.addOn || sub.autoEnable) return false - if (isLegacyFreePlan(sub)) return false - return sub.status === "active" - }) ?? null - ) + return customer.subscriptions.find((sub) => isActivePlanSubscription(sub)) ?? null } export function hasSelectedPlan(customer: Customer | null | undefined): boolean { diff --git a/packages/domain/package.json b/packages/domain/package.json index 6c9f474399..eca43688d2 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./billing": "./src/billing.ts", "./http": "./src/http/index.ts", "./primitives": "./src/primitives.ts", "./query-engine": "./src/query-engine.ts", diff --git a/packages/domain/src/billing.test.ts b/packages/domain/src/billing.test.ts new file mode 100644 index 0000000000..ee98688cb0 --- /dev/null +++ b/packages/domain/src/billing.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest" +import { isActivePlanSubscription } from "./billing" + +describe("isActivePlanSubscription", () => { + it("is true for an active base plan (trials included — Autumn marks them active)", () => { + expect(isActivePlanSubscription({ planId: "startup", status: "active" })).toBe(true) + }) + + it("is false for add-on, auto-enabled, and free plans", () => { + expect(isActivePlanSubscription({ planId: "byoc", status: "active", addOn: true })).toBe(false) + expect(isActivePlanSubscription({ planId: "starter", status: "active", autoEnable: true })).toBe(false) + expect(isActivePlanSubscription({ planId: "free", status: "active" })).toBe(false) + expect(isActivePlanSubscription({ planId: "x", status: "active", plan: { name: "Free" } })).toBe(false) + }) + + it("is false for non-active status and empty/missing input", () => { + expect(isActivePlanSubscription({ planId: "startup", status: "scheduled" })).toBe(false) + expect(isActivePlanSubscription({ planId: "startup", status: "expired" })).toBe(false) + expect(isActivePlanSubscription({})).toBe(false) + expect(isActivePlanSubscription(null)).toBe(false) + expect(isActivePlanSubscription(undefined)).toBe(false) + }) +}) diff --git a/packages/domain/src/billing.ts b/packages/domain/src/billing.ts new file mode 100644 index 0000000000..66d80baacc --- /dev/null +++ b/packages/domain/src/billing.ts @@ -0,0 +1,23 @@ +/** + * Shared billing gate — the single source of truth for "is this an actively + * selected paid plan?", used by both the web redirect gate + * (apps/web/src/lib/billing/plan-gating.ts) and the API customer-cache TTL + * (apps/api/src/routes/autumn.http.ts) so the two can't drift. Structurally + * typed so `autumn-js` stays out of `@maple/domain`; Autumn's `Subscription` + * shape satisfies it. + */ +export interface PlanGatingSubscription { + readonly status?: string | null + readonly addOn?: boolean | null + readonly autoEnable?: boolean | null + readonly planId?: string | null + readonly plan?: { readonly name?: string | null } | null +} + +/** Active, and not an add-on / auto-enabled / legacy-free tier. Trials count — Autumn reports them as `active`. */ +export function isActivePlanSubscription(sub: PlanGatingSubscription | null | undefined): boolean { + if (!sub) return false + if (sub.addOn || sub.autoEnable) return false + if (sub.planId?.toLowerCase() === "free" || sub.plan?.name?.toLowerCase() === "free") return false + return sub.status === "active" +} diff --git a/packages/query-engine/src/caching/edge-cache.test.ts b/packages/query-engine/src/caching/edge-cache.test.ts index b3b2e1802a..b3851f48ba 100644 --- a/packages/query-engine/src/caching/edge-cache.test.ts +++ b/packages/query-engine/src/caching/edge-cache.test.ts @@ -54,6 +54,27 @@ describe("EdgeCacheService.getOrCompute (no schema)", () => { assert.deepStrictEqual(second.value, { hello: "world", n: 42 }) }).pipe(Effect.provide(makeLayer(backend))) }) + + it.effect("derives the TTL from the computed value when ttlSeconds is a function", () => { + const puts: number[] = [] + const backend: EdgeCacheBackend = { + get: async () => undefined, // force a miss → always computes → always writes + put: async (_bucket, _hash, _value, ttlSeconds) => { + puts.push(ttlSeconds) + }, + delete: async () => {}, + } + const ttlBySize = (value: { n: number }) => (value.n > 10 ? 300 : 15) + + return Effect.gen(function* () { + const cache = yield* EdgeCacheService + yield* cache.getOrCompute({ bucket: "ttl", key: "big", ttlSeconds: ttlBySize }, Effect.succeed({ n: 42 })) + yield* cache.getOrCompute({ bucket: "ttl", key: "small", ttlSeconds: ttlBySize }, Effect.succeed({ n: 3 })) + + // The resolver runs against each freshly computed value, not a constant. + assert.deepStrictEqual(puts, [300, 15]) + }).pipe(Effect.provide(makeLayer(backend))) + }) }) describe("EdgeCacheService.invalidate", () => { diff --git a/packages/query-engine/src/caching/edge-cache.ts b/packages/query-engine/src/caching/edge-cache.ts index 5014bf72ea..620f9923eb 100644 --- a/packages/query-engine/src/caching/edge-cache.ts +++ b/packages/query-engine/src/caching/edge-cache.ts @@ -16,7 +16,8 @@ export class EdgeCacheIOError extends Schema.TaggedErrorClass( export interface EdgeCacheGetOrComputeOptions { readonly bucket: string readonly key: string - readonly ttlSeconds: number + /** Cache TTL (seconds), or a function deriving it from the computed value — run once on write, never on a hit. */ + readonly ttlSeconds: number | ((value: A) => number) /** * Optional codec used to (a) encode the value into a JSON-safe form before * `backend.put`, and (b) decode the cached bytes back into the original @@ -152,9 +153,11 @@ export const makeEdgeCacheService = (backend: EdgeCacheBackend): EdgeCacheServic const stored: unknown = options.schema ? yield* Schema.encodeUnknownEffect(options.schema)(value).pipe(Effect.orDie) : value + const ttlSeconds = + typeof options.ttlSeconds === "function" ? options.ttlSeconds(value) : options.ttlSeconds const writeNowMs = yield* Clock.currentTimeMillis yield* Effect.tryPromise({ - try: () => backend.put(options.bucket, hash, stored, options.ttlSeconds, writeNowMs), + try: () => backend.put(options.bucket, hash, stored, ttlSeconds, writeNowMs), catch: (error) => error, }).pipe( Effect.tapError((error) =>