carlos / platform Public

The billing flow demo sells from the price book in every currency

The review copy of the signup and billing flow configures the current
price book, one simulated price per plan, band and currency, and its
simulated Stripe answers with the session's and subscription's
currency, Adaptive Pricing off, so the currency control and the
pounds, dollars and euro prices can be walked through end to end. Still
a simulation: no card, no payment, no Stripe object anywhere.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f269365a0cdec6228bd90ab8627d5554d0cf2d47 parent de81dd7
1 file changed, +71 −27
  • internal/console/billingflow_demo_test.go +71 −27
diff --git a/internal/console/billingflow_demo_test.go b/internal/console/billingflow_demo_test.go
index 5d2973467..4085c3a34 100644
--- a/internal/console/billingflow_demo_test.go
+++ b/internal/console/billingflow_demo_test.go
@@ -74,14 +74,19 @@ func TestBillingFlowDemo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ // The demo sells from the current price book, as a deployed console
+ // would: one simulated price per plan, band and currency, named after
+ // its key so the simulation can price it from the book.
prices := map[string]string{}
for _, plan := range billingPaidPlans {
for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} {
- prices[billingPriceKey(plans.LegacyPriceTerms(plans.CurrentTermsVersion), plan, band)] = "price_" + plan + "_" + band
+ for _, c := range demoCurrencies() {
+ prices[billingPriceKey(demoTerms(c), plan, band)] = demoPriceID(plan, band, c)
+ }
}
}
srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{
- MerchantAccount: "acct_demo", PortalConfiguration: "bpc_demo",
+ MerchantAccount: "acct_demo", PortalConfiguration: "bpc_demo", PriceBook: plans.CurrentPriceBook,
TaxCodes: map[string]string{plans.PlanPersonal: "txcd_10102001", plans.PlanCommunity: "txcd_10102001", plans.PlanBusiness: "txcd_10102000"},
CountryBands: plans.CountryBands(), Prices: prices, WebhookSecrets: []string{demoWebhookSecret},
}}
@@ -215,29 +220,65 @@ type demoStripe struct {
}
type demoSession struct {
- ID, Status, Payment, SuccessURL, CancelURL, Price, Sqid string
- Quantity int64
- Meta map[string]string
- Expires int64
+ ID, Status, Payment, SuccessURL, CancelURL, Price, Currency, 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
+ ID, Status, Price, Currency 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) {
+func demoCurrencies() []plans.Currency {
+ list, err := plans.BookCurrencies(plans.CurrentPriceBook)
+ if err != nil {
+ panic(err)
+ }
+ return list
+}
+
+func demoTerms(c plans.Currency) plans.PriceTerms {
+ return plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: c}
+}
+
+func demoPriceID(plan, band string, c plans.Currency) string {
+ return "price_" + plan + "_" + band + "_" + string(c)
+}
+
+// demoPlanBand reads a simulated price id back into its plan, band and
+// currency. Anything else is no price.
+func demoPlanBand(priceID string) (plan, band string, c plans.Currency) {
parts := strings.Split(strings.TrimPrefix(priceID, "price_"), "_")
- if len(parts) == 2 {
- return parts[0], parts[1]
+ if len(parts) != 3 {
+ return "", "", ""
}
- return "", ""
+ c, err := plans.ParseCurrency(parts[2])
+ if err != nil {
+ return "", "", ""
+ }
+ return parts[0], parts[1], c
+}
+
+// demoPrice is the book's price for a simulated price id at a quantity,
+// or nil when the id names none.
+func demoPrice(priceID string, qty int64) *plans.Price {
+ plan, band, c := demoPlanBand(priceID)
+ if plan == "" {
+ return nil
+ }
+ price, err := plans.PriceFor(demoTerms(c), plan, band, qty)
+ if err != nil {
+ return nil
+ }
+ return price
}
func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) {
@@ -258,18 +299,19 @@ func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) {
}
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/"))
+ id := strings.TrimPrefix(p, "/v1/prices/")
+ plan, band, c := demoPlanBand(id)
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 {
+ price := demoPrice(id, def.MinQuantity)
+ if price == 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"}}
+ out = map[string]any{"id": id, "product": "prod_" + plan, "active": true, "livemode": false, "currency": string(c), "unit_amount": price.UnitMinor, "tax_behavior": "exclusive", "billing_scheme": "per_unit", "metadata": map[string]string{"terms_version": plans.CurrentTermsVersion, "price_book": plans.CurrentPriceBook, "currency": string(c), "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:
@@ -294,9 +336,9 @@ func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) {
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 := &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]"), Currency: r.PostForm.Get("currency"), 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"} {
+ for _, key := range []string{"account_sqid", "terms_version", "price_book", "currency", "plan", "band", "checkout_intent"} {
s.Meta[key] = r.PostForm.Get("subscription_data[metadata][" + key + "]")
}
d.sessions[s.ID] = s
@@ -327,7 +369,7 @@ func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) {
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}}
+ 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, "currency": s.Currency, "metadata": s.Meta, "items": map[string]any{"data": []map[string]any{{"id": "si_demo", "price": map[string]string{"id": s.Price, "currency": s.Currency}, "quantity": s.Quantity}}, "has_more": false}}
default:
w.WriteHeader(http.StatusNotFound)
return
@@ -346,7 +388,10 @@ func (d *demoStripe) sessionJSON(s *demoSession) map[string]any {
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}}, "currency": "eur"}
+ // The session is in exactly the currency the console asked for, with
+ // Adaptive Pricing off and no conversion, as Stripe returns a fixed
+ // currency session.
+ 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}}, "currency": s.Currency, "adaptive_pricing": map[string]bool{"enabled": false}}
}
// servePages is the browser-facing half: the simulated checkout and portal.
@@ -373,11 +418,10 @@ func (d *demoStripe) servePages(w http.ResponseWriter, r *http.Request) {
}
return
}
- plan, band := demoPlanBand(s.Price)
- q, _ := plans.Quote(plans.CurrentTermsVersion, plan, band, s.Quantity)
+ plan, _, c := demoPlanBand(s.Price)
price := "?"
- if q != nil {
- price = formatEuroMicro(q.AnnualPriceMicroEUR)
+ if p := demoPrice(s.Price, s.Quantity); p != nil {
+ price = formatMinor(c, p.AnnualMinor)
}
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>
@@ -426,7 +470,7 @@ 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.subscription = &demoSubscription{ID: "sub_demo", Status: "active", Price: s.Price, Currency: s.Currency, 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")