carlos / platform Public

Add a tailnet demo of the signup and billing flow with simulated Keymail and Stripe

TestBillingFlowDemo (skipped unless CARLOS_BILLING_DEMO_ADDR, _ORIGIN and
_STOP are set) serves the real console handlers and store on a listener,
with Keymail's passkey ceremony and Stripe's hosted checkout and portal
replaced by labelled simulations on the same host. The console's own
Stripe client and its hosted-URL host check are untouched: the demo
rewrites checkout.stripe.com and billing.stripe.com in the Location header
outside the console. Payment delivers the signed webhooks a real payment
would. Verified by driving sign-in, step-up, checkout, payment, the active
plan, the portal and a failed renewal with curl.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5d1a264322949821a06d31d0b4e1b9c48eb6ab22 parent 9acaf6f
1 file changed, +451 −0
  • internal/console/billingflow_demo_test.go +451 −0

CI passed — run details

diff --git a/internal/console/billingflow_demo_test.go b/internal/console/billingflow_demo_test.go
new file mode 100644
index 000000000..f24cc9fc2
--- /dev/null
+++ b/internal/console/billingflow_demo_test.go
@@ -0,0 +1,451 @@
+package console
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// TestBillingFlowDemo serves a review copy of the signup and billing flow on
+// a real listener: the real console handlers and store, with Keymail and
+// Stripe replaced by clearly labelled simulations that live on the same
+// listener. Nothing external is called and no real account, card or charge
+// exists anywhere. It is a review aid, skipped unless three variables are
+// set:
+//
+// CARLOS_BILLING_DEMO_ADDR listen address, e.g. 0.0.0.0:41881
+// CARLOS_BILLING_DEMO_ORIGIN the URL the reviewer's browser uses,
+// e.g. http://100.119.180.57:41881
+// CARLOS_BILLING_DEMO_STOP a path; the demo ends when the file exists
+//
+// The console's own Stripe client refuses any hosted URL that is not on
+// checkout.stripe.com or billing.stripe.com, and that check is left alone:
+// the demo rewrites those two hosts to its simulated pages in the response
+// Location header, outside the console.
+func TestBillingFlowDemo(t *testing.T) {
+ addr, origin, stop := os.Getenv("CARLOS_BILLING_DEMO_ADDR"), os.Getenv("CARLOS_BILLING_DEMO_ORIGIN"), os.Getenv("CARLOS_BILLING_DEMO_STOP")
+ if addr == "" || origin == "" || stop == "" {
+ t.Skip("set CARLOS_BILLING_DEMO_ADDR, CARLOS_BILLING_DEMO_ORIGIN and CARLOS_BILLING_DEMO_STOP to serve the demo")
+ }
+ origin = strings.TrimRight(origin, "/")
+ regPath, relStore, priv, pub := buildFixtures(t)
+ dir := t.TempDir()
+ st, err := Open(filepath.Join(dir, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ fk.mu.Lock()
+ fk.authTime = time.Now()
+ fk.mu.Unlock()
+ stripe := newDemoStripe(origin)
+ stripeAPI := httptest.NewServer(http.HandlerFunc(stripe.serveAPI))
+ defer stripeAPI.Close()
+
+ srv, err := NewServer(Config{
+ Origin: origin, Keymail: origin + "/__keymail", Registry: regPath,
+ Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http",
+ }, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv.instances.timeout = 100 * time.Millisecond
+ client, err := billingstripe.NewClient("rk_test_demo", billingstripe.WithBaseURL(stripeAPI.URL))
+ if err != nil {
+ t.Fatal(err)
+ }
+ prices := map[string]string{}
+ for _, plan := range billingPaidPlans {
+ for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} {
+ prices[billingPriceKey(plans.CurrentTermsVersion, plan, band)] = "price_" + plan + "_" + band
+ }
+ }
+ srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{
+ MerchantAccount: "acct_demo", PortalConfiguration: "bpc_demo",
+ TaxCodes: map[string]string{plans.PlanPersonal: "txcd_10102001", plans.PlanCommunity: "txcd_10102001", plans.PlanBusiness: "txcd_10102000"},
+ CountryBands: plans.CountryBands(), Prices: prices, WebhookSecrets: []string{demoWebhookSecret},
+ }}
+ stripe.console = srv
+
+ mux := http.NewServeMux()
+ // Simulated Keymail, mounted where the console's Keymail base points.
+ km := http.NewServeMux()
+ km.HandleFunc("GET /.well-known/keymail", fk.handleWellKnown)
+ km.HandleFunc("POST /api/oauth/token", fk.handleToken)
+ km.HandleFunc("POST /api/oauth/revoke", fk.handleRevoke)
+ km.HandleFunc("GET /api/federation/lookup", fk.handleLookup)
+ km.HandleFunc("POST /api/app/send", fk.handleSend)
+ km.HandleFunc("GET /oauth/authorize", func(w http.ResponseWriter, r *http.Request) { demoKeymailAuthorize(w, r, fk) })
+ mux.Handle("/__keymail/", http.StripPrefix("/__keymail", km))
+ mux.HandleFunc("/__stripe/", stripe.servePages)
+ mux.Handle("/", demoRewriteHostedURLs(origin, srv))
+
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+ go hs.Serve(ln) //nolint:errcheck
+ t.Logf("billing flow demo serving on %s as %s", addr, origin)
+ deadline := time.Now().Add(14 * time.Hour)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat(stop); err == nil {
+ break
+ }
+ time.Sleep(5 * time.Second)
+ }
+ hs.Close()
+}
+
+const demoWebhookSecret = "whsec_demo_not_a_secret"
+
+// demoKeymailAuthorize is the simulated Keymail approval page. It stands in
+// for keymail.dev's passkey ceremony: the reviewer types an address and
+// approves, and the fake mints a single-use code for the console to
+// exchange. It serves both the sign-in and the step-up redirect.
+func demoKeymailAuthorize(w http.ResponseWriter, r *http.Request, fk *fakeKeymail) {
+ q := r.URL.Query()
+ if r.URL.Query().Get("approve") == "1" {
+ address := strings.TrimSpace(q.Get("address"))
+ if address == "" || !strings.Contains(address, "@") {
+ address = "paul@keymail.dev"
+ }
+ var b [12]byte
+ rand.Read(b[:]) //nolint:errcheck
+ code := "demo-" + hex.EncodeToString(b[:])
+ fk.mu.Lock()
+ fk.address = address
+ fk.challenge = q.Get("code_challenge")
+ fk.expectCode = code
+ fk.authTime = time.Now()
+ fk.mu.Unlock()
+ back, err := url.Parse(q.Get("redirect_uri"))
+ if err != nil || back.Path == "" {
+ http.Error(w, "bad redirect_uri", http.StatusBadRequest)
+ return
+ }
+ bq := back.Query()
+ bq.Set("code", code)
+ bq.Set("state", q.Get("state"))
+ back.RawQuery = bq.Encode()
+ http.Redirect(w, r, back.String(), http.StatusSeeOther)
+ return
+ }
+ step := "Sign in"
+ if strings.HasSuffix(q.Get("redirect_uri"), "/auth/stepup/callback") {
+ step = "Confirm it is you"
+ }
+ fmt.Fprintf(w, demoPage, "Simulated Keymail", `<p class="sim">SIMULATION. This stands in for the passkey sign-in at keymail.dev. No real Keymail account is touched. Type any address; it becomes the console user.</p>
+<h1>`+step+` with Keymail</h1>
+<form method="get" action="`+html.EscapeString(r.URL.Path)+`">
+`+demoHidden(q, "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "response_type")+`
+<input type="hidden" name="approve" value="1">
+<label>Address <input type="email" name="address" value="paul@keymail.dev"></label>
+<button type="submit">Approve</button>
+</form>`)
+}
+
+func demoHidden(q url.Values, keys ...string) string {
+ var b strings.Builder
+ for _, k := range keys {
+ if v := q.Get(k); v != "" {
+ fmt.Fprintf(&b, `<input type="hidden" name="%s" value="%s">`+"\n", k, html.EscapeString(v))
+ }
+ }
+ return b.String()
+}
+
+// demoRewriteHostedURLs sends the browser to the simulated Stripe pages
+// where the real console would send it to checkout.stripe.com or
+// billing.stripe.com. The console's own host validation is untouched.
+func demoRewriteHostedURLs(origin string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ next.ServeHTTP(&demoLocationWriter{ResponseWriter: w, origin: origin}, r)
+ })
+}
+
+type demoLocationWriter struct {
+ http.ResponseWriter
+ origin string
+}
+
+func (d *demoLocationWriter) WriteHeader(code int) {
+ if loc := d.Header().Get("Location"); loc != "" {
+ for host, page := range map[string]string{"https://checkout.stripe.com/": "/__stripe/checkout/", "https://billing.stripe.com/": "/__stripe/portal/"} {
+ if strings.HasPrefix(loc, host) {
+ d.Header().Set("Location", d.origin+page+strings.TrimPrefix(loc, host))
+ }
+ }
+ }
+ d.ResponseWriter.WriteHeader(code)
+}
+
+// demoStripe is a simulated Stripe: the API the console calls (server side)
+// and the hosted pages the browser sees (checkout, portal), both fake, both
+// labelled. Sessions complete only when the reviewer presses Pay.
+type demoStripe struct {
+ origin string
+ console http.Handler
+ mu sync.Mutex
+ seq int
+ sessions map[string]*demoSession
+ customerCountry string
+ customerSqid string
+ subscription *demoSubscription
+}
+
+type demoSession struct {
+ ID, Status, Payment, SuccessURL, CancelURL, Price, Sqid string
+ Quantity int64
+ Meta map[string]string
+ Expires int64
+}
+
+type demoSubscription struct {
+ ID, Status, Price string
+ Quantity int64
+ Meta map[string]string
+ Start, End int64
+}
+
+func newDemoStripe(origin string) *demoStripe {
+ return &demoStripe{origin: origin, sessions: map[string]*demoSession{}, customerCountry: "IE"}
+}
+
+func demoPlanBand(priceID string) (plan, band string) {
+ parts := strings.Split(strings.TrimPrefix(priceID, "price_"), "_")
+ if len(parts) == 2 {
+ return parts[0], parts[1]
+ }
+ return "", ""
+}
+
+func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ now := time.Now()
+ var out any
+ p := r.URL.Path
+ switch {
+ case p == "/v1/account":
+ out = map[string]string{"id": "acct_demo"}
+ case strings.HasPrefix(p, "/v1/products/prod_"):
+ plan := strings.TrimPrefix(p, "/v1/products/prod_")
+ code := "txcd_10102001"
+ if plan == plans.PlanBusiness {
+ code = "txcd_10102000"
+ }
+ out = map[string]any{"id": "prod_" + plan, "active": true, "livemode": false, "tax_code": code}
+ case strings.HasPrefix(p, "/v1/prices/price_"):
+ plan, band := demoPlanBand(strings.TrimPrefix(p, "/v1/prices/"))
+ def, err := plans.Lookup(plans.CurrentTermsVersion, plan)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ q, err := plans.Quote(plans.CurrentTermsVersion, plan, band, def.MinQuantity)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ out = map[string]any{"id": "price_" + plan + "_" + band, "product": "prod_" + plan, "active": true, "livemode": false, "currency": "eur", "unit_amount": q.AnnualPriceMicroEUR / q.Quantity / 10000, "tax_behavior": "exclusive", "billing_scheme": "per_unit", "metadata": map[string]string{"terms_version": plans.CurrentTermsVersion, "plan": plan, "band": band}, "recurring": map[string]any{"interval": "year", "interval_count": 1, "usage_type": "licensed"}}
+ case p == "/v1/billing_portal/configurations/bpc_demo":
+ out = map[string]any{"id": "bpc_demo", "active": true, "livemode": false, "features": map[string]any{"customer_update": map[string]any{"enabled": false}, "subscription_update": map[string]any{"enabled": false}, "subscription_cancel": map[string]any{"enabled": true, "mode": "at_period_end"}, "payment_method_update": map[string]bool{"enabled": true}, "invoice_history": map[string]bool{"enabled": true}}}
+ case p == "/v1/billing_portal/sessions" && r.Method == http.MethodPost:
+ out = map[string]any{"id": "bps_demo", "url": "https://billing.stripe.com/p/session/demo", "livemode": false}
+ case p == "/v1/tax/registrations":
+ rows := []map[string]any{}
+ for _, kind := range []string{"standard", "oss_union"} {
+ rows = append(rows, map[string]any{"id": "taxreg_demo_" + kind, "country": "IE", "status": "active", "active_from": now.Add(-time.Hour).Unix(), "country_options": map[string]any{"ie": map[string]string{"type": kind}}})
+ }
+ out = map[string]any{"data": rows, "has_more": false}
+ case p == "/v1/customers" || strings.HasPrefix(p, "/v1/customers/"):
+ if r.Method == http.MethodPost {
+ _ = r.ParseForm()
+ if c := r.PostForm.Get("address[country]"); c != "" {
+ d.customerCountry = c
+ }
+ if sq := r.PostForm.Get("metadata[account_sqid]"); sq != "" {
+ d.customerSqid = sq
+ }
+ }
+ out = map[string]any{"id": "cus_demo", "livemode": false, "address": map[string]string{"country": d.customerCountry}, "shipping": nil, "metadata": map[string]string{"account_sqid": d.customerSqid}, "tax": map[string]any{"automatic_tax": "supported", "location": map[string]string{"country": d.customerCountry}}}
+ case p == "/v1/checkout/sessions" && r.Method == http.MethodPost:
+ _ = r.ParseForm()
+ d.seq++
+ s := &demoSession{ID: fmt.Sprintf("cs_demo_%d", d.seq), Status: "open", Payment: "unpaid", SuccessURL: r.PostForm.Get("success_url"), CancelURL: r.PostForm.Get("cancel_url"), Price: r.PostForm.Get("line_items[0][price]"), Sqid: r.PostForm.Get("client_reference_id"), Meta: map[string]string{}, Expires: now.Add(24 * time.Hour).Unix()}
+ s.Quantity, _ = parseInt64(r.PostForm.Get("line_items[0][quantity]"))
+ for _, key := range []string{"account_sqid", "terms_version", "plan", "band", "checkout_intent"} {
+ s.Meta[key] = r.PostForm.Get("subscription_data[metadata][" + key + "]")
+ }
+ d.sessions[s.ID] = s
+ out = d.sessionJSON(s)
+ case p == "/v1/checkout/sessions" && r.Method == http.MethodGet:
+ rows := []any{}
+ for _, s := range d.sessions {
+ rows = append(rows, d.sessionJSON(s))
+ }
+ out = map[string]any{"data": rows, "has_more": false}
+ case strings.HasPrefix(p, "/v1/checkout/sessions/"):
+ id := strings.TrimPrefix(p, "/v1/checkout/sessions/")
+ if strings.HasSuffix(id, "/expire") {
+ id = strings.TrimSuffix(id, "/expire")
+ if s := d.sessions[id]; s != nil && s.Status == "open" {
+ s.Status = "expired"
+ }
+ }
+ s := d.sessions[id]
+ if s == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ out = d.sessionJSON(s)
+ case p == "/v1/subscriptions/sub_demo":
+ if d.subscription == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ s := d.subscription
+ out = map[string]any{"id": s.ID, "customer": "cus_demo", "status": s.Status, "livemode": false, "created": s.Start, "current_period_start": s.Start, "current_period_end": s.End, "metadata": s.Meta, "items": map[string]any{"data": []map[string]any{{"id": "si_demo", "price": map[string]string{"id": s.Price}, "quantity": s.Quantity}}, "has_more": false}}
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(out)
+}
+
+func parseInt64(s string) (int64, error) {
+ var n int64
+ _, err := fmt.Sscan(s, &n)
+ return n, err
+}
+
+func (d *demoStripe) sessionJSON(s *demoSession) map[string]any {
+ sub := ""
+ if s.Status == "complete" {
+ sub = "sub_demo"
+ }
+ return map[string]any{"id": s.ID, "url": "https://checkout.stripe.com/c/" + s.ID, "status": s.Status, "expires_at": s.Expires, "livemode": false, "customer": "cus_demo", "subscription": sub, "payment_status": s.Payment, "client_reference_id": s.Sqid, "metadata": s.Meta, "customer_details": map[string]any{"address": map[string]string{"country": d.customerCountry}}}
+}
+
+// servePages is the browser-facing half: the simulated checkout and portal.
+func (d *demoStripe) servePages(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasPrefix(r.URL.Path, "/__stripe/checkout/"):
+ // Stripe's hosted URL is /c/<session>; the rewrite keeps the path.
+ id := path.Base(r.URL.Path)
+ d.mu.Lock()
+ s := d.sessions[id]
+ d.mu.Unlock()
+ if s == nil {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method == http.MethodPost {
+ _ = r.ParseForm()
+ switch r.PostForm.Get("outcome") {
+ case "pay":
+ d.pay(s)
+ http.Redirect(w, r, s.SuccessURL, http.StatusSeeOther)
+ default:
+ http.Redirect(w, r, s.CancelURL, http.StatusSeeOther)
+ }
+ return
+ }
+ plan, band := demoPlanBand(s.Price)
+ q, _ := plans.Quote(plans.CurrentTermsVersion, plan, band, s.Quantity)
+ price := "?"
+ if q != nil {
+ price = formatEuroMicro(q.AnnualPriceMicroEUR)
+ }
+ fmt.Fprintf(w, demoPage, "Simulated Stripe Checkout", fmt.Sprintf(`<p class="sim">SIMULATION. This page stands in for Stripe's hosted checkout. There is no card form, no payment and no real Stripe object. Pay marks the session complete and delivers the webhooks a real payment would; Cancel returns you to the console.</p>
+<h1>Carloku %s</h1>
+<p>%d member(s), %s a year before VAT, session <code>%s</code>, account <code>%s</code>.</p>
+<form method="post"><button name="outcome" value="pay">Pay (simulated)</button> <button name="outcome" value="cancel" class="quiet">Cancel</button></form>`, html.EscapeString(billingPlanNames[plan]), s.Quantity, price, s.ID, html.EscapeString(s.Sqid)))
+ case strings.HasPrefix(r.URL.Path, "/__stripe/portal/"):
+ if r.Method == http.MethodPost {
+ _ = r.ParseForm()
+ d.mu.Lock()
+ if d.subscription != nil {
+ switch r.PostForm.Get("outcome") {
+ case "fail":
+ d.subscription.Status = "past_due"
+ case "recover":
+ d.subscription.Status = "active"
+ case "cancel":
+ d.subscription.Status = "canceled"
+ }
+ }
+ d.mu.Unlock()
+ d.webhook("customer.subscription.updated", "sub_demo")
+ http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+ return
+ }
+ status := "none"
+ d.mu.Lock()
+ if d.subscription != nil {
+ status = d.subscription.Status
+ }
+ d.mu.Unlock()
+ back := d.origin + "/"
+ fmt.Fprintf(w, demoPage, "Simulated Stripe portal", fmt.Sprintf(`<p class="sim">SIMULATION. This stands in for Stripe's customer portal. Each button changes the fake subscription and delivers the webhook a real change would.</p>
+<h1>Your subscription: %s</h1>
+<form method="post">
+<button name="outcome" value="fail">Simulate a failed renewal payment</button>
+<button name="outcome" value="recover">Simulate a recovered payment</button>
+<button name="outcome" value="cancel">Cancel the subscription</button>
+</form>
+<p><a href="%s">Back to the console</a></p>`, html.EscapeString(status), html.EscapeString(back)))
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+func (d *demoStripe) pay(s *demoSession) {
+ d.mu.Lock()
+ now := time.Now()
+ s.Status, s.Payment = "complete", "paid"
+ d.subscription = &demoSubscription{ID: "sub_demo", Status: "active", Price: s.Price, Quantity: s.Quantity, Meta: s.Meta, Start: now.Unix(), End: now.AddDate(1, 0, 0).Unix()}
+ d.mu.Unlock()
+ d.webhook("checkout.session.completed", s.ID)
+ d.webhook("customer.subscription.created", "sub_demo")
+}
+
+// webhook delivers a signed event to the console exactly as Stripe would,
+// straight into the handler.
+func (d *demoStripe) webhook(typ, objectID string) {
+ var b [8]byte
+ rand.Read(b[:]) //nolint:errcheck
+ payload := fmt.Sprintf(`{"id":"evt_demo_%s","type":%q,"created":%d,"livemode":false,"data":{"object":{"id":%q}}}`, hex.EncodeToString(b[:]), typ, time.Now().Unix(), objectID)
+ ts := time.Now().Unix()
+ mac := hmac.New(sha256.New, []byte(demoWebhookSecret))
+ fmt.Fprintf(mac, "%d.%s", ts, payload)
+ r := httptest.NewRequest(http.MethodPost, "/billing/webhook", strings.NewReader(payload))
+ r.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))))
+ w := httptest.NewRecorder()
+ d.console.ServeHTTP(w, r)
+}
+
+const demoPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>%s</title>
+<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;color:#1d1927}.sim{background:#fff3c4;border:1px solid #e0b400;padding:.75rem 1rem;border-radius:.5rem;font-size:.9rem}button{font:inherit;padding:.6rem 1.1rem;border-radius:.5rem;border:1px solid #5b3e96;background:#5b3e96;color:#fff;margin:.25rem .25rem .25rem 0}button.quiet{background:#fff;color:#1d1927;border-color:#999}label{display:block;margin:1rem 0}input{font:inherit;padding:.4rem}code{background:#eee;padding:.1rem .3rem}</style></head><body>%s</body></html>`