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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ Image tags:

Images are published for `linux/amd64` and `linux/arm64`.

## Reverse Proxy Base Path

Billtap can run behind a shared browser origin such as
`https://localhost:8081/billtap` while keeping internal service-to-service calls
on the unprefixed container URL, such as `http://billtap:8080`.

Set one of these before building or starting Billtap:

```bash
PUBLIC_BASE_PATH=/billtap
# or, to override only this app when a shared stack sets PUBLIC_BASE_PATH:
BILLTAP_PUBLIC_BASE_PATH=/billtap
```

Set `BILLTAP_PUBLIC_BASE_URL` to the browser-visible origin without the path
prefix when Billtap generates hosted checkout and portal URLs:

```bash
BILLTAP_PUBLIC_BASE_URL=https://localhost:8081 \
PUBLIC_BASE_PATH=/billtap \
go run ./cmd/billtap
```

The server also honors `X-Forwarded-Prefix`, so a proxy can strip `/billtap`
before forwarding while Billtap still generates prefixed `Location` headers and
hosted URLs. Browser links, static assets, dashboard API calls, and Stripe-like
calls are prefix-aware:

```text
/billtap/app/dashboard/
/billtap/app/assets/...
/billtap/api/diagnostics
/billtap/v1/customers
```

## Fixture And Assertion APIs

Billtap includes local integration-test helpers:
Expand Down
7 changes: 5 additions & 2 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ documentation before it counts as implemented.

## Supported Stripe-Like API Subset

Base path: `/v1`
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.

| Resource | Endpoints | Level | Scope |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -97,7 +100,7 @@ Base path: `/v1`
| Prices | `POST /v1/prices`, `GET /v1/prices`, `GET /v1/prices/{id}`, `POST /v1/prices/{id}`, `GET /v1/prices/search` | Supported | Create, list, retrieve, update, and search prices. Supports `product`, `currency`, `unit_amount`, `lookup_key`, recurring interval fields, `active`, and metadata. List supports `product`, `active`, `type=recurring`, and `type=one_time`. Search supports a measured Stripe Search Query Language subset for `active`, `type`, `lookup_key`, and metadata equality clauses joined by `AND`, including one-time price lookup by `lookup_key`. |
| Coupons | `POST /v1/coupons`, `GET /v1/coupons`, `GET /v1/coupons/{id}`, `POST /v1/coupons/{id}`, `DELETE /v1/coupons/{id}` | Partial | Create/list/retrieve local coupon evidence for SDK and fixture smoke paths. Amount-off, percent-off, duration, metadata, and deletion markers are modeled. A bounded single-discount subset applies coupons to customer defaults, checkout sessions, subscriptions, invoice preview math, and renewal invoices; redemption limits and expiration analytics are not modeled. |
| Promotion codes | `POST /v1/promotion_codes`, `GET /v1/promotion_codes`, `GET /v1/promotion_codes/{id}`, `POST /v1/promotion_codes/{id}` | Partial | Create/list/retrieve local promotion-code evidence linked to a local coupon. List supports `code`, `coupon`, `customer`, and `active` filters. Promotion codes can be applied through `discounts[0][promotion_code]`; minimum amounts, expiration rules, and promotion analytics are not modeled. |
| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. |
| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. `PUBLIC_BASE_PATH`, `BILLTAP_PUBLIC_BASE_PATH`, and `X-Forwarded-Prefix` are reflected in hosted browser URLs. |
| Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. |
| Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. |
| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, `cancel_at_period_end`, and `pause_collection` evidence; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. |
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ This directory contains the public product, architecture, testing, and release n

- `decisions/0001-project-shape.md`
- `decisions/0002-stack.md`
- `decisions/0003-public-base-path.md`

Internal adoption notes and original company-specific validation material are preserved locally under `.private/`, which is intentionally ignored by Git.
5 changes: 5 additions & 0 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ release notes whenever compatibility coverage changes.
- Start Billtap from the local binary with `BILLTAP_STATIC_DIR=dist/app`.
- Start the sample app with `PORT=3300 npm --prefix examples/sample-app start` before CLI scenario smoke checks.
- Open `/app/dashboard/`, `/app/checkout/`, and `/app/portal/`.
- Start Billtap with `PUBLIC_BASE_PATH=/billtap` and confirm
`/billtap/app/dashboard/`, `/billtap/api/diagnostics`, `/billtap/v1/customers`,
and a generated checkout session URL are all prefix-aware.
- Send a request through a proxy or curl with `X-Forwarded-Prefix: /billtap` and
confirm redirect `Location` headers include `/billtap`.
- Create customer, product, price, and checkout session through `/v1`.
- Complete checkout through `/api/checkout/sessions/{id}/complete`.
- Verify `/api/timeline`, `/api/objects`, and `/api/delivery-attempts`.
Expand Down
47 changes: 47 additions & 0 deletions docs/decisions/0003-public-base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Decision 0003: Browser-Facing Public Base Path

## Status

Accepted

## Context

Billtap may run behind a shared reverse proxy where the browser-visible URL is
path-prefixed, for example `https://localhost:8081/billtap`, while internal
container traffic still uses the service URL `http://billtap:8080`. Requiring
the proxy to rewrite HTML and JavaScript assets is brittle and makes local
multi-app stacks harder to reason about.

## Decision

Billtap supports a browser-facing public base path through:

- `PUBLIC_BASE_PATH`, a generic multi-app stack variable
- `BILLTAP_PUBLIC_BASE_PATH`, a Billtap-specific override
- `X-Forwarded-Prefix`, for proxies that strip the prefix before forwarding

The configured or forwarded path is used for:

- app links and static asset URLs
- dashboard API calls
- Stripe-like `/v1` browser calls
- redirect `Location` headers
- hosted checkout and portal URLs

`BILLTAP_PUBLIC_BASE_URL` remains the browser-visible origin. If both
`BILLTAP_PUBLIC_BASE_URL=https://localhost:8081` and
`PUBLIC_BASE_PATH=/billtap` are configured, generated hosted URLs use
`https://localhost:8081/billtap/...`.

Internal service-to-service traffic remains unprefixed and should continue to
use URLs such as `http://billtap:8080/v1`.

## Consequences

The Vite app is built with `/app/` under the public base path, so assets resolve
without proxy rewrites. Server routes accept both prefixed and unprefixed paths
when a configured public base path is present, which preserves internal compose
traffic while allowing browser requests under the prefix.

The public base path is validated as a URL path. Full URLs, query strings,
fragments, dot path segments, and empty path segments are rejected.
40 changes: 40 additions & 0 deletions docs/runbooks/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,46 @@ go run ./cmd/billtap
Billtap from a container or private network name, but the browser needs checkout
and portal URLs that resolve from the host.

## Shared Reverse Proxy Path

Use `PUBLIC_BASE_PATH` when Billtap is mounted below a shared browser origin:

```bash
PUBLIC_BASE_PATH=/billtap \
BILLTAP_PUBLIC_BASE_URL=https://localhost:8081 \
go run ./cmd/billtap
```

`BILLTAP_PUBLIC_BASE_PATH` is also supported and takes precedence over
`PUBLIC_BASE_PATH` when a multi-app stack needs a Billtap-specific override.
The path must be a URL path, not a full URL.

With `PUBLIC_BASE_PATH=/billtap`, browser-facing paths are:

```text
https://localhost:8081/billtap/app/dashboard/
https://localhost:8081/billtap/app/checkout/
https://localhost:8081/billtap/app/portal/
https://localhost:8081/billtap/api/diagnostics
https://localhost:8081/billtap/v1/customers
```

Internal compose traffic should continue to use the service URL:

```text
http://billtap:8080/v1
```

If the reverse proxy strips the prefix before forwarding, pass:

```text
X-Forwarded-Prefix: /billtap
```

Billtap uses that header for redirects, hosted checkout and portal URLs, and
browser-visible links. This avoids HTML or JavaScript response rewrites in the
proxy.

## Docker

```bash
Expand Down
32 changes: 25 additions & 7 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6444,22 +6444,40 @@ func absoluteURL(r *http.Request, path string, publicBase string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if path == "" {
path = "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if publicBase != "" {
if path == "" {
return publicBase
}
if strings.HasPrefix(path, "/") {
return publicBase + path
}
return publicBase + "/" + path
return publicBase + path
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if prefix := requestForwardedPrefix(r); prefix != "" {
path = prefix + path
}
return scheme + "://" + r.Host + path
}

func requestForwardedPrefix(r *http.Request) string {
raw := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Prefix"), ",")[0])
if raw == "" || raw == "/" || strings.Contains(raw, "://") || strings.ContainsAny(raw, "?#") {
return ""
}
if !strings.HasPrefix(raw, "/") {
raw = "/" + raw
}
raw = strings.TrimRight(raw, "/")
if strings.Contains(raw, "//") || strings.Contains(raw, "/../") || strings.HasSuffix(raw, "/..") {
return ""
}
return raw
}

func (h *Handler) emitCheckoutWebhooks(r *http.Request, result map[string]any) []webhooks.Event {
if h.webhooks == nil {
return nil
Expand Down
49 changes: 49 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"fmt"
"os"
"strconv"
"strings"
)

const (
envConfigPath = "BILLTAP_CONFIG"
envAddr = "BILLTAP_ADDR"
envDatabaseURL = "BILLTAP_DATABASE_URL"
envStaticDir = "BILLTAP_STATIC_DIR"
envPublicBasePath = "PUBLIC_BASE_PATH"
envBilltapPublicBasePath = "BILLTAP_PUBLIC_BASE_PATH"
envPublicBaseURL = "BILLTAP_PUBLIC_BASE_URL"
envEnvironment = "BILLTAP_ENV"
envRelayMode = "BILLTAP_RELAY_MODE"
Expand All @@ -34,6 +37,7 @@ type Config struct {
Addr string `json:"addr"`
DatabaseURL string `json:"database_url"`
StaticDir string `json:"static_dir"`
PublicBasePath string `json:"public_base_path"`
PublicBaseURL string `json:"public_base_url"`
Environment string `json:"environment"`
RelayMode bool `json:"relay_mode"`
Expand Down Expand Up @@ -90,6 +94,12 @@ func LoadWithLookup(path string, lookup LookupFunc) (Config, error) {
if value, ok := lookup(envStaticDir); ok {
cfg.StaticDir = value
}
if value, ok := lookup(envPublicBasePath); ok {
cfg.PublicBasePath = value
}
if value, ok := lookup(envBilltapPublicBasePath); ok {
cfg.PublicBasePath = value
}
if value, ok := lookup(envPublicBaseURL); ok {
cfg.PublicBaseURL = value
}
Expand Down Expand Up @@ -118,6 +128,11 @@ func LoadWithLookup(path string, lookup LookupFunc) (Config, error) {
if cfg.RelayMode {
cfg.RawPayloadStorage = RawPayloadMetadataOnly
}
publicBasePath, err := ValidatePublicBasePath(cfg.PublicBasePath)
if err != nil {
return Config{}, err
}
cfg.PublicBasePath = publicBasePath

if err := cfg.Validate(); err != nil {
return Config{}, err
Expand All @@ -135,6 +150,9 @@ func (c Config) Validate() error {
if c.StaticDir == "" {
return errors.New("static_dir is required")
}
if _, err := ValidatePublicBasePath(c.PublicBasePath); err != nil {
return err
}
if c.Environment == "" {
return errors.New("environment is required")
}
Expand Down Expand Up @@ -181,6 +199,9 @@ func merge(base Config, override Config) Config {
if override.StaticDir != "" {
base.StaticDir = override.StaticDir
}
if override.PublicBasePath != "" {
base.PublicBasePath = override.PublicBasePath
}
if override.PublicBaseURL != "" {
base.PublicBaseURL = override.PublicBaseURL
}
Expand All @@ -205,6 +226,34 @@ func merge(base Config, override Config) Config {
return base
}

func NormalizePublicBasePath(value string) string {
normalized, _ := ValidatePublicBasePath(value)
return normalized
}

func ValidatePublicBasePath(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || value == "/" {
return "", nil
}
if strings.Contains(value, "://") || strings.ContainsAny(value, "?#") {
return "", errors.New("public_base_path must be a URL path, not a full URL")
}
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
value = strings.TrimRight(value, "/")
for _, part := range strings.Split(value, "/") {
if part == ".." || part == "." {
return "", errors.New("public_base_path cannot contain dot path segments")
}
}
if strings.Contains(value, "//") {
return "", errors.New("public_base_path cannot contain empty path segments")
}
return value, nil
}

func parseBool(value string) bool {
switch value {
case "1", "true", "TRUE", "True", "yes", "YES", "on", "ON":
Expand Down
Loading
Loading