Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Errors are ToolError subclasses in errors.py; the error handler is the only plac
## About this world

StripeAPI is a faithful, stateful, forkable replica of Stripe's Billing and Payments core: 155
routed operations across 24 SQLite tables behind Stripe's own eight-tool MCP surface
routed operations across 25 SQLite tables behind Stripe's own eight-tool MCP surface
(`list_available_accounts_or_orgs`, `stripe_api_search`, `stripe_api_details`, `stripe_api_read`,
`stripe_api_write`, `get_stripe_account_info`, `manage_stripe_accounts`, `stripe_analytics`).
Tools are addressed by `operationId` with `stripe_context` and `livemode` context parameters; on
Expand Down Expand Up @@ -74,8 +74,12 @@ the code carries a comment linking to its entry there.
- **Conformance** is validated by replaying 19 cassettes (committed, redacted) recorded against the
real Stripe API, plus schema conformance of every returned object against `spec3.min.json`. CI
replays only; re-recording needs a test-mode key and `api.stripe.com` egress.
- **Fixtures.** Only `empty` ships today (schema, no rows). The `small` and `large` fixtures are
deferred, as is the eval suite.
- **Fixtures.** Three ship, all frozen at `2026-09-01T14:00:00.000Z`: `empty` (the schema and the
sandbox account, live mode), `small_co` (Wrenfield Labs, a hand-written 45-customer test-mode
account) and `big_co` (Ostrander Systems, a simulated test-mode account of about 380 business
customers, 25 MB or less). The company fixtures are replayed through the world's own code by
`fixtures_src/` and described, with checked queries, in `docs/fixtures.md`. The eval suite is
deferred.
- **This repo is the world checkout**: `pyproject.toml` and `src/seahaven_stripe_world/` at the root, beside
`specs/` and `research/`. The framework is not vendored here: `seahaven~=0.6` resolves to the
PyPI release, and `uv.lock` pins the exact version, so `uv sync` installs the same core every time
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ logged for you to check.

## How real is it?

- **155 Stripe API operations across 24 tables**, pinned to API version `2026-08-26.dahlia`. Every
- **155 Stripe API operations across 25 tables**, pinned to API version `2026-08-26.dahlia`. Every
object shape aligned to Stripe's OpenAPI spec.
- **A billing engine, not just CRUD.** Subscriptions move through all eight statuses. Invoices go
from draft to open to paid, void or uncollectible. Proration, dunning, credit notes and a balance
Expand Down Expand Up @@ -132,8 +132,8 @@ def grade(state) -> float:

async def episode(seed: int) -> float:
async with SeahavenClient(base_url="http://127.0.0.1:8000") as env:
# A private Stripe test-mode account, copied from the `empty` fixture.
await env.reset(fixture="empty", seed=seed, startup={"livemode": False})
# A private Stripe account, copied from the `empty` fixture.
await env.reset(fixture="empty", seed=seed)
tools = await env.list_tools() # the eight Stripe MCP tools, as JSON schemas
await run_agent(env, tools, TASK)
return grade(await env.state())
Expand Down Expand Up @@ -361,7 +361,10 @@ for seed in range(100):
The servers take it too: `env.reset(fixture="pro_plan", ...)` over OpenEnv, and
`uv run serve_http.py --reset-options '{"fixture": "pro_plan", "startup": {"livemode": false}}'`
for the HTTP API. Commit the script that builds a fixture alongside it, so it can be rebuilt.
Ready-made fixtures with richer histories are on the way.
Two ready-made fixtures with real history ship beside `empty`, both test-mode accounts:
`small_co` (Wrenfield Labs, 45 hand-written customers) and `big_co` (Ostrander Systems, about 380
simulated business customers over 18 months). [docs/fixtures.md](docs/fixtures.md) describes each
company and its loose ends.

## What's modelled

Expand Down
768 changes: 768 additions & 0 deletions docs/fixtures.md

Large diffs are not rendered by default.

31 changes: 23 additions & 8 deletions docs/technical.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ and no world default.

## What it covers

**155 routed operations** across 24 SQLite tables, pinned to Stripe API version
**155 routed operations** across 25 SQLite tables, pinned to Stripe API version
`2026-08-26.dahlia`. The operation count is asserted by a test and cannot drift silently.
Discovery covers the 123 operations the real Stripe MCP catalogues (72 routed, 51 catalogued but
unrouted, which answer a product-activation or permission refusal when called); the remaining 471
Expand Down Expand Up @@ -141,9 +141,24 @@ names). Recording needs a test-mode key and `api.stripe.com` egress, and never r

## Fixtures

Only the `empty` fixture ships today -- the schema and nothing else, frozen at
`2026-09-01T14:00:00.000Z`. The `small` and `large` fixtures described in the spec are **deferred**
and not yet built. The eval suite is also deferred.
Three fixtures ship, all frozen at `2026-09-01T14:00:00.000Z`:

| Fixture | What | Mode | File |
|---|---|---|---|
| `empty` | The schema and the sandbox account, nothing else | live | 0.6 MB |
| `small_co` | Wrenfield Labs: 45 hand-written customers of a two-person tutoring-tools company | test | 1.8 MB |
| `big_co` | Ostrander Systems: about 380 simulated business customers of a field-service SaaS company over 18 months | test | 23.7 MB |

A company fixture is opened in the mode its account was made in; asking for the other mode is
refused at instance creation. Each is built by `fixtures_src/generate.py`, which replays its
history through the world's own dispatcher and billing engine at historical instants
(`fixtures_src/replay/`), checks it (`fixtures_src/replay/checks.py`) and freezes it; a rebuild is
byte-identical, and `tests/test_fixtures.py` and `tests/test_small_co.py` prove it. `big_co` builds
in about 25 seconds; its population is scaled to about 380 customers so the file stays at 25 MB or
less (`fixtures_src/big_co/shape.py`). Forking an instance from either takes milliseconds
(`tests/test_fork_cost.py`). [`fixtures.md`](fixtures.md) is the guide for eval writers: each
company's story, catalog and loose ends, with landmark queries that `tests/test_guide.py` checks
against the committed files. The eval suite is deferred.

## Development

Expand Down Expand Up @@ -247,11 +262,11 @@ THIRD_PARTY_LICENSES.md
.github/workflows/ci.yml # the check list, on every push to main and every PR
src/seahaven_stripe_world/
world.py # the World object
startup.py # per-instance account (livemode startup option)
startup.py # the account row -> ctx.state; the mode rule
openenv_app.py # the ASGI app `seahaven serve` builds
errors.py stripe_errors.py # the two error systems
_ids.py _time.py _json.py _seq.py # shared helpers
schema/ # 001-005, DDL for 24 tables + FTS5
schema/ # 001-006, DDL for 25 tables + FTS5
dispatch/ # router, ResourceSpec engine, params, response
resources/ # one module per resource + hand-written actions
billing/ # proration, invoicing, dunning, ledger, lifecycle, magic cards
Expand All @@ -262,8 +277,8 @@ src/seahaven_stripe_world/
middleware/ # error handler, stripe envelope, idempotency
http_api/ # Stripe's HTTP API as a seahaven.http handler
spec/ # spec3.json, spec3.min.json, mcp_catalogue.jsonl, products table
fixtures/empty/ # the one shipped fixture
fixtures_src/ # the generators fixtures are frozen from
fixtures/ # empty, small_co, big_co: the shipped fixtures
fixtures_src/ # the recipe they are frozen from, and the guide check
tools_dev/ # build-time tools: spec pruning, catalogue enumeration, cassette recorder
tests/ # 1,100+ tests
conformance/ # cassette replay, allowed differences
Expand Down
16 changes: 16 additions & 0 deletions fixtures/big_co/fixture.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
created_at: '2026-10-15T20:25:49.876Z'
description: 'Ostrander Systems, a Denver company selling field-service software to
HVAC, plumbing and electrical contractors, as a test-mode Stripe account: about
380 business customers over 18 months, from a migration off a legacy billing system
through sales-led growth, with per-technician seat plans, annual contracts invoiced
on terms, sales tax, renewals, upgrades, churn, dunning, refunds, disputes, credit
notes and daily payouts, plus scripted loose ends. Good for evals that need a realistic
account at scale: pagination, search, aggregation and needle-in-a-haystack lookups.'
file_sha256: b13902ee825d841beb704b534ae435cfc06fbd62767f34ed7637e9bd381d3709
format_version: 1
id: big_co
now: '2026-09-01T14:00:00.000Z'
parent_id: null
schema_hash: e2f582a5828e83c16e123cbf44d4e0344939ba17ee0942b3b338bf5a08eecf66
world: seahaven_stripe_world
world_version: 0.2.0
Binary file added fixtures/big_co/state.sqlite
Binary file not shown.
11 changes: 6 additions & 5 deletions fixtures/empty/fixture.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
created_at: '2026-09-22T20:04:03.480Z'
description: Schema only. Start here to write history, or to test setup flows.
file_sha256: c32f6a55fe05be97ddd4cf324350af5518a86256e3b3f7b86f3858271190716b
created_at: '2026-10-15T20:25:27.258Z'
description: The schema and the sandbox account, in live mode, with no history. Start
here to write history, or to test setup flows.
file_sha256: 33c4999fbf5b2c3ae954678838ddb41e00d0fa656dae82d6dec3aa11d770865a
format_version: 1
id: empty
now: '2026-09-01T14:00:00.000Z'
parent_id: null
schema_hash: db47e63cc10be6773ea1c35a9519e93eb9a145afe0de17d14caf521bf58e87f0
schema_hash: e2f582a5828e83c16e123cbf44d4e0344939ba17ee0942b3b338bf5a08eecf66
world: seahaven_stripe_world
world_version: 0.1.0
world_version: 0.2.0
Binary file modified fixtures/empty/state.sqlite
Binary file not shown.
14 changes: 14 additions & 0 deletions fixtures/small_co/fixture.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
created_at: '2026-10-15T20:53:40.387Z'
description: 'Wrenfield Labs, a two-person booking and payments tool for independent
tutors, music teachers and coaches, as a test-mode Stripe account: a hand-written
cast of about 45 customers from a private beta through a public launch, with renewals,
a failed payment in dunning, refunds and a dispute. Good for evals that need a small
account whose every customer has a story.'
file_sha256: b281e3636bb201a0ca2a20c3032be3f627d3010509b931e306407f768d78d4d3
format_version: 1
id: small_co
now: '2026-09-01T14:00:00.000Z'
parent_id: null
schema_hash: e2f582a5828e83c16e123cbf44d4e0344939ba17ee0942b3b338bf5a08eecf66
world: seahaven_stripe_world
world_version: 0.2.0
Binary file added fixtures/small_co/state.sqlite
Binary file not shown.
2 changes: 2 additions & 0 deletions fixtures_src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Fixture authoring source: the recipe (`generate.py`), the replay engine (`replay/`), and the
company builders (`small_co/`, `big_co/`). Uses the world as a client; never part of the world."""
207 changes: 207 additions & 0 deletions fixtures_src/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""The two company accounts (functional spec §2.2; architecture §2.3), and their installation.

A `CompanyAccount` holds only what the company sets: its id, its fields of the account object and
its settings. The rest of the object is the sandbox account the schema seeds
(`schema/006_account.sql`), read from the row when the account is installed, so the SQL seed is
the one copy of it. Each company is an activated US company account, test-mode, with one test bank
account for payouts and a manual payout schedule.
"""

import copy
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, cast

import seahaven
from fixtures_src.replay.timeline import FixtureBuildError

from seahaven_stripe_world import _json, startup

__all__ = ["OSTRANDER", "WRENFIELD", "CompanyAccount", "install_account"]


@dataclass(frozen=True)
class CompanyAccount:
id: str
fields: Mapping[str, Any] # the company's values, nested as in the account object
settings: Mapping[str, Any] # the row's `settings`: ledger and dunning overrides
bank_suffix: str # the external bank account's id is `ba_` + the account fragment + this

@property
def bank_account_id(self) -> str:
"""The payout destination: the account's one external bank account."""
return f"ba_{self.id[5:]}{self.bank_suffix}"

def object_over(self, sandbox: Mapping[str, Any]) -> dict[str, Any]:
"""`sandbox` with this company's fields set over it.

A nested field is set inside the sandbox's object of that name; anything else, an empty
object included, replaces the sandbox's value. A field the sandbox does not have is
refused, so a change to the seed cannot silently leave a company field behind."""
return _set_over(copy.deepcopy(dict(sandbox)), self.fields, "")


def _set_over(base: dict[str, Any], fields: Mapping[str, Any], prefix: str) -> dict[str, Any]:
for key, value in fields.items():
path = f"{prefix}{key}"
if key not in base:
raise FixtureBuildError(f"the sandbox account object has no {path}")
if isinstance(value, Mapping) and isinstance(base[key], dict) and base[key]:
_set_over(base[key], value, f"{path}.")
else:
base[key] = copy.deepcopy(value)
return base


def install_account(ctx: seahaven.Ctx, account: CompanyAccount) -> None:
"""Make the seeded sandbox row `account`, then reload `ctx.state["account"]`, so every id
minted afterwards carries the company's account fragment. `livemode` stays as startup stamped
it. The one row a builder writes directly: it is the fixture's definition, not history."""
row = ctx.db.one("SELECT id, object FROM account")
if row is None or row["id"] != startup.ACCOUNT_ID:
found = None if row is None else row["id"]
raise FixtureBuildError(
f"install_account starts from the seeded sandbox account {startup.ACCOUNT_ID},"
f" not {found!r}"
)
# The table's CHECK guarantees the column holds a JSON object.
obj = account.object_over(cast("dict[str, Any]", _json.loads(row["object"])))
ctx.db.execute(
"UPDATE account SET id = ?, object = ?, settings = ?",
account.id,
_json.dumps(obj),
_json.dumps(dict(account.settings)),
)
ctx.state["account"] = startup.open_account(ctx, livemode=None)


def _bank_account(account_id: str, holder: str, created_suffix: str) -> dict[str, Any]:
"""The one test-mode external bank account; its `ba_` id derives from the account id."""
return {
"account": account_id,
"account_holder_name": holder,
"account_holder_type": "company",
"account_type": None,
"available_payout_methods": ["standard"],
"bank_name": "STRIPE TEST BANK",
"country": "US",
"currency": "usd",
"default_for_currency": True,
"fingerprint": account_id[5:].lower()[::-1],
"future_requirements": {
"currently_due": [],
"errors": [],
"past_due": [],
"pending_verification": [],
},
"id": f"ba_{account_id[5:]}{created_suffix}",
"last4": "6789",
"metadata": {},
"object": "bank_account",
"requirements": {
"currently_due": [],
"errors": [],
"past_due": [],
"pending_verification": [],
},
"routing_number": "110000000",
"status": "new",
}


def _company(
*,
account_id: str,
bank_suffix: str,
business_name: str,
display_name: str,
url: str,
support_email: str,
support_phone: str | None,
support_address: dict[str, Any],
email: str,
timezone: str,
descriptor: str,
descriptor_prefix: str,
created: int,
dunning: dict[str, Any],
) -> CompanyAccount:
fields = {
"id": account_id,
"business_profile": {
"mcc": "5734",
"name": business_name,
"support_address": support_address,
"support_email": support_email,
"support_phone": support_phone,
"url": url,
},
"business_type": "company",
"capabilities": {"card_payments": "active", "transfers": "active"},
"charges_enabled": True,
"payouts_enabled": True,
"details_submitted": True,
"created": created,
"email": email,
"external_accounts": {
"data": [_bank_account(account_id, display_name, bank_suffix)],
"url": f"/v1/accounts/{account_id}/external_accounts",
},
"requirements": {"disabled_reason": None},
"settings": {
"card_payments": {"statement_descriptor_prefix": descriptor_prefix},
"dashboard": {"display_name": display_name, "timezone": timezone},
"payments": {"statement_descriptor": descriptor},
"payouts": {"schedule": {"delay_days": 2, "interval": "manual"}},
},
"tos_acceptance": {"date": created},
}
return CompanyAccount(
id=account_id, fields=fields, settings={"dunning": dunning}, bank_suffix=bank_suffix
)


def _address(city: str, state: str, postal_code: str) -> dict[str, Any]:
return {
"city": city,
"country": "US",
"line1": None,
"line2": None,
"postal_code": postal_code,
"state": state,
}


WRENFIELD = _company(
account_id="acct_1Pq7WrnFd3kT9mZa",
bank_suffix="Wf01Bk",
business_name="Wrenfield",
display_name="Wrenfield Labs",
url="https://wrenfield.app",
support_email="support@wrenfield.app",
support_phone=None,
support_address=_address("Asheville", "NC", "28801"),
email="hello@wrenfield.app",
timezone="America/New_York",
descriptor="WRENFIELD",
descriptor_prefix="WRENFIELD",
created=1756130400, # 2025-08-25T14:00:00Z
dunning={"max_attempts": 8, "window": "2w", "end_behavior": "cancel"},
)

OSTRANDER = _company(
account_id="acct_1OsTr4ndXq8NvK2e",
bank_suffix="Os01Bk",
business_name="Ostrander FieldOps",
display_name="Ostrander Systems",
url="https://ostranderfieldops.com",
support_email="billing@ostranderfieldops.com",
support_phone="+17205550147",
support_address=_address("Denver", "CO", "80202"),
email="finance@ostranderfieldops.com",
timezone="America/Denver",
descriptor="OSTRANDER FIELDOPS",
descriptor_prefix="OSTRANDER",
created=1739199600, # 2025-02-10T15:00:00Z
dunning={"max_attempts": 8, "window": "3w", "end_behavior": "mark_unpaid"},
)
Loading
Loading