| new file mode 100644 |
| index 000000000..23db4538d |
| --- /dev/null |
| +++ b/internal/console/billingcurrency_test.go |
| @@ -0,0 +1,977 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/billingstripe" |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func bookTerms(c plans.Currency) plans.PriceTerms { |
| + return plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: c} |
| +} |
| + |
| +func currencyPriceID(plan, band string, c plans.Currency) string { |
| + return "price_" + plan + "_" + band + "_" + string(c) |
| +} |
| + |
| +// newCurrencyFixture is newBillingFixture with the console selling from the |
| +// current price book: every country's real band, and a provider price for |
| +// every plan, band and currency, each exactly what the book says and |
| +// saying so in its own metadata. The legacy price of the base fixture stays |
| +// configured, as a deployment that sold before books would have it. |
| +func newCurrencyFixture(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Account) { |
| + t.Helper() |
| + fx, f, acct := newBillingFixture(t) |
| + cfg := &fx.srv.billing.cfg |
| + cfg.PriceBook, cfg.CountryBands = plans.CurrentPriceBook, plans.CountryBands() |
| + f.prices = map[string]map[string]any{} |
| + for _, plan := range []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} { |
| + qty := int64(1) |
| + if plan != plans.PlanPersonal { |
| + qty = 10 |
| + } |
| + for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} { |
| + for _, c := range []plans.Currency{plans.USD, plans.EUR, plans.GBP} { |
| + terms := bookTerms(c) |
| + price, err := plans.PriceFor(terms, plan, band, qty) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id := currencyPriceID(plan, band, c) |
| + cfg.Prices[billingPriceKey(terms, plan, band)] = id |
| + f.prices[id] = map[string]any{"id": id, "product": "prod_personal", "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, "plan": plan, "band": band, "price_book": plans.CurrentPriceBook, "currency": string(c)}, |
| + "recurring": map[string]any{"interval": "year", "interval_count": 1, "usage_type": "licensed"}} |
| + } |
| + } |
| + } |
| + return fx, f, acct |
| +} |
| + |
| +// The buyer's currency: the billing country's default, or the one they |
| +// chose. Either way the band, and so every allowance, is the country's. |
| +// What reaches the provider is the price for exactly that plan, band and |
| +// currency, a session asked for in that currency with Adaptive Pricing |
| +// off, and the book and currency in the metadata of the session and of |
| +// the subscription it will create. |
| +func TestCheckoutCurrencyIsTheCountrysDefaultOrTheBuyersChoice(t *testing.T) { |
| + for name, tc := range map[string]struct { |
| + country, postal, explicit string |
| + want plans.Currency |
| + }{ |
| + "Ireland": {"IE", "", "", plans.EUR}, |
| + "the United Kingdom": {"GB", "", "", plans.GBP}, |
| + "the United States": {"US", "94107", "", plans.USD}, |
| + "Poland": {"PL", "", "", plans.EUR}, |
| + "Switzerland": {"CH", "", "", plans.USD}, |
| + "Ireland, choosing dollars": {"IE", "", "usd", plans.USD}, |
| + "Poland, choosing pounds": {"PL", "", "gbp", plans.GBP}, |
| + "the United States, in euros": {"US", "94107", "eur", plans.EUR}, |
| + "the United Kingdom, in euro": {"GB", "", "eur", plans.EUR}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry = tc.country |
| + band, err := plans.CountryBand(tc.country) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, tc.country, tc.postal, tc.explicit, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + intent, err := fx.st.billingCheckoutIntent(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if intent.Currency != string(tc.want) || intent.PriceBook != plans.CurrentPriceBook || intent.Band != band || intent.Price != currencyPriceID(plans.PlanPersonal, band, tc.want) { |
| + t.Fatalf("intent %+v; want %s at the country's band %s", intent, tc.want, band) |
| + } |
| + form := f.forms[len(f.forms)-1] |
| + for key, want := range map[string]string{ |
| + "currency": string(tc.want), "adaptive_pricing[enabled]": "false", "line_items[0][price]": intent.Price, |
| + "metadata[currency]": string(tc.want), "metadata[price_book]": plans.CurrentPriceBook, "metadata[band]": band, |
| + "subscription_data[metadata][currency]": string(tc.want), "subscription_data[metadata][price_book]": plans.CurrentPriceBook, |
| + } { |
| + if got := form.Get(key); got != want { |
| + t.Errorf("checkout form %s = %q, want %q", key, got, want) |
| + } |
| + } |
| + // The allowance the purchase carries is the band's, whatever |
| + // the currency. |
| + inCurrency, err := plans.PriceFor(intent.priceTerms(), intent.Plan, intent.Band, intent.Quantity) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy, err := plans.Quote(plans.CurrentTermsVersion, intent.Plan, band, intent.Quantity) |
| + if err != nil || inCurrency.Allowances != legacy.Allowances { |
| + t.Fatalf("allowances in %s differ from the band's: %+v, %+v (%v)", tc.want, inCurrency.Allowances, legacy.Allowances, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A currency somebody asked for that is not one sold is refused. Nothing |
| +// is read from or written to the provider, no intent is opened, and the |
| +// country's default does not stand in for it. |
| +func TestCheckoutRefusesACurrencyThatIsNotSold(t *testing.T) { |
| + for _, bad := range []string{"jpy", "EUR", "Gbp", " ", "eur ", "euro", "chf"} { |
| + fx, f, acct := newCurrencyFixture(t) |
| + _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", bad, f.now) |
| + if !errors.Is(err, errBillingCurrency) { |
| + t.Errorf("%q: %v, want errBillingCurrency", bad, err) |
| + } |
| + if f.requests != 0 { |
| + t.Errorf("%q: %d requests reached the provider", bad, f.requests) |
| + } |
| + if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil { |
| + t.Errorf("%q: an intent was opened", bad) |
| + } |
| + } |
| + // A console still selling the way it did before books sells in EUR |
| + // and nothing else. |
| + fx, f, acct := newBillingFixture(t) |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "usd", f.now); !errors.Is(err, errBillingCurrency) || f.requests != 0 { |
| + t.Fatalf("usd from a console with no book: %v, %d requests", err, f.requests) |
| + } |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", "eur", f.now); err != nil { |
| + t.Fatalf("eur from a console with no book: %v", err) |
| + } |
| + if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.Currency != "" || intent.PriceBook != "" { |
| + t.Fatalf("a purchase with no book is stored with no currency: %+v %v", intent, err) |
| + } |
| +} |
| + |
| +// Through the door: the form's currency field, posted by an owner who has |
| +// just confirmed who they are. An unknown currency is a 400 and nothing |
| +// else happens. |
| +func TestCheckoutFormCurrencyField(t *testing.T) { |
| + post := func(t *testing.T, currency string) (*httptest.ResponseRecorder, *consoleFixture, *fakeBillingStripe, *Account) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + const owner = "paul@keymail.dev" |
| + raw, err := fx.st.CreateSession(owner, "", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.RecordStepUp(raw, owner, time.Now(), time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + form := url.Values{"csrf": {raw}, "plan": {"personal"}, "country": {"IE"}, "consent": {plans.CurrentTermsVersion}} |
| + if currency != "-" { |
| + form.Set("currency", currency) |
| + } |
| + r := httptest.NewRequest(http.MethodPost, fx.path("/billing/checkout"), strings.NewReader(form.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: raw}) |
| + w := httptest.NewRecorder() |
| + fx.srv.ServeHTTP(w, r) |
| + return w, fx, f, acct |
| + } |
| + for currency, want := range map[string]string{"-": "eur", "": "eur", "gbp": "gbp", "usd": "usd"} { |
| + w, fx, _, acct := post(t, currency) |
| + if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "https://checkout.stripe.com/") { |
| + t.Fatalf("currency %q: %d %s", currency, w.Code, w.Header().Get("Location")) |
| + } |
| + if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.Currency != want { |
| + t.Fatalf("currency %q: intent %+v %v, want %s", currency, intent, err, want) |
| + } |
| + } |
| + w, fx, f, acct := post(t, "jpy") |
| + if w.Code != http.StatusBadRequest || strings.TrimSpace(w.Body.String()) != "currency_invalid" { |
| + t.Fatalf("an unknown currency: %d %q", w.Code, w.Body.String()) |
| + } |
| + if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil || f.checkoutCalls != 0 { |
| + t.Fatalf("an unknown currency opened an intent or a session (%d checkout calls)", f.checkoutCalls) |
| + } |
| +} |
| + |
| +// The provider's price has to be this purchase's price. One that is in |
| +// another currency, for another amount, or that does not say which book |
| +// and currency it is for, is not sold, and no session is created. |
| +func TestCheckoutRefusesAProviderPriceThatIsNotTheBooks(t *testing.T) { |
| + id := currencyPriceID(plans.PlanPersonal, plans.BandA, plans.GBP) |
| + for name, spoil := range map[string]func(price map[string]any){ |
| + "in another currency": func(p map[string]any) { p["currency"] = "eur" }, |
| + "one penny more": func(p map[string]any) { p["unit_amount"] = int64(2201) }, |
| + "the euro amount under gbp": func(p map[string]any) { p["unit_amount"] = int64(2400) }, |
| + "metadata for another currency": func(p map[string]any) { p["metadata"].(map[string]string)["currency"] = "usd" }, |
| + "metadata with no book": func(p map[string]any) { delete(p["metadata"].(map[string]string), "price_book") }, |
| + "metadata with no currency": func(p map[string]any) { delete(p["metadata"].(map[string]string), "currency") }, |
| + "metadata for another book": func(p map[string]any) { p["metadata"].(map[string]string)["price_book"] = "2031-01-01-v9" }, |
| + "metadata for another band": func(p map[string]any) { p["metadata"].(map[string]string)["band"] = "B" }, |
| + "carrying currency options": func(p map[string]any) { |
| + p["currency_options"] = map[string]any{"eur": map[string]any{"unit_amount": 2400}} |
| + }, |
| + "another price's id at this path": func(p map[string]any) { p["id"] = currencyPriceID(plans.PlanPersonal, plans.BandA, plans.EUR) }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry = "GB" |
| + spoil(f.prices[id]) |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); !errors.Is(err, errBillingUnavailable) { |
| + t.Fatalf("%v, want errBillingUnavailable", err) |
| + } |
| + if f.checkoutCalls != 0 { |
| + t.Fatal("a session was created for a price that is not the book's") |
| + } |
| + }) |
| + } |
| + // A price configured under the wrong currency's key. |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry = "GB" |
| + fx.srv.billing.cfg.Prices[billingPriceKey(bookTerms(plans.GBP), plans.PlanPersonal, plans.BandA)] = currencyPriceID(plans.PlanPersonal, plans.BandA, plans.EUR) |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); !errors.Is(err, errBillingUnavailable) || f.checkoutCalls != 0 { |
| + t.Fatalf("the euro price under the gbp key: %v, %d checkout calls", err, f.checkoutCalls) |
| + } |
| +} |
| + |
| +// A session that comes back in another currency than it was asked for is |
| +// not kept: the intent has no session, and nothing is offered to the buyer. |
| +func TestCheckoutRefusesASessionInAnotherCurrency(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry, f.sessionCurrency = "GB", "eur" |
| + dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now) |
| + var stripeErr *billingstripe.Error |
| + if dest != "" || !errors.As(err, &stripeErr) || stripeErr.Code != billingstripe.CodeCurrencyMismatch { |
| + t.Fatalf("dest %q err %v, want a currency mismatch and no destination", dest, err) |
| + } |
| + if intent, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || intent.SessionID != "" || intent.URL != "" { |
| + t.Fatalf("the session was kept: %+v %v", intent, err) |
| + } |
| +} |
| + |
| +// Changing currency while a checkout is open is a different purchase: the |
| +// open session is expired first and a new intent opened in the new |
| +// currency, as for a changed plan or country. |
| +func TestCheckoutInAnotherCurrencyReplacesTheOpenSession(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + ctx := context.Background() |
| + if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "", f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, _ := fx.st.billingCheckoutIntent(acct.ID) |
| + // The same request again resumes the same session. |
| + if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "eur", f.now.Add(time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if again, _ := fx.st.billingCheckoutIntent(acct.ID); again.ID != first.ID || f.expireCalls != 0 || f.checkoutCalls != 1 { |
| + t.Fatalf("the same currency again: intent %s then %s, %d expiries, %d sessions", first.ID, again.ID, f.expireCalls, f.checkoutCalls) |
| + } |
| + // The session is still open at the provider. The console expires it |
| + // itself before it opens anything in another currency. |
| + if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "gbp", f.now.Add(time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.expireCalls != 1 || f.checkoutCalls != 2 { |
| + t.Fatalf("after choosing gbp with a session open in eur: %d expiries and %d sessions, want 1 and 2", f.expireCalls, f.checkoutCalls) |
| + } |
| + second, err := fx.st.billingCheckoutIntent(acct.ID) |
| + if err != nil || second.ID == first.ID || second.Currency != "gbp" || first.Currency != "eur" { |
| + t.Fatalf("after choosing gbp: first %+v, second %+v, %v", first, second, err) |
| + } |
| + if got := f.forms[len(f.forms)-1].Get("currency"); got != "gbp" { |
| + t.Fatalf("the new session was asked for in %q", got) |
| + } |
| +} |
| + |
| +// A completed session is read back before its purchase is recorded. One |
| +// that is no longer in the intent's currency, or that carries a conversion |
| +// or Adaptive Pricing, is not bound to the intent and not recorded as a |
| +// subscription. |
| +func TestCompletedSessionMustStillBeInTheIntentsCurrency(t *testing.T) { |
| + for name, spoil := range map[string]func(f *fakeBillingStripe){ |
| + "it reads back in another currency": func(f *fakeBillingStripe) { f.sessionCurrency = "eur" }, |
| + "it reads back with no currency": func(f *fakeBillingStripe) { f.sessionCurrency = "-" }, |
| + // Everything at the provider agrees with itself, in dollars: the |
| + // session, the subscription, its price and its metadata. The |
| + // intent the buyer opened was in pounds. |
| + "the provider says dollars throughout and the intent says pounds": func(f *fakeBillingStripe) { |
| + f.sessionCurrency, f.subscriptionCurrency = "usd", "usd" |
| + f.subscriptionPrice = currencyPriceID(plans.PlanPersonal, f.meta["band"], plans.USD) |
| + f.meta["currency"] = "usd" |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry = "GB" |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + intent, _ := fx.st.billingCheckoutIntent(acct.ID) |
| + f.mu.Lock() |
| + f.sessionStatus, f.subscriptionPrice = "complete", intent.Price |
| + spoil(f) |
| + f.mu.Unlock() |
| + if sub, err := fx.srv.bindBillingCheckout(context.Background(), intent.SessionID); !errors.Is(err, errBillingIdentity) || sub != "" { |
| + t.Errorf("binding the session: %q %v, want errBillingIdentity", sub, err) |
| + } |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now); !errors.Is(err, errBillingIdentity) { |
| + t.Errorf("recording the subscription: %v, want errBillingIdentity", err) |
| + } |
| + if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" { |
| + t.Errorf("the purchase was recorded: %+v", state) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// An intent is opened only on price terms that are whole: a book with a |
| +// currency it sells in, or no book and EUR. |
| +func TestIntentNeedsWholePriceTerms(t *testing.T) { |
| + fx, _, acct := newCurrencyFixture(t) |
| + base := billingIntent{AccountID: acct.ID, Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Price: "price_x", Quantity: 1, Country: "IE", Members: 1} |
| + for name, edit := range map[string]func(i *billingIntent){ |
| + "a book and no currency": func(i *billingIntent) { i.PriceBook = plans.CurrentPriceBook }, |
| + "a book and a currency it lacks": func(i *billingIntent) { i.PriceBook, i.Currency = plans.CurrentPriceBook, "jpy" }, |
| + "a book nobody has": func(i *billingIntent) { i.PriceBook, i.Currency = "2031-01-01-v9", "eur" }, |
| + "no book and dollars": func(i *billingIntent) { i.Currency = "usd" }, |
| + "terms nobody has": func(i *billingIntent) { |
| + i.Terms, i.PriceBook, i.Currency = "2020-01-01-v0", plans.CurrentPriceBook, "eur" |
| + }, |
| + } { |
| + want := base |
| + edit(&want) |
| + if got, err := fx.st.beginBillingCheckout(want, time.Now()); err == nil { |
| + t.Errorf("%s: opened %+v", name, got) |
| + } |
| + if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil { |
| + t.Fatalf("%s: an intent row exists", name) |
| + } |
| + } |
| + want := base |
| + want.PriceBook, want.Currency = plans.CurrentPriceBook, "gbp" |
| + if got, err := fx.st.beginBillingCheckout(want, time.Now()); err != nil || got.Currency != "gbp" || got.PriceBook != plans.CurrentPriceBook { |
| + t.Fatalf("whole terms: %+v %v", got, err) |
| + } |
| +} |
| + |
| +// buyIn opens a checkout in a currency, completes it at the fake provider |
| +// and reconciles the subscription, as a paid purchase goes. |
| +func buyIn(t *testing.T, country, currency string) (*consoleFixture, *fakeBillingStripe, *Account, billingIntent) { |
| + t.Helper() |
| + fx, f, acct := newCurrencyFixture(t) |
| + f.customerCountry = country |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, country, "", currency, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + intent, err := fx.st.billingCheckoutIntent(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.mu.Lock() |
| + f.sessionStatus, f.subscriptionPrice = "complete", intent.Price |
| + f.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now); err != nil { |
| + t.Fatalf("reconcile: %v", err) |
| + } |
| + return fx, f, acct, intent |
| +} |
| + |
| +// A purchase is recorded with its currency and its book, on the account |
| +// and on the assignment its usage will be priced from. After that the |
| +// subscription is in that currency for good: a reading of it that names |
| +// another, however consistently, is refused and changes nothing. |
| +func TestSubscriptionCurrencyIsRecordedAndNeverChanges(t *testing.T) { |
| + fx, f, acct, intent := buyIn(t, "GB", "") |
| + state, err := fx.st.billingAccount(acct.ID) |
| + if err != nil || state.Status != "active" || state.Currency != "gbp" || state.PriceBook != plans.CurrentPriceBook { |
| + t.Fatalf("recorded state %+v %v", state, err) |
| + } |
| + rows, err := fx.st.billingInvoiceHistory(acct.ID) |
| + if err != nil || len(rows) != 1 || rows[0].Currency != plans.GBP || rows[0].PriceBook != plans.CurrentPriceBook || rows[0].PriceTerms() != bookTerms(plans.GBP) { |
| + t.Fatalf("assignment history %+v %v", rows, err) |
| + } |
| + usdPrice := currencyPriceID(plans.PlanPersonal, intent.Band, plans.USD) |
| + for name, tamper := range map[string]func(){ |
| + "the subscription reports another currency": func() { f.subscriptionCurrency = "eur" }, |
| + "its metadata names another currency": func() { f.meta["currency"] = "usd" }, |
| + "its metadata names a book nobody has": func() { f.meta["price_book"] = "2031-01-01-v9" }, |
| + "its metadata names no book or currency": func() { delete(f.meta, "price_book"); delete(f.meta, "currency") }, |
| + "it is another currency through and through": func() { |
| + f.meta["currency"], f.subscriptionPrice, f.subscriptionCurrency = "usd", usdPrice, "usd" |
| + }, |
| + "it is a legacy subscription through and through": func() { |
| + delete(f.meta, "price_book") |
| + delete(f.meta, "currency") |
| + f.subscriptionPrice, f.subscriptionCurrency = "price_personal", "eur" |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f.mu.Lock() |
| + keepMeta := map[string]string{} |
| + for k, v := range f.meta { |
| + keepMeta[k] = v |
| + } |
| + keepPrice, keepCurrency := f.subscriptionPrice, f.subscriptionCurrency |
| + f.subscriptionStatus = "past_due" // a real change, so a reading that passed would be written |
| + tamper() |
| + f.mu.Unlock() |
| + err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now.Add(time.Minute)) |
| + if !errors.Is(err, errBillingIdentity) { |
| + t.Errorf("reconcile: %v, want errBillingIdentity", err) |
| + } |
| + after, _ := fx.st.billingAccount(acct.ID) |
| + if after != state { |
| + t.Errorf("the account changed: %+v, was %+v", after, state) |
| + } |
| + if rows, _ := fx.st.billingInvoiceHistory(acct.ID); len(rows) != 1 { |
| + t.Errorf("%d assignments after a refused reading", len(rows)) |
| + } |
| + f.mu.Lock() |
| + f.meta, f.subscriptionPrice, f.subscriptionCurrency, f.subscriptionStatus = keepMeta, keepPrice, keepCurrency, "active" |
| + f.mu.Unlock() |
| + }) |
| + } |
| + // An honest change to the same subscription is still recorded, in the |
| + // same currency. |
| + f.mu.Lock() |
| + f.subscriptionStatus = "past_due" |
| + f.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_paid", "", f.now.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after, _ := fx.st.billingAccount(acct.ID); after.Status != "past_due" || after.Currency != "gbp" || after.PriceBook != plans.CurrentPriceBook { |
| + t.Fatalf("after an honest change: %+v", after) |
| + } |
| +} |
| + |
| +// A subscription bought before price books says nothing about a currency. |
| +// A console that now sells from a book still recognises it, by the legacy |
| +// price key, as EUR, and records it with no currency, which is what such a |
| +// row is. It is not given the book's currency, and one that turns out to |
| +// be in another currency is refused. |
| +func TestLegacySubscriptionStaysEURUnderAConsoleWithABook(t *testing.T) { |
| + fx, f, acct := newBillingFixture(t) |
| + ctx := context.Background() |
| + if _, err := fx.srv.startBillingCheckout(ctx, acct, plans.PlanPersonal, "IE", "", "", f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.mu.Lock() |
| + f.sessionStatus = "complete" |
| + f.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := fx.st.billingAccount(acct.ID) |
| + if before.Status != "active" || before.Currency != "" || before.PriceBook != "" || before.priceTerms().Money() != plans.EUR { |
| + t.Fatalf("the legacy purchase: %+v", before) |
| + } |
| + // The console is upgraded and now sells from the book. |
| + fx.srv.billing.cfg.PriceBook = plans.CurrentPriceBook |
| + f.mu.Lock() |
| + f.subscriptionStatus = "past_due" |
| + f.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now.Add(time.Minute)); err != nil { |
| + t.Fatalf("a legacy subscription under a console with a book: %v", err) |
| + } |
| + after, _ := fx.st.billingAccount(acct.ID) |
| + if after.Status != "past_due" || after.Currency != "" || after.PriceBook != "" { |
| + t.Fatalf("after the upgrade: %+v", after) |
| + } |
| + f.mu.Lock() |
| + f.subscriptionCurrency, f.subscriptionStatus = "usd", "active" |
| + f.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(ctx, "sub_paid", "", f.now.Add(2*time.Minute)); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("a legacy subscription the provider reports in usd: %v", err) |
| + } |
| +} |
| + |
| +// An account's currency can change only with a new subscription, after the |
| +// old one has ended. The two are then different rows of its history, and a |
| +// month they both paid for is priced in neither currency. |
| +func TestCurrencyChangesOnlyWithANewSubscriptionAndNeverInsideAMonth(t *testing.T) { |
| + fx, _, acct := newCurrencyFixture(t) |
| + st := fx.st |
| + at := func(day int) time.Time { return time.Date(2026, 9, day, 0, 0, 0, 0, time.UTC) } |
| + state := func(sub, status string, c plans.Currency, start time.Time) billingState { |
| + return billingState{AccountID: acct.ID, Customer: "cus_one", Subscription: sub, Status: status, Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: string(c), Band: plans.BandA, Quantity: 1, PeriodStart: start.Unix(), PeriodEnd: start.AddDate(1, 0, 0).Unix()} |
| + } |
| + if err := st.applyBillingState(state("sub_a", "active", plans.GBP, at(1)), 0, "e1", at(1)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The same subscription, live, in dollars: refused. |
| + if err := st.applyBillingState(state("sub_a", "active", plans.USD, at(1)), 1, "e2", at(5)); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("the same subscription in another currency: %v", err) |
| + } |
| + // Another subscription while the first is live: refused, as it always was. |
| + if err := st.applyBillingState(state("sub_b", "active", plans.USD, at(5)), 1, "e3", at(5)); err == nil { |
| + t.Fatal("a second subscription was recorded beside a live one") |
| + } |
| + // A book with no currency, and a currency the book does not sell in. |
| + for _, c := range []plans.Currency{"", "jpy"} { |
| + if err := st.applyBillingState(state("sub_a", "past_due", c, at(1)), 1, "", at(6)); err == nil { |
| + t.Fatalf("currency %q was recorded", c) |
| + } |
| + } |
| + if err := st.applyBillingState(state("sub_a", "canceled", plans.GBP, at(1)), 1, "e4", at(10)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.applyBillingState(state("sub_b", "active", plans.USD, at(10)), 2, "e5", at(10)); err != nil { |
| + t.Fatalf("a new subscription in another currency after the old one ended: %v", err) |
| + } |
| + now, _ := st.billingAccount(acct.ID) |
| + if now.Subscription != "sub_b" || now.Currency != "usd" { |
| + t.Fatalf("the account: %+v", now) |
| + } |
| + rows, err := st.billingInvoiceHistory(acct.ID) |
| + if err != nil || len(rows) != 3 || rows[0].Currency != plans.GBP || rows[1].Currency != plans.GBP || rows[2].Currency != plans.USD { |
| + t.Fatalf("history: %+v %v", rows, err) |
| + } |
| + history := make([]plans.Assignment, len(rows)) |
| + for i, row := range rows { |
| + history[i] = row.Assignment |
| + } |
| + if _, err := plans.AllowanceForMonth("2026-09", history); !errors.Is(err, plans.ErrAssignmentHistory) { |
| + t.Fatalf("a month paid for in pounds and then in dollars: %v, want it refused", err) |
| + } |
| + statement := usage.Statement{V: 1, Account: fx.sqid, Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000}, DaysInMonth: 30, Closed: true} |
| + if calc, err := priceBillingCalculation(statement, history); err == nil { |
| + t.Fatalf("the month was priced: %+v", calc) |
| + } |
| + if after, err := plans.AllowanceForMonth("2026-10", history); err != nil || after.Currency != plans.USD { |
| + t.Fatalf("the month after, in dollars throughout: %+v %v", after, err) |
| + } |
| +} |
| + |
| +// billingCalculationV1 is the calculation as it was written before version |
| +// 2 existed: the same fields in the same order, with its money by value. |
| +// A legacy month must still marshal to exactly these bytes. |
| +type billingCalculationV1 struct { |
| + StatementObject usage.ObjectEvidence `json:"statement_object"` |
| + V int `json:"v"` |
| + Arithmetic string `json:"arithmetic"` |
| + Currency string `json:"currency"` |
| + Statement usage.Statement `json:"statement"` |
| + StatementSHA256 string `json:"statement_sha256"` |
| + Allowance plans.MonthAllowance `json:"allowance"` |
| + Usage plans.Resources `json:"usage"` |
| + Overage plans.OverageResult `json:"overage"` |
| + Invoice plans.InvoiceResult `json:"invoice"` |
| + Lines []plans.OverageLine `json:"lines"` |
| + TaxCode string `json:"tax_code"` |
| + CountedDays []string `json:"counted_days,omitempty"` |
| +} |
| + |
| +func monthHistory(terms plans.PriceTerms, house bool) []plans.Assignment { |
| + effective := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) |
| + return []plans.Assignment{{EffectiveAt: effective, Revision: 1, TermsVersion: terms.TermsVersion, Plan: plans.PlanPersonal, Band: plans.BandA, Quantity: 1, House: house, Status: "active", PaidThrough: effective.AddDate(1, 0, 0), PriceBook: terms.PriceBook, Currency: terms.Currency}} |
| +} |
| + |
| +// A month is priced in the currency its subscription was bought in. From a |
| +// book that is a version 2 calculation, whose every amount is in that one |
| +// currency under names that claim no other. From before books it is a |
| +// version 1 calculation, EUR, byte for byte what it always was. |
| +func TestMonthIsPricedInTheCurrencyItWasBoughtIn(t *testing.T) { |
| + statement := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000, usage.MeterStorageGBMonth: 40}, DaysInMonth: 30, Closed: true} |
| + legacy, err := priceBillingCalculation(statement, monthHistory(plans.LegacyPriceTerms(plans.CurrentTermsVersion), false)) |
| + if err != nil || !legacy.wellFormed() || legacy.V != 1 || legacy.Currency != "eur" || legacy.Charges != nil || legacy.Total != nil || legacy.PriceBook != "" { |
| + t.Fatalf("legacy month: %+v %v", legacy, err) |
| + } |
| + legacy.TaxCode = "txcd_test" |
| + got, err := json.Marshal(legacy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + was, err := json.Marshal(billingCalculationV1{StatementObject: legacy.StatementObject, V: 1, Arithmetic: legacy.Arithmetic, Currency: "eur", Statement: legacy.Statement, StatementSHA256: legacy.StatementSHA256, |
| + Allowance: legacy.Allowance, Usage: legacy.Usage, Overage: *legacy.Overage, Invoice: *legacy.Invoice, Lines: legacy.Lines, TaxCode: "txcd_test", CountedDays: legacy.CountedDays}) |
| + if err != nil || string(got) != string(was) { |
| + t.Fatalf("a legacy month marshals as\n%s\nand used to marshal as\n%s", got, was) |
| + } |
| + var back billingCalculation |
| + if err := json.Unmarshal(got, &back); err != nil || !back.wellFormed() || back.totalMinor() != legacy.Invoice.TotalCents || back.waived() != legacy.Invoice.Waived { |
| + t.Fatalf("a legacy month read back: %+v %v", back, err) |
| + } |
| + for _, c := range []plans.Currency{plans.USD, plans.EUR, plans.GBP} { |
| + calc, err := priceBillingCalculation(statement, monthHistory(bookTerms(c), false)) |
| + if err != nil || !calc.wellFormed() || calc.V != 2 || calc.Currency != string(c) || calc.PriceBook != plans.CurrentPriceBook || calc.Overage != nil || calc.Invoice != nil { |
| + t.Fatalf("%s month: %+v %v", c, calc, err) |
| + } |
| + if calc.Usage != legacy.Usage || calc.Allowance.Resources != legacy.Allowance.Resources { |
| + t.Errorf("%s: usage or allowance differs from the legacy month's", c) |
| + } |
| + want, err := plans.OverageFor(bookTerms(c), plans.PlanPersonal, calc.Usage, calc.Allowance.Resources) |
| + if err != nil || *calc.Charges != *want || calc.Charges.Currency != c || calc.Total.Currency != c { |
| + t.Errorf("%s charges %+v, want %+v (%v)", c, calc.Charges, want, err) |
| + } |
| + var sum int64 |
| + for _, l := range calc.Lines { |
| + sum += l.AmountCents |
| + } |
| + if calc.waived() || sum != calc.totalMinor() || calc.totalMinor() <= 0 { |
| + t.Errorf("%s: total %d, lines sum to %d", c, calc.totalMinor(), sum) |
| + } |
| + raw, _ := json.Marshal(calc) |
| + for _, absent := range []string{"MicroEUR", `"overage"`, `"invoice"`, "TotalCents"} { |
| + if strings.Contains(string(raw), absent) { |
| + t.Errorf("a %s month's calculation contains %s: %s", c, absent, raw) |
| + } |
| + } |
| + for _, present := range []string{`"v":2`, `"currency":"` + string(c) + `"`, `"price_book":"` + plans.CurrentPriceBook + `"`, `"charges"`, `"total"`} { |
| + if !strings.Contains(string(raw), present) { |
| + t.Errorf("a %s month's calculation lacks %s: %s", c, present, raw) |
| + } |
| + } |
| + var back billingCalculation |
| + if err := json.Unmarshal(raw, &back); err != nil || !back.wellFormed() || back.totalMinor() != calc.totalMinor() { |
| + t.Errorf("a %s month read back: %+v %v", c, back, err) |
| + } |
| + // EUR from the book is the legacy month's money. |
| + if c == plans.EUR && (calc.totalMinor() != legacy.Invoice.TotalCents || calc.Charges.TotalMicro != legacy.Overage.TotalMicroEUR) { |
| + t.Errorf("EUR from the book: %d minor, legacy %d", calc.totalMinor(), legacy.Invoice.TotalCents) |
| + } |
| + } |
| + // A house month is waived in any currency. |
| + house, err := priceBillingCalculation(statement, monthHistory(bookTerms(plans.GBP), true)) |
| + if err != nil || !house.waived() || len(house.Lines) != 0 || house.totalMinor() != 0 { |
| + t.Fatalf("a house month in gbp: %+v %v", house, err) |
| + } |
| +} |
| + |
| +// A calculation read back from storage is acted on only if it is whole for |
| +// its version and names one currency throughout. |
| +func TestCalculationWellFormed(t *testing.T) { |
| + statement := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000}, DaysInMonth: 30, Closed: true} |
| + fresh := func(t *testing.T, terms plans.PriceTerms) billingCalculation { |
| + calc, err := priceBillingCalculation(statement, monthHistory(terms, false)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(calc) |
| + var copied billingCalculation |
| + if err := json.Unmarshal(raw, &copied); err != nil { |
| + t.Fatal(err) |
| + } |
| + return copied |
| + } |
| + legacy, gbp := plans.LegacyPriceTerms(plans.CurrentTermsVersion), bookTerms(plans.GBP) |
| + for name, tc := range map[string]struct { |
| + terms plans.PriceTerms |
| + spoil func(c *billingCalculation) |
| + }{ |
| + "v1 said to be in dollars": {legacy, func(c *billingCalculation) { c.Currency = "usd" }}, |
| + "v1 with a book": {legacy, func(c *billingCalculation) { c.PriceBook = plans.CurrentPriceBook }}, |
| + "v1 with no invoice": {legacy, func(c *billingCalculation) { c.Invoice = nil }}, |
| + "v1 whose allowance names a currency": {legacy, func(c *billingCalculation) { c.Allowance.Currency = plans.GBP }}, |
| + "v1 carrying v2 money": {legacy, func(c *billingCalculation) { c.Total = &plans.Invoice{Currency: plans.EUR} }}, |
| + "v2 said to be in another currency": {gbp, func(c *billingCalculation) { c.Currency = "eur" }}, |
| + "v2 whose total is in another": {gbp, func(c *billingCalculation) { c.Total.Currency = plans.USD }}, |
| + "v2 whose charges are in another": {gbp, func(c *billingCalculation) { c.Charges.Currency = plans.USD }}, |
| + "v2 whose allowance is in another": {gbp, func(c *billingCalculation) { c.Allowance.Currency = plans.USD }}, |
| + "v2 from another book than it says": {gbp, func(c *billingCalculation) { c.PriceBook = "2031-01-01-v9" }}, |
| + "v2 with no charges": {gbp, func(c *billingCalculation) { c.Charges = nil }}, |
| + "v2 with no total": {gbp, func(c *billingCalculation) { c.Total = nil }}, |
| + "v2 carrying v1 money": {gbp, func(c *billingCalculation) { c.Invoice = &plans.InvoiceResult{} }}, |
| + "v2 relabelled as v1": {gbp, func(c *billingCalculation) { c.V = 1 }}, |
| + "v1 relabelled as v2": {legacy, func(c *billingCalculation) { c.V = 2 }}, |
| + "a version nobody wrote": {gbp, func(c *billingCalculation) { c.V = 3 }}, |
| + "v2 in a currency the book lacks": {gbp, func(c *billingCalculation) { |
| + c.Currency, c.Allowance.Currency, c.Charges.Currency, c.Total.Currency = "jpy", "jpy", "jpy", "jpy" |
| + }}, |
| + } { |
| + calc := fresh(t, tc.terms) |
| + if !calc.wellFormed() { |
| + t.Fatalf("%s: fixture: the calculation is not well formed before it is spoiled", name) |
| + } |
| + tc.spoil(&calc) |
| + if calc.wellFormed() { |
| + t.Errorf("%s: accepted", name) |
| + } |
| + } |
| +} |
| + |
| +// The overage invoice for a month bought in pounds is created, lined and |
| +// verified in pounds, end to end through the engine. A provider that |
| +// answers in another currency at any step stops it, and a subscription |
| +// the provider reports in another currency stops it before anything is |
| +// written. |
| +func TestInvoiceEngineInvoicesInTheSubscriptionsCurrency(t *testing.T) { |
| + inPounds := func(t *testing.T) *invoiceFixture { |
| + ix := newInvoiceFixture(t) |
| + // The fixture's account bought in pounds from the current book. |
| + for _, table := range []string{"billing_accounts", "billing_assignments"} { |
| + if _, err := ix.st.db.Exec(`UPDATE `+table+` SET currency='gbp', price_book=? WHERE account_id=?`, plans.CurrentPriceBook, ix.acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + ix.fake.subscriptionCurrency = "gbp" |
| + return ix |
| + } |
| + t.Run("in pounds throughout", func(t *testing.T) { |
| + ix := inPounds(t) |
| + want := ix.expectedCalc(t) |
| + if want.V != 2 || want.Currency != "gbp" || want.waived() { |
| + t.Fatalf("fixture: the month's calculation: %+v", want) |
| + } |
| + if err := ix.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := ix.month(t) |
| + var frozen billingCalculation |
| + if err := json.Unmarshal(rec.Calculation, &frozen); err != nil || rec.State != "complete" || !frozen.wellFormed() || frozen.Currency != "gbp" || frozen.totalMinor() != want.totalMinor() { |
| + t.Fatalf("month %+v, calculation %+v %v", rec, frozen, err) |
| + } |
| + if len(ix.fake.currencies) < 2 { |
| + t.Fatalf("requests that named a currency: %v", ix.fake.currencies) |
| + } |
| + for kind, c := range ix.fake.currencies { |
| + if c != "gbp" { |
| + t.Errorf("the %s request was made in %q", kind, c) |
| + } |
| + } |
| + inv := ix.fake.invoices[rec.InvoiceID] |
| + var sum int64 |
| + for _, l := range inv.lines { |
| + sum += l.amount |
| + if l.currency != "gbp" { |
| + t.Errorf("a line was created in %q", l.currency) |
| + } |
| + } |
| + if inv.currency != "gbp" || sum != want.totalMinor() { |
| + t.Fatalf("the invoice: %s, %d pence; want gbp, %d", inv.currency, sum, want.totalMinor()) |
| + } |
| + }) |
| + for name, spoil := range map[string]func(ix *invoiceFixture){ |
| + "the draft comes back in euros": func(ix *invoiceFixture) { ix.fake.reportInvoiceCurrency = "eur" }, |
| + "an item comes back in euros": func(ix *invoiceFixture) { ix.fake.reportItemCurrency = "eur" }, |
| + "a line reads back in euros": func(ix *invoiceFixture) { ix.fake.reportLineCurrency = "eur" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ix := inPounds(t) |
| + spoil(ix) |
| + err := ix.run(t) |
| + rec := ix.month(t) |
| + if rec.State == "complete" || rec.State == "paying" || rec.State == "open" { |
| + t.Fatalf("the month reached %q with a provider answering in another currency (run: %v)", rec.State, err) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); mutations["finalize"] != 0 || mutations["pay"] != 0 { |
| + t.Fatalf("the invoice was finalized or paid: %v", mutations) |
| + } |
| + }) |
| + } |
| + t.Run("the subscription is reported in euros", func(t *testing.T) { |
| + ix := inPounds(t) |
| + ix.fake.subscriptionCurrency = "eur" |
| + if err := ix.run(t); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("run: %v, want errBillingIdentity", err) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 { |
| + t.Fatalf("something was written: %v", mutations) |
| + } |
| + }) |
| + t.Run("the subscription reports no currency", func(t *testing.T) { |
| + ix := inPounds(t) |
| + ix.fake.subscriptionCurrency = "" |
| + if err := ix.run(t); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("run: %v, want errBillingIdentity", err) |
| + } |
| + }) |
| + // A month frozen in pounds whose assignment row has since been altered |
| + // to another currency is not invoiced in either. |
| + t.Run("the recorded assignment no longer matches the frozen month", func(t *testing.T) { |
| + ix := inPounds(t) |
| + ix.fake.dropRequest["draft"] = true |
| + _ = ix.run(t) |
| + if rec := ix.month(t); rec.Digest == "" { |
| + t.Fatal("fixture: the month was not frozen") |
| + } |
| + // Only the recorded assignment changes. The provider still reports |
| + // the subscription in pounds, so nothing but the comparison of the |
| + // frozen month with the record can notice. |
| + if _, err := ix.st.db.Exec(`UPDATE billing_assignments SET currency='usd' WHERE account_id=?`, ix.acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + ix.now = ix.now.Add(time.Minute) |
| + _ = ix.run(t) |
| + if rec := ix.month(t); rec.State == "complete" || rec.InvoiceID != "" { |
| + t.Fatalf("a month frozen in pounds went ahead after its assignment changed currency: %+v", rec) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 { |
| + t.Fatalf("something was written: %v", mutations) |
| + } |
| + }) |
| +} |
| + |
| +// A request's currency is part of its hash. A legacy month's requests |
| +// hash exactly as they did before a currency was a parameter, so an |
| +// operation journaled by an earlier build is recognised; they are still |
| +// sent naming EUR. |
| +func TestInvoiceRequestHashAndCurrency(t *testing.T) { |
| + type draftBefore struct { |
| + Customer, Subscription, SubscriptionRef, Account, Month, CalculationDigest string |
| + } |
| + type itemBefore struct { |
| + Customer, Invoice, Meter, TaxCode, Description, CalculationDigest string |
| + AmountCents, PeriodStart, PeriodEnd int64 |
| + } |
| + run := func(v int, currency string) *billingInvoiceRun { |
| + return &billingInvoiceRun{ |
| + acct: &Account{Sqid: "bab"}, month: "2026-09", start: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), end: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), |
| + rec: billingMonthRecord{Customer: "cus_hist", Subscription: "sub_hist", InvoiceID: "in_usage", Digest: strings.Repeat("a", 64), LinkSubscription: true}, |
| + calc: billingCalculation{V: v, Currency: currency, TaxCode: "txcd_test"}, |
| + } |
| + } |
| + legacy := run(1, "eur") |
| + draft := legacy.draftParams() |
| + line, err := legacy.lineParams(plans.OverageLine{Meter: usage.MeterInstanceHours, AmountCents: 915}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if draft.Currency != "eur" || line.Currency != "eur" { |
| + t.Fatalf("a legacy month's requests name %q and %q", draft.Currency, line.Currency) |
| + } |
| + if got, was := legacy.requestHash("draft", draft), billingRequestHash("draft", draftBefore{draft.Customer, draft.Subscription, draft.SubscriptionRef, draft.Account, draft.Month, draft.CalculationDigest}); got != was { |
| + t.Errorf("a legacy draft hashes as %s and used to hash as %s", got, was) |
| + } |
| + if got, was := legacy.requestHash("line-x", line), billingRequestHash("line-x", itemBefore{line.Customer, line.Invoice, line.Meter, line.TaxCode, line.Description, line.CalculationDigest, line.AmountCents, line.PeriodStart, line.PeriodEnd}); got != was { |
| + t.Errorf("a legacy line hashes as %s and used to hash as %s", got, was) |
| + } |
| + hashes := map[string]string{"legacy": legacy.requestHash("draft", draft)} |
| + for _, c := range []string{"usd", "eur", "gbp"} { |
| + r := run(2, c) |
| + p := r.draftParams() |
| + if p.Currency != c { |
| + t.Fatalf("a %s month's draft names %q", c, p.Currency) |
| + } |
| + h := r.requestHash("draft", p) |
| + for other, seen := range hashes { |
| + if seen == h { |
| + t.Errorf("a %s draft hashes like a %s one", c, other) |
| + } |
| + } |
| + hashes[c] = h |
| + } |
| +} |
| + |
| +// The quantity worker reads the price for the account's own stored book and |
| +// currency, and a subscription the provider reports in another currency is |
| +// not one it acts on. |
| +func TestQuantityWorkerChecksTheStoredCurrency(t *testing.T) { |
| + fx, f, acct, _ := buyIn(t, "GB", "") |
| + state, _ := fx.st.billingAccount(acct.ID) |
| + if _, ok, err := fx.srv.quantityProviderItem(context.Background(), acct, state); err != nil || !ok { |
| + t.Fatalf("the account's own subscription: ok=%v err=%v", ok, err) |
| + } |
| + f.mu.Lock() |
| + f.subscriptionCurrency = "eur" |
| + f.mu.Unlock() |
| + if _, _, err := fx.srv.quantityProviderItem(context.Background(), acct, state); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("a subscription reported in another currency: %v", err) |
| + } |
| + f.mu.Lock() |
| + f.subscriptionCurrency, f.subscriptionPrice = "", currencyPriceID(plans.PlanPersonal, state.Band, plans.EUR) |
| + f.mu.Unlock() |
| + if _, _, err := fx.srv.quantityProviderItem(context.Background(), acct, state); !errors.Is(err, errBillingIdentity) { |
| + t.Fatalf("a subscription on another currency's price: %v", err) |
| + } |
| +} |
| + |
| +// A valid ?currency= on a GET is kept in a short-lived cookie so it |
| +// survives the sign-in redirects. It is a suggestion: an invalid value is |
| +// dropped, never stored, and a cookie somebody forged is not believed. |
| +func TestCurrencySuggestionCookie(t *testing.T) { |
| + fx, _, _ := newCurrencyFixture(t) |
| + cookie := func(method, target string) *http.Cookie { |
| + w := httptest.NewRecorder() |
| + fx.srv.ServeHTTP(w, httptest.NewRequest(method, target, nil)) |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == currencyCookie { |
| + return c |
| + } |
| + } |
| + return nil |
| + } |
| + for _, target := range []string{"/?currency=gbp", "/auth/signin?currency=gbp", fx.path("/billing") + "?currency=gbp&x=1"} { |
| + c := cookie(http.MethodGet, target) |
| + if c == nil || c.Value != "gbp" || c.MaxAge != int(currencyCookieTTL.Seconds()) || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { |
| + t.Errorf("GET %s: cookie %+v", target, c) |
| + } |
| + } |
| + for _, target := range []string{"/", "/?currency=", "/?currency=jpy", "/?currency=GBP", "/?currency=gbp¤cy=usd", "/?currency=gbp%20"} { |
| + if c := cookie(http.MethodGet, target); c != nil { |
| + t.Errorf("GET %s set %+v", target, c) |
| + } |
| + } |
| + if c := cookie(http.MethodPost, "/?currency=gbp"); c != nil { |
| + t.Errorf("a POST set %+v", c) |
| + } |
| + suggest := func(srv *Server, value string) string { |
| + r := httptest.NewRequest(http.MethodGet, "/", nil) |
| + if value != "-" { |
| + r.AddCookie(&http.Cookie{Name: currencyCookie, Value: value}) |
| + } |
| + return srv.suggestedCurrency(r) |
| + } |
| + for value, want := range map[string]string{"gbp": "gbp", "usd": "usd", "eur": "eur", "jpy": "", "GBP": "", "": "", "-": ""} { |
| + if got := suggest(fx.srv, value); got != want { |
| + t.Errorf("cookie %q suggests %q, want %q", value, got, want) |
| + } |
| + } |
| + if got := strings.Join(fx.srv.billingCurrencies(), ","); got != "usd,eur,gbp" { |
| + t.Errorf("currencies offered: %s", got) |
| + } |
| + // A console selling the old way offers and remembers EUR alone, and |
| + // one with billing off offers and remembers nothing. |
| + old, _, _ := newBillingFixture(t) |
| + if got := strings.Join(old.srv.billingCurrencies(), ","); got != "eur" || suggest(old.srv, "gbp") != "" || suggest(old.srv, "eur") != "eur" { |
| + t.Errorf("a console with no book: offers %q", got) |
| + } |
| + off := newConsoleFixture(t) |
| + if len(off.srv.billingCurrencies()) != 0 || suggest(off.srv, "eur") != "" { |
| + t.Error("a console with billing off offers a currency") |
| + } |
| +} |
| + |
| +// The columns are added to a database from before them, and every row |
| +// already there reads as what it is: a purchase with no book, in EUR. |
| +func TestCurrencyColumnsOnAnExistingDatabase(t *testing.T) { |
| + fx, _, acct := newBillingFixture(t) |
| + st := fx.st |
| + at := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| + if err := st.applyBillingState(billingState{AccountID: acct.ID, Customer: "cus_one", Subscription: "sub_a", Status: "active", Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Quantity: 1, PeriodStart: at.Unix(), PeriodEnd: at.AddDate(1, 0, 0).Unix()}, 0, "e1", at); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, table := range []string{"billing_accounts", "billing_checkout_intents", "billing_assignments"} { |
| + for _, column := range []string{"currency", "price_book"} { |
| + if _, err := st.db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN ` + column); err != nil { |
| + t.Fatalf("fixture: drop %s.%s: %v", table, column, err) |
| + } |
| + } |
| + } |
| + if err := st.migrateBilling(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.migrateBilling(); err != nil { |
| + t.Fatalf("a second migration: %v", err) |
| + } |
| + state, err := st.billingAccount(acct.ID) |
| + if err != nil || state.Subscription != "sub_a" || state.Currency != "" || state.PriceBook != "" || !state.priceTerms().Legacy() || state.priceTerms().Money() != plans.EUR { |
| + t.Fatalf("the row from before: %+v %v", state, err) |
| + } |
| + rows, err := st.billingInvoiceHistory(acct.ID) |
| + if err != nil || len(rows) != 1 || rows[0].Currency != "" || rows[0].PriceBook != "" { |
| + t.Fatalf("its assignment: %+v %v", rows, err) |
| + } |
| + // And it is still the same subscription when read again as legacy. |
| + next := state |
| + next.Status = "past_due" |
| + if err := st.applyBillingState(next, state.Revision, "e2", at.AddDate(0, 0, 3)); err != nil { |
| + t.Fatalf("a legacy row updated after the migration: %v", err) |
| + } |
| + // Written with an explicit "eur" and no book, it is stored the one way. |
| + next.Status, next.Currency = "active", "eur" |
| + if err := st.applyBillingState(next, state.Revision+1, "e3", at.AddDate(0, 0, 4)); err != nil { |
| + t.Fatalf("a legacy row written with eur: %v", err) |
| + } |
| + if after, _ := st.billingAccount(acct.ID); after.Currency != "" || after.Status != "active" { |
| + t.Fatalf("stored as %+v", after) |
| + } |
| +} |