Skip to content

Commit 233e86c

Browse files
committed
release: 0.13.2
Add balances.check to the Autumn emulator, matching the autumn-js SDK's POST /v1/balances.check wire shape. Access is computed from the same plan item and tracked usage state that drives customer balances, the response carries the full balance object the SDK validates, and the route participates in fault injection and the request ledger.
1 parent 512201c commit 233e86c

30 files changed

Lines changed: 195 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
# Changelog
22

3-
## 0.13.1
3+
## 0.13.2
44

55
<!-- release:start -->
66

7-
### Fixes
7+
### New Features
88

9-
- **Seeded drive items land on the seed's own user**`drive_items` (and other resources) that omit `user_email` now attach to the first user declared in the seed config instead of the plugin's built-in default user, so a token minted for the seeded user sees the seeded files (and their `content`) under `/me/drive`.
9+
- **Autumn `balances.check`** — the Autumn emulator now supports the SDK's `check()` call (`POST /v1/balances.check`). Access is computed from the same plan items and tracked usage that drive customer balances: unlimited and overage-allowed features always pass, metered features pass while remaining balance covers `required_balance` (default 1), and a feature the customer's plan does not carry is allowed with a `null` balance. The response carries the full balance object (`granted`, `remaining`, `usage`, `unlimited`, `overage_allowed`) in the shape autumn-js validates, and the route participates in fault injection and the request ledger like every other operation.
1010

1111
<!-- release:end -->
1212

13+
## 0.13.1
14+
15+
### Fixes
16+
17+
- **Seeded drive items land on the seed's own user**`drive_items` (and other resources) that omit `user_email` now attach to the first user declared in the seed config instead of the plugin's built-in default user, so a token minted for the seeded user sees the seeded files (and their `content`) under `/me/drive`.
18+
1319
## 0.13.0
1420

1521
### New Features

packages/@emulators/adapter-next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/adapter-next",
3-
"version": "0.13.1",
3+
"version": "0.13.2",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

packages/@emulators/apple/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/apple",
3-
"version": "0.13.1",
3+
"version": "0.13.2",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

packages/@emulators/autumn/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/autumn",
3-
"version": "0.13.1",
3+
"version": "0.13.2",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

packages/@emulators/autumn/src/__tests__/autumn.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ beforeAll(() => {
2121
fallbackUser: { login: "am_emulate_admin", id: 1, scopes: [] },
2222
});
2323
seedFromConfig(store, BASE, {
24-
plans: [{ id: "pro", name: "Pro", items: [{ feature_id: "executions", included: 1000 }] }],
25-
customers: [{ id: "org_paid", subscriptions: [{ plan_id: "pro", status: "active" }] }],
24+
plans: [
25+
{ id: "pro", name: "Pro", items: [{ feature_id: "executions", included: 1000 }] },
26+
{ id: "starter", name: "Starter", items: [{ feature_id: "executions", included: 2 }] },
27+
],
28+
customers: [
29+
{ id: "org_paid", subscriptions: [{ plan_id: "pro", status: "active" }] },
30+
{ id: "org_capped", subscriptions: [{ plan_id: "starter", status: "active" }] },
31+
],
2632
});
2733
httpServer = serve({ fetch: app.fetch, port: PORT });
2834
autumn = new Autumn({ secretKey: "am_test_emulate", serverURL: BASE });
@@ -48,6 +54,61 @@ describe("autumn emulator with the real autumn-js SDK", () => {
4854
await autumn.track({ customerId: "org_fresh", featureId: "executions", value: 1 });
4955
});
5056

57+
it("check allows a feature with remaining balance", async () => {
58+
const check = await autumn.check({ customerId: "org_paid", featureId: "executions" });
59+
expect(check.allowed).toBe(true);
60+
expect(check.customerId).toBe("org_paid");
61+
expect(check.balance?.remaining).toBe(1000);
62+
expect(check.balance?.unlimited).toBe(false);
63+
});
64+
65+
it("check denies an exhausted feature with no overage", async () => {
66+
// starter includes 2 executions; burn them both through balances.track.
67+
await autumn.track({ customerId: "org_capped", featureId: "executions", value: 2 });
68+
const check = await autumn.check({ customerId: "org_capped", featureId: "executions" });
69+
expect(check.allowed).toBe(false);
70+
expect(check.balance?.remaining).toBe(0);
71+
expect(check.balance?.usage).toBe(2);
72+
expect(check.balance?.overageAllowed).toBe(false);
73+
});
74+
75+
it("check allows a feature the customer's plan does not carry", async () => {
76+
const check = await autumn.check({ customerId: "org_paid", featureId: "not-a-feature" });
77+
expect(check.allowed).toBe(true);
78+
expect(check.balance).toBeNull();
79+
});
80+
81+
it("an armed fault on balances.check returns the injected 500 and marks the ledger entry", async () => {
82+
const armRes = await fetch(`${BASE}/_emulate/faults`, {
83+
method: "POST",
84+
headers: { "content-type": "application/json" },
85+
body: JSON.stringify({
86+
match: { operationId: "balances.check" },
87+
response: { status: 500, body: { message: "injected failure" } },
88+
}),
89+
});
90+
expect(armRes.status).toBe(200);
91+
const { fault } = (await armRes.json()) as { fault: { id: string } };
92+
93+
const faulted = await fetch(`${BASE}/v1/balances.check`, {
94+
method: "POST",
95+
headers: { "content-type": "application/json", authorization: "Bearer am_test_emulate" },
96+
body: JSON.stringify({ customer_id: "org_paid", feature_id: "executions" }),
97+
});
98+
expect(faulted.status).toBe(500);
99+
expect(await faulted.json()).toEqual({ message: "injected failure" });
100+
101+
const ledger = (await (await fetch(`${BASE}/_emulate/ledger`)).json()) as {
102+
entries: Array<{ path: string; faulted?: boolean; faultId?: string; response: { status: number } }>;
103+
};
104+
const entry = ledger.entries.find((e) => e.path === "/v1/balances.check" && e.faulted);
105+
expect(entry).toMatchObject({ faulted: true, faultId: fault.id, response: { status: 500 } });
106+
107+
// The fault was one-shot; the next check goes through normally.
108+
const check = await autumn.check({ customerId: "org_paid", featureId: "executions" });
109+
expect(check.allowed).toBe(true);
110+
});
111+
51112
// Regression for the autumn-js 0.9.0 emulator regression: autumn-js 1.2.8's
52113
// `useCustomer` hook drives its backend route, which always calls
53114
// `customers.getOrCreate` with `expand: ["balances.feature"]` (see

packages/@emulators/autumn/src/manifest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export const manifest: ServiceManifest = {
44
id: "autumn",
55
name: "Autumn",
66
description:
7-
"Stateful Autumn billing emulator: customers (with seedable subscriptions and a plan catalog), usage tracking, plan eligibility, and a hosted checkout flow for paid plans and card-required free trials.",
7+
"Stateful Autumn billing emulator: customers (with seedable subscriptions and a plan catalog), usage tracking, feature access checks, plan eligibility, and a hosted checkout flow for paid plans and card-required free trials.",
88
docsUrl: "https://docs.emulators.dev/autumn",
99
surfaces: [
1010
{ id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" },
@@ -27,6 +27,7 @@ export const manifest: ServiceManifest = {
2727
{ operationId: "customers.list", method: "POST", path: "/v1/customers.list", status: "hand-authored" },
2828
{ operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" },
2929
{ operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" },
30+
{ operationId: "balances.check", method: "POST", path: "/v1/balances.check", status: "hand-authored" },
3031
{ operationId: "plans.list", method: "POST", path: "/v1/plans.list", status: "hand-authored" },
3132
{ operationId: "billing.attach", method: "POST", path: "/v1/billing.attach", status: "hand-authored" },
3233
{

packages/@emulators/autumn/src/routes/api.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { RouteContext } from "@emulators/core";
22

33
import { getAutumnStore } from "../store.js";
4-
import { ensureCustomer, serializeCustomer, serializePlan, activateSubscription } from "../serialize.js";
4+
import {
5+
ensureCustomer,
6+
serializeCustomer,
7+
serializePlan,
8+
activateSubscription,
9+
balanceForFeature,
10+
} from "../serialize.js";
511

612
/** Autumn v1 RPC-style API (paths mirror autumn-js: /v1/<group>.<method>). */
713
export function autumnApiRoutes(ctx: RouteContext): void {
@@ -57,6 +63,43 @@ export function autumnApiRoutes(ctx: RouteContext): void {
5763
});
5864
});
5965

66+
// Feature access check, shaped after autumn-js's CheckResponse schema
67+
// (allowed, customer_id, entity_id, required_balance, balance, flag).
68+
// `allowed` is computed from the same balance state customers.get_or_create
69+
// serializes: unlimited features and overage-allowed features always pass,
70+
// metered features pass while `remaining` covers the required balance.
71+
// A feature the customer's plan does not carry gets a permissive
72+
// `allowed: true` with a null balance. The SDK types leave this case
73+
// ambiguous (CheckResponse.balance is nullable either way), so the emulator
74+
// deliberately fails open: an unseeded feature should not block every
75+
// request in the application under test. Seed a plan item with included: 0
76+
// to model a feature that denies access.
77+
app.post("/v1/balances.check", async (c) => {
78+
const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
79+
const customerId = String(body.customer_id ?? body.customerId ?? "");
80+
const featureId = String(body.feature_id ?? body.featureId ?? "");
81+
if (!customerId || !featureId) {
82+
return c.json({ message: "customer_id and feature_id are required", code: "invalid_request" }, 400);
83+
}
84+
const requiredBalance = typeof body.required_balance === "number" ? body.required_balance : 1;
85+
const entityId = typeof body.entity_id === "string" ? body.entity_id : null;
86+
const store = as();
87+
// Real Autumn auto-creates unknown customers on check (the SDK's own
88+
// backend flow relies on get_or_create semantics), so mirror that here.
89+
const customer = ensureCustomer(store, customerId, body);
90+
const balance = balanceForFeature(store, customer, featureId);
91+
const allowed =
92+
balance === undefined || balance.unlimited || balance.overage_allowed || balance.remaining >= requiredBalance;
93+
return c.json({
94+
allowed,
95+
customer_id: customerId,
96+
entity_id: entityId,
97+
required_balance: requiredBalance,
98+
balance: balance ?? null,
99+
flag: null,
100+
});
101+
});
102+
60103
// The plan catalog, scoped to the calling customer. The backend handler
61104
// injects `customer_id` into every request, so eligibility is per-customer:
62105
// a card-required trial reads as "Start free trial" until it is attached.

packages/@emulators/autumn/src/routes/openapi.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ function buildSpec(baseUrl: string): Record<string, unknown> {
107107
responses: { "200": ok("Event confirmation."), "400": ok("Validation error.") },
108108
},
109109
},
110+
"/v1/balances.check": {
111+
post: {
112+
operationId: "balances.check",
113+
tags: ["balances"],
114+
summary: "Check feature access for a customer",
115+
requestBody: jsonBody(
116+
{
117+
customer_id: { type: "string" },
118+
feature_id: { type: "string" },
119+
required_balance: { type: "number" },
120+
},
121+
["customer_id", "feature_id"],
122+
"The customer and feature to check. `required_balance` defaults to 1.",
123+
),
124+
responses: { "200": ok("Access decision with the feature balance."), "400": ok("Validation error.") },
125+
},
126+
},
110127
"/v1/plans.list": {
111128
post: {
112129
operationId: "plans.list",

packages/@emulators/autumn/src/serialize.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,25 @@ function serializeSubscription(sub: AutumnSubscription): Record<string, unknown>
106106
};
107107
}
108108

109-
function balancesFor(as: AutumnStore, customer: AutumnCustomer): Record<string, unknown> {
109+
/** One feature balance in the snake_case wire shape autumn-js's
110+
* `Balance$inboundSchema` requires: every field below is non-optional in the
111+
* SDK schema, so a balance is either absent (null) or complete. */
112+
export interface SerializedBalance {
113+
feature_id: string;
114+
feature: Record<string, unknown>;
115+
granted: number;
116+
remaining: number;
117+
usage: number;
118+
unlimited: boolean;
119+
overage_allowed: boolean;
120+
max_purchase: number | null;
121+
next_reset_at: number | null;
122+
}
123+
124+
function balancesFor(as: AutumnStore, customer: AutumnCustomer): Record<string, SerializedBalance> {
110125
const planId = activeSubscription(customer)?.plan_id ?? "free";
111126
const plan = as.plans.findOneBy("plan_id", planId);
112-
const balances: Record<string, unknown> = {};
127+
const balances: Record<string, SerializedBalance> = {};
113128
for (const item of plan?.items ?? []) {
114129
const unlimited = item.unlimited === true;
115130
const granted = unlimited ? 0 : (item.included ?? 0);
@@ -129,6 +144,17 @@ function balancesFor(as: AutumnStore, customer: AutumnCustomer): Record<string,
129144
return balances;
130145
}
131146

147+
/** The customer's balance for one feature, or undefined when the customer's
148+
* current plan has no item for it. Shares `balancesFor` so `balances.check`
149+
* and `customers.get_or_create` can never disagree about a balance. */
150+
export function balanceForFeature(
151+
as: AutumnStore,
152+
customer: AutumnCustomer,
153+
featureId: string,
154+
): SerializedBalance | undefined {
155+
return balancesFor(as, customer)[featureId];
156+
}
157+
132158
export function serializeCustomer(as: AutumnStore, customer: AutumnCustomer): Record<string, unknown> {
133159
return {
134160
id: customer.customer_id,

packages/@emulators/aws/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/aws",
3-
"version": "0.13.1",
3+
"version": "0.13.2",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

0 commit comments

Comments
 (0)