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
53 changes: 40 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,37 +167,64 @@ calls are prefix-aware:
The published GHCR image is runtime-prefix safe. You do not need to rebuild the
frontend for each mount path.

## Workspaces
## Run-Scoped Isolation

Billtap can hold several fully isolated billing datasets in one running server,
so parallel test suites do not have to restart Billtap or reset shared state
between runs.
between runs. The Stripe-compatible service URL can include a run scope:

- Requests with no workspace selector use the `default` workspace, backed by
```text
http://billtap:8080/runs/<runId>
```

Stripe SDKs can keep their normal `/v1/...` paths because the SDK appends them
under that base URL.

- Requests with no run selector use the `default` run, backed by
the configured `database_url`. Existing integrations keep working unchanged.
- Name a workspace to get an independent dataset (its own customers, invoices,
- Name a run to get an independent dataset (its own customers, invoices,
webhooks, idempotency keys, and test clocks). It is created on first use.
- Select a workspace with the `X-Billtap-Workspace` request header or the
`workspace` query parameter. The resolved name is echoed on the
`X-Billtap-Workspace` response header.
- Select a run with `/runs/<runId>/...`. The resolved name is echoed on
`X-Billtap-Run-Id` and `X-Billtap-Workspace`.
- For backward compatibility, `X-Billtap-Workspace` and the `workspace` query
parameter still select the same isolated run on unprefixed requests.
- `DELETE /runs/<runId>` removes that run's dataset. `GET /admin/runs` lists
known runs and row-count summaries.

```bash
# default workspace (backward compatible)
# default run (backward compatible)
curl http://localhost:8080/v1/customers

# isolated dataset for one test suite
curl http://localhost:8080/runs/suite-a/v1/customers
curl http://localhost:8080/runs/suite-a/v1/webhook_endpoints

# legacy workspace selectors, mapped to runs
curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers
curl 'http://localhost:8080/v1/customers?workspace=suite-a'

# list known workspaces
# list and clean up runs
curl http://localhost:8080/admin/runs
curl -X DELETE http://localhost:8080/runs/suite-a

# legacy listing alias
curl http://localhost:8080/workspaces
```

Workspace names accept letters, digits, `.`, `-`, and `_`, must start with a
letter or digit, and are case-insensitive. Each named workspace is stored next
to the default database under a `workspaces/` directory (for example
`.billtap/workspaces/suite-a.db`).
Run IDs accept letters, digits, `.`, `-`, and `_`, must start with a letter or
digit, and are case-insensitive. Each named run is stored next to the default
database as an isolated SQLite file. For compatibility with earlier Billtap
builds, those files currently live under the existing `workspaces/` directory
(for example `.billtap/workspaces/suite-a.db`).

Fixture packs can also be applied directly to a run:

```bash
go run ./cmd/billtap seed --run-id suite-a --pack seed/sample-basic.yml
```

When the fixture pack has a top-level `runId`, that value is used for the run
scope and fixture metadata.
## Fixture And Assertion APIs

Billtap includes local integration-test helpers:
Expand Down
124 changes: 123 additions & 1 deletion cmd/billtap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/hckim/billtap/internal/billing"
"github.com/hckim/billtap/internal/compatibility"
"github.com/hckim/billtap/internal/config"
"github.com/hckim/billtap/internal/fixtures"
"github.com/hckim/billtap/internal/scenarios"
"github.com/hckim/billtap/internal/server"
"github.com/hckim/billtap/internal/storage"
Expand All @@ -33,6 +34,8 @@ func main() {
os.Exit(runScenario(args[1:]))
case "compatibility":
os.Exit(runCompatibility(args[1:]))
case "seed":
os.Exit(runSeed(args[1:]))
}
}

Expand Down Expand Up @@ -62,7 +65,7 @@ func main() {
appServer := server.New(server.Options{Config: cfg, Store: store})
defer func() {
if err := appServer.Close(); err != nil {
slog.Warn("close workspaces", "error", err)
slog.Warn("close runs", "error", err)
}
}()

Expand All @@ -88,6 +91,125 @@ func main() {
}
}

func runSeed(args []string) int {
packPath, runIDFlag, databaseURL, configPath, err := parseSeedArgs(args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, "usage: billtap seed --pack path [--run-id id] [--database-url dsn] [--config path]")
return scenarios.ExitInvalidConfig
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitInvalidConfig
}
if strings.TrimSpace(databaseURL) != "" {
cfg.DatabaseURL = strings.TrimSpace(databaseURL)
}
body, err := os.ReadFile(packPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitInvalidConfig
}
pack, err := fixtures.LoadPack(body, fixtureContentType(packPath))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitInvalidConfig
}
runID, err := server.NormalizeRunID(firstSeedValue(pack.RunID, runIDFlag, server.DefaultRun))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitInvalidConfig
}
pack.RunID = runID

ctx := context.Background()
store, err := storage.OpenSQLite(ctx, server.RunDSN(cfg.DatabaseURL, runID))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitRuntimeFailure
}
defer func() {
if err := store.Close(); err != nil {
slog.Warn("close seed store", "error", err)
}
}()
result, err := fixtures.NewService(billing.NewService(store)).Apply(ctx, pack)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return scenarios.ExitRuntimeFailure
}
fmt.Fprintf(os.Stdout, "seeded fixture %s runId=%s customers=%d products=%d prices=%d subscriptions=%d test_clocks=%d\n",
result.Name,
result.RunID,
result.Summary["customers"],
result.Summary["products"],
result.Summary["prices"],
result.Summary["subscriptions"],
result.Summary["test_clocks"],
)
return scenarios.ExitPass
}

func parseSeedArgs(args []string) (packPath string, runID string, databaseURL string, configPath string, err error) {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--pack" || arg == "--run-id" || arg == "--database-url" || arg == "--config":
if i+1 >= len(args) {
return "", "", "", "", fmt.Errorf("%s requires a value", arg)
}
i++
switch arg {
case "--pack":
packPath = args[i]
case "--run-id":
runID = args[i]
case "--database-url":
databaseURL = args[i]
case "--config":
configPath = args[i]
}
case strings.HasPrefix(arg, "--pack="):
packPath = strings.TrimPrefix(arg, "--pack=")
case strings.HasPrefix(arg, "--run-id="):
runID = strings.TrimPrefix(arg, "--run-id=")
case strings.HasPrefix(arg, "--database-url="):
databaseURL = strings.TrimPrefix(arg, "--database-url=")
case strings.HasPrefix(arg, "--config="):
configPath = strings.TrimPrefix(arg, "--config=")
case strings.HasPrefix(arg, "-"):
return "", "", "", "", fmt.Errorf("unknown flag %s", arg)
default:
if packPath != "" {
return "", "", "", "", fmt.Errorf("multiple fixture packs provided")
}
packPath = arg
}
}
if strings.TrimSpace(packPath) == "" {
return "", "", "", "", fmt.Errorf("fixture pack path is required")
}
return packPath, runID, databaseURL, configPath, nil
}

func fixtureContentType(path string) string {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") {
return "application/yaml"
}
return "application/json"
}

func firstSeedValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

func runCompatibility(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: billtap compatibility <scorecard|inventory> [flags]")
Expand Down
35 changes: 35 additions & 0 deletions cmd/billtap/main_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package main

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/hckim/billtap/internal/billing"
"github.com/hckim/billtap/internal/scenarios"
"github.com/hckim/billtap/internal/server"
"github.com/hckim/billtap/internal/storage"
)

func TestParseScenarioRunArgsAllowsFlagsAfterFile(t *testing.T) {
Expand Down Expand Up @@ -120,6 +124,37 @@ func TestRunCompatibilityInventoryRequiresOpenAPIPath(t *testing.T) {
}
}

func TestRunSeedUsesFixtureRunIDBeforeFlag(t *testing.T) {
dir := t.TempDir()
packPath := filepath.Join(dir, "seed.yml")
dbPath := filepath.Join(dir, "billtap.db")
writeFile(t, packPath, `
name: seed-pack
runId: yaml-run
customers:
- id: cus_seeded
email: seeded@example.test
`)
code := runSeed([]string{"--pack", packPath, "--run-id", "flag-run", "--database-url", dbPath})
if code != scenarios.ExitPass {
t.Fatalf("exit code = %d, want %d", code, scenarios.ExitPass)
}

ctx := context.Background()
store, err := storage.OpenSQLite(ctx, server.RunDSN(dbPath, "yaml-run"))
if err != nil {
t.Fatalf("open seeded run store: %v", err)
}
defer store.Close()
customers, err := billing.NewService(store).ListCustomers(ctx)
if err != nil {
t.Fatalf("list customers: %v", err)
}
if len(customers) != 1 || customers[0].ID != "cus_seeded" || customers[0].Metadata["billtap_fixture_run_id"] != "yaml-run" {
t.Fatalf("customers = %#v, want fixture run customer", customers)
}
}

func TestRunScenarioReturnsInvalidConfigExitCode(t *testing.T) {
dir := t.TempDir()
scenarioPath := filepath.Join(dir, "bad.yml")
Expand Down
15 changes: 8 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,14 @@ Initial default:
- in-memory option for unit tests

One running server can host several isolated billing datasets. The implicit
`default` workspace is backed by the configured `database_url`; each named
workspace (selected per request via the `X-Billtap-Workspace` header or
`workspace` query parameter) opens its own SQLite database lazily under a
sibling `workspaces/` directory and gets an independent API handler, so its
billing state, webhooks, idempotency keys, and test clocks stay isolated.

Tables (per workspace):
`default` run is backed by the configured `database_url`; each named run
selected through `/runs/<runId>` opens its own SQLite database lazily under the
existing sibling `workspaces/` directory and gets an independent API handler, so
its billing state, webhooks, idempotency keys, and test clocks stay isolated.
The legacy `X-Billtap-Workspace` header and `workspace` query parameter remain
aliases for the same run partition.

Tables (per run):

- customers
- products
Expand Down
18 changes: 14 additions & 4 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ documentation before it counts as implemented.

## Supported Stripe-Like API Subset

Base path: `/v1`. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set,
or a proxy sends `X-Forwarded-Prefix`, the same API is available below that
browser-facing prefix, such as `/billtap/v1`. Internal service traffic can keep
using the unprefixed service URL.
Base path: `/v1`. A parallel test run can scope the same Stripe-like API under
`/runs/<runId>/v1`; unscoped requests use the backward-compatible `default`
run. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, or a proxy
sends `X-Forwarded-Prefix`, the same API is available below that browser-facing
prefix, such as `/billtap/v1` or `/billtap/runs/<runId>/v1`. Internal service
traffic can keep using the unprefixed service URL.

| Resource | Endpoints | Level | Scope |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -120,6 +122,10 @@ using the unprefixed service URL.
| Webhook endpoints | `POST /v1/webhook_endpoints`, `GET /v1/webhook_endpoints`, `GET /v1/webhook_endpoints/{id}`, `POST /v1/webhook_endpoints/{id}`, `PATCH /v1/webhook_endpoints/{id}`, `DELETE /v1/webhook_endpoints/{id}`, `GET /v1/webhook_endpoints/{id}/attempts` | Supported | Manage local webhook endpoints and inspect endpoint-scoped delivery attempts. Secrets are generated when omitted and masked in API responses. `enabled_events` supports exact event names, `*`, and prefix wildcards such as `invoice.*`. `PATCH` accepts the same local mutable fields as `POST`, including the `enabled` alias for `active`. |
| Events | `GET /v1/events`, `GET /v1/events/{id}` | Supported | List and retrieve Billtap-created events. Filters include `type`, `scenarioRunId`, `created[gte]`, `created[gt]`, `created[lte]`, `created[lt]`, `data.object.customer`, and `data.object.metadata[key]`. |

All list and search endpoints are scoped by the selected run. Webhook endpoints
registered through `/runs/<runId>/v1/webhook_endpoints` receive only events
emitted in that run, and local test clocks are isolated the same way.

## Billtap APIs

Base path: `/api`
Expand All @@ -136,6 +142,10 @@ Base path: `/api`
| Scenarios | `POST /api/scenarios/run` | Runs a scenario JSON object or YAML payload and returns the scenario report. |
| Boundary controls | `GET /api/audit-log`, `POST /api/retention/apply` | Audit and retention controls for replay, delivery overrides, and raw evidence redaction. |

Billtap-only `/api` endpoints are also available under `/runs/<runId>/api` for
run-scoped checkout completion, fixture apply/snapshot/assert, diagnostics, and
webhook replay workflows.

## Webhook Compatibility Claim

Billtap emits Stripe-style event envelopes for the supported checkout sequence
Expand Down
11 changes: 6 additions & 5 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ Fixture ergonomics for integration tests:
- assert expected objects through `POST /api/fixtures/assert`
- keep fixture IDs stable for customer, product, and price setup
- use fixture `runId`, `namespace`, `tenantId`, and `ref` metadata to isolate repeated local/CI runs
- for stronger isolation, run parallel suites against separate Billtap
workspaces instead of restarting the server between sets: send
`X-Billtap-Workspace: <name>` (or `?workspace=<name>`) so each suite gets an
independent dataset, while unselected requests keep using the `default`
workspace; `GET /workspaces` lists what exists
- for stronger isolation, run parallel suites against separate Billtap runs
instead of restarting the server between sets: set the Stripe-compatible API
base to `/runs/<runId>` so each suite gets an independent dataset, while
unselected requests keep using the `default` run; `GET /admin/runs` lists
what exists. `X-Billtap-Workspace` and `?workspace=<name>` remain legacy
aliases for unprefixed requests.

Integration diagnostics for failed app runs:

Expand Down
Loading
Loading