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
71 changes: 69 additions & 2 deletions apps/api/src/routes/billing.http.test.ts
Original file line number Diff line number Diff line change
@@ -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* () {
Expand Down Expand Up @@ -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" }] }))
})
})
33 changes: 30 additions & 3 deletions apps/api/src/routes/billing.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -44,8 +62,10 @@ class UncacheableAutumnResult extends Schema.TaggedErrorClass<UncacheableAutumnR
) {}

/**
* Run `getOrCreateCustomer` through the per-org edge cache (200-only). Returns
* the resolved result plus whether it came from the cache (for span annotation).
* 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).
*/
export const readCustomerCached = (
edgeCache: Pick<EdgeCacheServiceShape, "getOrCompute">,
Expand All @@ -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
Expand Down
10 changes: 2 additions & 8 deletions apps/web/src/lib/billing/plan-gating.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isActivePlanSubscription } from "@maple/domain/billing"
import type {
BillingBalance,
BillingCustomer,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions packages/domain/src/billing.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
23 changes: 23 additions & 0 deletions packages/domain/src/billing.ts
Original file line number Diff line number Diff line change
@@ -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"
}
21 changes: 21 additions & 0 deletions packages/query-engine/src/caching/edge-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/query-engine/src/caching/edge-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export class EdgeCacheIOError extends Schema.TaggedErrorClass<EdgeCacheIOError>(
export interface EdgeCacheGetOrComputeOptions<A = unknown, I = unknown> {
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
Expand Down Expand Up @@ -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) =>
Expand Down
Loading