# Worked example: tallyhall, before adding a refund window

A fictional FastAPI ticketing service. The README is two releases stale, one rule has a second copy, and one module is generated.

## Input given to the skill

> I've just joined and my first ticket is "refunds must be requested within 14 days of the event ending". I've never opened this repo. Get me oriented. The repo is the current directory.

The files that mattered, with the line numbers the tour cites:

```text
README.md
  5   ## Run it
  7       make dev
 12   Settings live in `settings.toml`.

Makefile
  1   .PHONY: run test lint
  3   run:
  4   	uvicorn tallyhall.main:app --reload
  7   test:
  8   	pytest -q -m "not e2e"
 10   lint:
 11   	ruff check tallyhall tests

.github/workflows/ci.yml
 17         - run: pip install -e ".[dev]"
 19         - run: make lint
 21         - run: make test
 23     env:
 24       TALLYHALL_DB_URL: sqlite:///ci.db

pyproject.toml
 14   [tool.pytest.ini_options]
 15   markers = ["e2e: needs the payment sandbox"]

tallyhall/main.py
  6   app = FastAPI()
  7   app.include_router(refunds.router)

tallyhall/api/refunds.py
 18   @router.post("/orders/{order_id}/refunds", status_code=201)
 19   def create_refund(order_id: int, db=Depends(get_db)):
 20       order = orders.get(db, order_id)
 22       check_refundable(order, now=utcnow())
 24       except RefundRejected as e:
 25           raise HTTPException(422, {"code": e.code, "message": str(e)})
 27       return refunds.create(db, order)

tallyhall/rules/refunds.py
  4   class RefundRejected(Exception):
  9   def check_refundable(order, now):
 10       if order.status != "paid":
 11           raise RefundRejected("not_paid", "only paid orders can be refunded")
 12       if order.refunded_at is not None:
 13           raise RefundRejected("already_refunded", "order already refunded")

tallyhall/admin/bulk_refund.py
 27       if order.status == "paid" and order.refunded_at is None:
 28           refunds.create(db, order)

tallyhall/models_gen.py
  1   # generated by datamodel-codegen:
  2   #   filename:  schemas/order.json

tallyhall/settings.py
  6   DB_URL = os.environ["TALLYHALL_DB_URL"]

tests/rules/test_refunds.py   (test_rejects_unpaid, test_rejects_already_refunded)
tests/api/test_refunds_e2e.py (marked e2e)
```

The fact lister also reported: `make dev` named at `README.md:7` with no `dev` target in the Makefile, and `settings.toml` named at `README.md:12` and not present. No test file imports `tallyhall/admin/bulk_refund.py`. The Order model (`tallyhall/models_gen.py`) has an `event_ends_at` field.

---

## Output

**What matters for this change**

- The rule goes in `check_refundable` (`tallyhall/rules/refunds.py:9`). Every other refund rule lives there, and the API calls it at `tallyhall/api/refunds.py:22`.
- There is a second copy of the refund rules in the support bulk-refund tool (`tallyhall/admin/bulk_refund.py:27`). It doesn't call `check_refundable`, so it won't pick up the window unless someone changes it. Whether it should is the first question for a maintainer.
- Ignore the README's `make dev` and `settings.toml`. Neither exists. Run with `make run` (`Makefile:3`); settings come from env vars (`tallyhall/settings.py:6`).
- `tallyhall/models_gen.py` is generated from `schemas/order.json`. Don't edit it. The field the window needs, `event_ends_at`, is already there.
- `make test` skips the e2e tests (`Makefile:8`, marker at `pyproject.toml:15`). The rule tests in `tests/rules/` do run.

# Tour: tallyhall, for "refunds within 14 days of the event ending"

_Read on 2026-10-20. Scope: `POST /orders/{order_id}/refunds` from the app bootstrap to `refunds.create`. Skipped on purpose: ticket purchase, the payment-provider client, deployment config._

[assumption] "Within 14 days of the event ending" means `now - order.event_ends_at <= 14 days`, with the boundary day allowed.

## Run it

| To | Command | Source | Status |
|---|---|---|---|
| Install | `pip install -e ".[dev]"` | `.github/workflows/ci.yml:17` | read, not run |
| Test (whole suite) | `make test` → `pytest -q -m "not e2e"` | `.github/workflows/ci.yml:21`, `Makefile:8` | ran: passed (14 passed, 3 deselected) |
| Test (this area) | `pytest -q -m "not e2e" tests/rules/test_refunds.py` | derived from `Makefile:8` | ran: passed (2 passed) |
| Lint | `make lint` → `ruff check tallyhall tests` | `.github/workflows/ci.yml:19`, `Makefile:11` | ran: passed |
| Run locally | `make run` | `Makefile:3` | read, not run (needs `TALLYHALL_DB_URL`) |

Needs: `TALLYHALL_DB_URL`, read at `tallyhall/settings.py:6` with no default; CI sets `sqlite:///ci.db` at `.github/workflows/ci.yml:24`.

## The path this change touches

1. `tallyhall/main.py:7`: the refunds router is mounted on the app.
2. `tallyhall/api/refunds.py:19` `create_refund`: loads the order (line 20).
3. `tallyhall/api/refunds.py:22`: calls `check_refundable(order, now=utcnow())`.
4. `tallyhall/rules/refunds.py:9` `check_refundable`: raises `RefundRejected` for an unpaid order (line 10) or one already refunded (line 12).
5. `tallyhall/api/refunds.py:25`: `RefundRejected` becomes a 422 with `{code, message}`.
6. `tallyhall/api/refunds.py:27`: `refunds.create` writes the refund. This is the far edge.

## Where the change goes

- `tallyhall/rules/refunds.py` `check_refundable`: add the window check after line 13, raising `RefundRejected("refund_window_closed", …)`. This follows the two rules already there.
- Must change with it: nothing generated. `event_ends_at` already exists in `tallyhall/models_gen.py`. Whether `tallyhall/admin/bulk_refund.py:27` changes too is open (question 1).

## Conventions the code follows

- **Errors:** a rule raises `RefundRejected(code, message)` with a snake_case code (`tallyhall/rules/refunds.py:11`); the route maps it to 422 (`tallyhall/api/refunds.py:25`).
- **Time:** the route passes `now` in, so rules are tested without a clock (`tallyhall/api/refunds.py:22`).
- **Tests:** one `test_rejects_<reason>` per rule, under `tests/rules/` (`tests/rules/test_refunds.py`).
- **Config:** env vars read in `tallyhall/settings.py`; a missing var fails at import (`tallyhall/settings.py:6`).

## Traps

- **Generated:** `tallyhall/models_gen.py` from `schemas/order.json` (`tallyhall/models_gen.py:1-2`). No regenerate command is defined in the Makefile, pyproject or CI; see question 2. CI drift check: none found.
- **Second copy:** `tallyhall/admin/bulk_refund.py:27` repeats the paid and not-refunded checks inline.
- **Untested on the path:** none on the API path. Off the path, `tallyhall/admin/bulk_refund.py` has no test.
- **Tests the default command skips:** `e2e` (`Makefile:8`, `pyproject.toml:15`): `tests/api/test_refunds_e2e.py`, which needs the payment sandbox.
- **Env vars and flags on the path:** `TALLYHALL_DB_URL` only. No feature flags found.
- **Vendored:** none.

## Where the README is wrong

- README says `make dev` (`README.md:7`); the Makefile has `run`, `test` and `lint` only (`Makefile:1`). Use `make run`.
- README says settings live in `settings.toml` (`README.md:12`); there is no such file, and settings are env vars (`tallyhall/settings.py:6`).

## Tests for this area

- Covered by: `tests/rules/test_refunds.py` (unpaid and already-refunded rejections).
- Run just these: `pytest -q -m "not e2e" tests/rules/test_refunds.py` (derived from `Makefile:8`).
- Not covered: the 422 mapping is exercised only by the e2e test, which `make test` skips.

## First step

Add `test_rejects_refund_after_window` to `tests/rules/test_refunds.py`, with an order whose `event_ends_at` is 15 days before `now`. Run `pytest -q -m "not e2e" tests/rules/test_refunds.py`. It fails until `check_refundable` checks the window. Add a 14-day case that passes, to pin the boundary.

## Open questions for a maintainer

1. Should the bulk-refund tool (`tallyhall/admin/bulk_refund.py:27`) apply the window, or is it the support override that skips it?
2. How is `tallyhall/models_gen.py` regenerated? Nothing in the repo defines the command.
3. Is the window measured from `event_ends_at` in UTC? `utcnow()` is used at `tallyhall/api/refunds.py:22`, but the schema doesn't say what timezone the event times are in.

---

Why this passes the bar: every command has a source and a status, both README claims are contradicted with citations, the second copy is named and left as a question rather than silently changed or ignored, the generated file is called out before anyone edits it, and the first step names a file, a command and the failure that proves it.
