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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- Added multi-workspace support so one running server can hold several fully
isolated billing datasets. Requests select a workspace with the
`X-Billtap-Workspace` header or `workspace` query parameter, unselected
requests keep using the backward-compatible `default` workspace, named
workspaces open their own SQLite database lazily under `workspaces/`, and
`GET /workspaces` lists the known workspaces.
- Added a manual invoice-backed one-time payment flow for local SaaS usage
charges, including `POST /v1/invoices`, `POST /v1/invoiceitems`,
`POST /v1/invoices/{id}/finalize`, metadata preservation, expanded
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,37 @@ 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

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.

- Requests with no workspace selector use the `default` workspace, backed by
the configured `database_url`. Existing integrations keep working unchanged.
- Name a workspace 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.

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

# isolated dataset for one test suite
curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers
curl 'http://localhost:8080/v1/customers?workspace=suite-a'

# list known workspaces
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`).

## Fixture And Assertion APIs

Billtap includes local integration-test helpers:
Expand Down
9 changes: 8 additions & 1 deletion cmd/billtap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,16 @@ 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)
}
}()

srv := &http.Server{
Addr: cfg.Addr,
Handler: server.New(server.Options{Config: cfg, Store: store}),
Handler: appServer,
ReadHeaderTimeout: 5 * time.Second,
}

Expand Down
9 changes: 8 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ Initial default:
- SQLite for local app state
- in-memory option for unit tests

Tables:
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):

- customers
- products
Expand Down
5 changes: 5 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ 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

Integration diagnostics for failed app runs:

Expand Down
95 changes: 69 additions & 26 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
Expand All @@ -21,22 +22,33 @@ type Options struct {
}

type Server struct {
cfg config.Config
store storage.Store
mux *http.ServeMux
cfg config.Config
store storage.Store
mux *http.ServeMux
workspaces *workspaceManager
}

func New(opts Options) http.Handler {
func New(opts Options) *Server {
s := &Server{
cfg: opts.Config,
store: opts.Store,
mux: http.NewServeMux(),
}
s.cfg.PublicBasePath = config.NormalizePublicBasePath(s.cfg.PublicBasePath)
s.workspaces = newWorkspaceManager(s.cfg, s.store, s.buildAPIHandler)
s.routes()
return s
}

// Close releases workspace storage opened on demand. The default store passed
// via Options is owned by the caller and is not closed here.
func (s *Server) Close() error {
if s.workspaces == nil {
return nil
}
return s.workspaces.Close()
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
basePath := s.requestBasePath(r)
if basePath == "" {
Expand All @@ -58,28 +70,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) routes() {
if repo, ok := s.store.(billing.Repository); ok {
var webhookService *webhooks.Service
if webhookRepo, ok := s.store.(webhooks.Repository); ok {
webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{
StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly,
RetentionDays: s.cfg.RetentionDays,
SignatureHeaderName: s.cfg.WebhookSignatureHeader,
APIVersion: s.cfg.WebhookAPIVersion,
})
}
var diagnosticsService *diagnostics.Service
if diagnosticsRepo, ok := s.store.(diagnostics.Repository); ok {
diagnosticsService = diagnostics.NewService(diagnosticsRepo)
}
handler := api.New(api.Options{
Billing: billing.NewService(repo),
Webhooks: webhookService,
Diagnostics: diagnosticsService,
PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath),
})
s.mux.Handle("/v1/", handler)
s.mux.Handle("/api/", handler)
if s.workspaces.apiEnabled {
apiHandler := s.workspaces.handler()
s.mux.Handle("/v1/", apiHandler)
s.mux.Handle("/api/", apiHandler)
s.mux.HandleFunc("/workspaces", s.handleWorkspaces)
}
s.mux.HandleFunc("/", s.handleRoot)
s.mux.HandleFunc("/health", s.handleHealth)
Expand All @@ -93,6 +88,54 @@ func (s *Server) routes() {
s.mux.HandleFunc("/assets/", s.handleAssets)
}

// buildAPIHandler assembles the Stripe-like API handler for a single
// workspace store. It is invoked once per workspace by the workspace manager.
func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) {
repo, ok := store.(billing.Repository)
if !ok {
return nil, errors.New("storage backend does not implement the billing repository")
}
var webhookService *webhooks.Service
if webhookRepo, ok := store.(webhooks.Repository); ok {
webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{
StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly,
RetentionDays: s.cfg.RetentionDays,
SignatureHeaderName: s.cfg.WebhookSignatureHeader,
APIVersion: s.cfg.WebhookAPIVersion,
})
}
var diagnosticsService *diagnostics.Service
if diagnosticsRepo, ok := store.(diagnostics.Repository); ok {
diagnosticsService = diagnostics.NewService(diagnosticsRepo)
}
return api.New(api.Options{
Billing: billing.NewService(repo),
Webhooks: webhookService,
Diagnostics: diagnosticsService,
PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath),
}), nil
}

func (s *Server) handleWorkspaces(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
methodNotAllowed(w)
return
}
names := s.workspaces.list()
data := make([]map[string]any, 0, len(names))
for _, name := range names {
data = append(data, map[string]any{
"object": "workspace",
"name": name,
"is_default": name == DefaultWorkspace,
})
}
writeJSON(w, r, http.StatusOK, map[string]any{
"object": "list",
"data": data,
})
}

func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
Expand Down
Loading
Loading