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
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
# Changelog

## 0.14.1
## 0.14.2

<!-- release:start -->

### New Features

- **Autumn `balances.update`** — the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s.
- **Autumn payment method setup and billing portal** — the Autumn emulator now models the card on file end to end. `POST /v1/billing.setup_payment` (autumn-js `billing.setupPayment`) opens a Stripe setup-mode checkout and returns `{ customer_id, entity_id?, url }`; the hosted page at `GET /checkout/setup/:sessionId` captures a card (`card_number`, `exp`, both editable so a test can pick the brand) and redirects to `success_url`. As with the existing checkout flow, nothing changes on the customer until the `checkout.session.completed` webhook lands, modelled by `POST /checkout/setup/:sessionId/settle` (or the customer-wide `POST /checkout/settle`, which now settles setup sessions too). Settling matches real Autumn's surprising behaviour: a setup session only sets the default payment method when the customer has none, and never replaces an existing card, because Autumn's setup-checkout handler reads the current default first and re-sets that same card.
- **Autumn hosted billing portal** — `POST /v1/billing.open_customer_portal` now returns a URL that actually serves a page. `GET /checkout/portal/:customerId` shows the customer's current plan, the card on file (or "No payment method"), a form to update it, and a link back to the `return_url` the portal session was opened with (recorded in the new `autumn.portals` collection). `POST /checkout/portal/:customerId/payment-method` changes the card IMMEDIATELY, with no settle step, because real Stripe swaps the default inside the portal and Autumn reads the card live on every expand. This is how an application under test changes a card. Unknown customers 404.
- **Autumn `payment_method` expand** — `customers.get_or_create` honours `expand: ["payment_method"]`, returning the Stripe PaymentMethod (`id`, `type`, `card.brand`, `card.last4`, `card.exp_month`, `card.exp_year`), and omitting the field entirely when there is no card or when `expand` does not ask for it. Settling a paid checkout also leaves a visa 4242 on file when the customer had no card, and customers can be seeded with a `payment_method`.

<!-- release:end -->

## 0.14.1

### New Features

- **Autumn `balances.update`** — the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s.

## 0.14.0

### New Features
Expand All @@ -35,14 +43,12 @@

- **MCP OAuth compliance scenario knobs** — the MCP emulator gains a seedable `oauth` config for exercising RFC 7591/8414/9728 client compliance: `issuerOverride` (advertise a lying issuer), `resourceOverride` (protected-resource metadata naming a foreign resource), `tokenEndpointAuthMethods` (advertise an exact list, or `"omit"` to drop the field so RFC 8414's client_secret_basic default applies), `dcrAuthMethodOverride` (the DCR response substitutes the requested token auth method), and `rejectClientNameContaining` (registration rejects branded client names with `invalid_client_metadata`). The token endpoint now supports HTTP Basic client authentication and enforces the registered method strictly: `client_secret_basic` clients must use Basic, `client_secret_post` clients must use the form body.


## 0.13.6

### New Features

- **WorkOS session logout** — the WorkOS emulator now serves AuthKit's session-end endpoint (`GET /user_management/sessions/logout?session_id=...&return_to=...`), the URL the Node SDK's `getLogoutUrl` builds. It revokes the session (its refresh token stops minting tokens, matching real WorkOS) and redirects to `return_to`, or renders a signed-out page when none is given. Apps can now exercise their full sign-out flow, including the hop through WorkOS, against the emulator.


## 0.13.5

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/@emulators/adapter-next/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@emulators/adapter-next",
"version": "0.14.1",
"version": "0.14.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/@emulators/apple/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@emulators/apple",
"version": "0.14.1",
"version": "0.14.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/@emulators/autumn/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@emulators/autumn",
"version": "0.14.1",
"version": "0.14.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
Expand Down
223 changes: 223 additions & 0 deletions packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, serve } from "@emulators/core";
import { Autumn } from "autumn-js";

import { autumnPlugin, seedFromConfig } from "../index.js";
import { manifest } from "../manifest.js";

// Drives "change the billing card" end to end through the real autumn-js SDK:
// billing.setup_payment opens a hosted setup page, submitting it captures a
// card and redirects back, and the customer's default payment method only
// changes once the Stripe webhook settles.

const PORT = 41885;
const BASE = `http://localhost:${PORT}`;
const SUCCESS_URL = `${BASE}/back-to-billing`;

let httpServer: ReturnType<typeof serve>;
let autumn: Autumn;

beforeAll(() => {
const { app, store } = createServer(autumnPlugin, {
port: PORT,
baseUrl: BASE,
manifest,
fallbackUser: { login: "am_emulate_admin", id: 1, scopes: [] },
});
seedFromConfig(store, BASE, {
plans: [
{ id: "free", name: "Free", auto_enable: true, items: [{ feature_id: "executions", included: 10000 }] },
{
id: "team",
name: "Team",
price: { amount: 150, interval: "month" },
items: [{ feature_id: "executions", included: 250000 }],
},
],
});
httpServer = serve({ fetch: app.fetch, port: PORT });
autumn = new Autumn({ secretKey: "am_test_emulate", serverURL: BASE });
});

afterAll(async () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
});

// The SDK types the expanded field as `any`, so read it through a narrow shape.
interface Card {
id: string;
type: string;
card: { brand: string; last4: string; exp_month: number; exp_year: number };
}

const cardOnFile = async (customerId: string): Promise<Card | null> => {
const customer = await autumn.customers.getOrCreate({ customerId, expand: ["payment_method"] });
// Real Autumn omits the key entirely when no card is on file, even when expanded.
return (customer.paymentMethod ?? null) as Card | null;
};

describe("autumn emulator: setup_payment (change the card on file)", () => {
const CUSTOMER = "org_setup";

it("setup_payment returns a hosted setup URL", async () => {
const res = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL });
expect(res.customerId).toBe(CUSTOMER);
expect(res.url, "a hosted setup URL is returned").toContain("/checkout/setup/");

// A fresh customer has no card on file yet.
expect(await cardOnFile(CUSTOMER), "no card before setup").toBeNull();
});

it("the hosted setup page renders a save-card form", async () => {
const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL });
const page = await fetch(url);
expect(page.status).toBe(200);
const html = await page.text();
expect(html, "the form offers to save the card").toContain("Save card");
expect(html, "the page is the payment method update page").toContain("Update payment method");
});

it("submitting the card redirects to success_url but does not yet replace the card", async () => {
const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL });
const sessionId = new URL(url).pathname.split("/").pop()!;

const completed = await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "card_number=5555 5555 5555 4444&exp=11/31",
redirect: "manual",
});
expect(completed.status, "completion redirects").toBe(302);
expect(completed.headers.get("location"), "back to the app").toBe(SUCCESS_URL);

// The webhook has not landed: the customer still has the OLD card (none).
expect(await cardOnFile(CUSTOMER), "card unchanged before settle").toBeNull();

const settled = await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" });
expect(settled.ok).toBe(true);
expect(await settled.json()).toEqual({ settled: 1 });

const card = await cardOnFile(CUSTOMER);
expect(card?.type).toBe("card");
expect(card?.id, "a Stripe-style payment method id").toMatch(/^pm_emulate_/);
expect(card?.card).toMatchObject({ brand: "mastercard", last4: "4444", exp_month: 11, exp_year: 2031 });
});

it("settling an unknown setup session 404s", async () => {
const res = await fetch(`${BASE}/checkout/setup/seti_emulate_nope/settle`, { method: "POST" });
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: "not_found" });
});

it("get_or_create omits payment_method when expand does not ask for it", async () => {
const customer = (await autumn.customers.getOrCreate({ customerId: CUSTOMER })) as Record<string, unknown>;
expect("paymentMethod" in customer, "field is absent without expand").toBe(false);
});

// Real Autumn's setup-checkout webhook handler reads the customer's existing
// default payment method first and re-sets that same card when one exists,
// so a second setup session cannot change the card. Confirmed live: a visa
// 4242 stayed default after a mastercard 4444 was saved through setup.
it("a second setup session settles but leaves the existing card in place", async () => {
const before = await cardOnFile(CUSTOMER);
expect(before?.card, "the first setup left a mastercard on file").toMatchObject({ last4: "4444" });

const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL });
const sessionId = new URL(url).pathname.split("/").pop()!;
await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "card_number=4242 4242 4242 4242&exp=01/33",
redirect: "manual",
});
const settled = await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" });
expect(await settled.json(), "the session still settles").toEqual({ settled: 1 });

const after = await cardOnFile(CUSTOMER);
expect(after?.card, "the old card is still the default").toMatchObject({ brand: "mastercard", last4: "4444" });
expect(after?.id, "and it is the same payment method").toBe(before?.id);
});

it("paying for a plan leaves a visa on file", async () => {
const PAYING = "org_pays";
const attach = await autumn.billing.attach({ customerId: PAYING, planId: "team", successUrl: SUCCESS_URL });
const sessionId = new URL(attach.paymentUrl!).pathname.split("/").pop()!;
await fetch(`${BASE}/checkout/${sessionId}/complete`, { method: "POST", redirect: "manual" });
await fetch(`${BASE}/checkout/settle`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ customer_id: PAYING }),
});

const card = await cardOnFile(PAYING);
expect(card?.card).toMatchObject({ brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 });
});
});

describe("autumn emulator: billing portal (change the card on file)", () => {
const CUSTOMER = "org_portal";
const RETURN_URL = `${BASE}/settings/billing`;

const openPortal = async (): Promise<string> => {
const res = await autumn.billing.openCustomerPortal({ customerId: CUSTOMER, returnUrl: RETURN_URL });
expect(res.customerId).toBe(CUSTOMER);
return res.url;
};

it("the portal page shows the card on file and links back to the app", async () => {
// Give the customer a card the only supported way: a first setup session.
const setup = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL });
const sessionId = new URL(setup.url).pathname.split("/").pop()!;
await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "card_number=4242 4242 4242 4242&exp=12/30",
redirect: "manual",
});
await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" });

const url = await openPortal();
expect(url, "the portal URL points at the hosted page").toContain(`/checkout/portal/${CUSTOMER}`);

const page = await fetch(url);
expect(page.status).toBe(200);
const html = await page.text();
expect(html, "the portal is identified").toContain("Billing portal");
expect(html, "the current card brand").toContain("visa");
expect(html, "the current card last4").toContain("4242");
expect(html, "the update form").toContain("Update payment method");
expect(html, "the return_url recorded when the portal was opened").toContain(`href="${RETURN_URL}"`);
});

it("updating the card in the portal applies immediately, with no settle step", async () => {
const res = await fetch(`${BASE}/checkout/portal/${CUSTOMER}/payment-method`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "card_number=5555 5555 5555 4444&exp=11/31",
redirect: "manual",
});
expect(res.status, "the portal redirects back to itself").toBe(302);
expect(res.headers.get("location")).toBe(`/checkout/portal/${CUSTOMER}`);

// No settle: Stripe owns the portal and swaps the default synchronously.
const card = await cardOnFile(CUSTOMER);
expect(card?.card).toMatchObject({ brand: "mastercard", last4: "4444", exp_month: 11, exp_year: 2031 });

const html = await (await fetch(`${BASE}/checkout/portal/${CUSTOMER}`)).text();
expect(html, "the page reflects the new card").toContain("mastercard");
});

it("the portal page 404s for an unknown customer", async () => {
const res = await fetch(`${BASE}/checkout/portal/org_does_not_exist`);
expect(res.status).toBe(404);
expect(await res.text()).toContain("Customer not found");
});

it("the return link is omitted when the portal was opened without a return_url", async () => {
const NO_RETURN = "org_portal_bare";
const res = await autumn.billing.openCustomerPortal({ customerId: NO_RETURN });
const html = await (await fetch(res.url)).text();
expect(html, "no card yet").toContain("No payment method");
expect(html, "no return link").not.toContain("Return to");
});
});
41 changes: 41 additions & 0 deletions packages/@emulators/autumn/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export interface AutumnCustomer extends Entity {
/** Plan ids whose free trial this customer has already consumed. Once used,
* the plan's `trial_available` flips to false (Autumn offers a trial once). */
trials_used?: string[];
/** The customer's default payment method, as Stripe holds it. Only returned
* by `customers.get_or_create` when `expand` asks for `payment_method`. */
payment_method?: AutumnPaymentMethod | null;
}

export interface AutumnTrackEvent extends Entity {
Expand Down Expand Up @@ -64,3 +67,41 @@ export interface AutumnCheckout extends Entity {
* to `settled` (webhook processed, subscription active). */
status: "pending" | "completed" | "settled";
}

/** A Stripe PaymentMethod as Autumn surfaces it on an expanded customer. Only
* the card fields an application realistically renders are modelled. */
export interface AutumnPaymentMethod {
id: string;
type: "card";
card: { brand: string; last4: string; exp_month: number; exp_year: number };
}

/** A Stripe Checkout session in `mode: "setup"`, opened by
* `billing.setup_payment` so a customer can put a card on file. The hosted
* page captures a card and redirects to `success_url`, but the customer's
* default payment method only changes once the asynchronous
* `checkout.session.completed` webhook is processed, modelled here by
* `settle` (the same race as the checkout flow above). Settling only sets the
* default when the customer has no card yet; see `settleSetup` in
* routes/checkout.ts for why replacing a card needs the billing portal. */
export interface AutumnSetupSession extends Entity {
session_id: string;
customer_id: string;
success_url: string;
/** `pending` (setup open) to `completed` (card captured, webhook in flight)
* to `settled` (webhook processed). */
status: "pending" | "completed" | "settled";
/** The card captured when the hosted page was submitted. It becomes the
* customer's default at settle only when the customer had no card. */
payment_method?: AutumnPaymentMethod;
}

/** A Stripe billing portal session, opened by `billing.open_customer_portal`.
* Only the `return_url` matters to an application under test: the hosted
* portal page renders a link back to it. Stripe portal sessions are
* single-use and short-lived; the emulator keeps them so the page can find
* the most recent one for a customer. */
export interface AutumnPortalSession extends Entity {
customer_id: string;
return_url: string;
}
Loading
Loading