A price book: fixed prices in USD, EUR and GBP, one stored currency per purchase, and a generated public export
internal/plans gains an embedded, versioned price book beside the terms. It holds the 36 per-member annual prices written out, validated on load against the one rounding rule (base times band factor, half up, per member, before quantity), with overage rates and an invoice floor for each currency. A book's EUR must equal the terms' own EUR. PriceTerms (terms version, book, currency) is the stored identity of a purchase. PriceFor, OverageFor and OverageInvoiceFor price from it and never convert: charges in one currency are refused an invoice in another. A row with no book is legacy EUR and gets the numbers it always did. Assignments and month allowances carry the book and currency, omit them for legacy rows so existing snapshots marshal byte for byte, and a month whose paid spans disagree on either is refused. ChooseCurrency: an explicit currency is taken or refused, never replaced by a default; with none, EU member states get EUR, GB gets GBP, all else USD. Allowances are identical in every currency. PublicPriceBook and cmd/pricebook-export generate the site's price data; the committed copy is checked by a test. The amounts are a draft, the book says so, and nothing reads it at checkout yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+1781
−25
cmd/pricebook-export/main.go+36 −0docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md+3 −3internal/plans/lines.go+30 −19internal/plans/month.go+41 −3internal/plans/pricebook.go+682 −0internal/plans/pricebook.json+43 −0internal/plans/pricebook_test.go+532 −0internal/plans/public-pricebook.json+414 −0
CI passed — run details
diff --git a/cmd/pricebook-export/main.go b/cmd/pricebook-export/main.go| new file mode 100644 |
| index 000000000..23e7de889 |
| --- /dev/null |
| +++ b/cmd/pricebook-export/main.go |
| @@ -0,0 +1,36 @@ |
| +// Command pricebook-export writes the public price book the pricing site |
| +// formats from (plans.PublicPriceBook): every price, rate and default the |
| +// page shows, generated from the embedded terms, price book and country |
| +// bands, so that no figure is typed twice. |
| +// |
| +// go run ./cmd/pricebook-export # the current book, to stdout |
| +// go run ./cmd/pricebook-export -o out.json # to a file |
| +// go run ./cmd/pricebook-export -book <version> |
| +package main |
| + |
| +import ( |
| + "flag" |
| + "fmt" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func main() { |
| + book := flag.String("book", plans.CurrentPriceBook, "price book version") |
| + out := flag.String("o", "", "write to this file instead of stdout") |
| + flag.Parse() |
| + raw, err := plans.PublicPriceBook(*book) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "pricebook-export:", err) |
| + os.Exit(1) |
| + } |
| + if *out == "" { |
| + os.Stdout.Write(raw) |
| + return |
| + } |
| + if err := os.WriteFile(*out, raw, 0o644); err != nil { |
| + fmt.Fprintln(os.Stderr, "pricebook-export:", err) |
| + os.Exit(1) |
| + } |
| +} |
diff --git a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md| index 68fdc22da..caebfd93d 100644 |
| --- a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md |
| +++ b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md |
| @@ -18,7 +18,7 @@ It amends the approved pricing spec (`2026-09-07-carloku-pricing-plans-design.md |
| A price book is embedded, versioned, immutable once published, and separate from the terms version. Terms (`plans.json`) keep the plans' resources, quantities and band factors. A book adds money: for each plan, band and currency, the price of one member for one year in minor units; for each currency, the four overage rates and the floor under which an overage invoice is waived. |
| -`internal/plans/pricebook.json`, one object per book: |
| +`internal/plans/pricebook.json` holds `{"books": [...]}`, one object per book: |
| ```json |
| { |
| @@ -132,7 +132,7 @@ type Invoice struct { |
| // currency, rounds once, and splits the total into lines that sum to it. |
| func OverageInvoiceFor(t PriceTerms, c Charges, house bool) (*Invoice, []OverageLine, error) |
| -func BookApproved(book string) (bool, error) |
| +func BookIsApproved(book string) (bool, error) |
| func PublicPriceBook(book string) ([]byte, error) // the export below, byte-stable |
| ``` |
| @@ -161,7 +161,7 @@ Interface for the billing page (the layout branch owns the template): the page v |
| ## Public export for the site |
| -`PublicPriceBook` renders this from the embedded terms, book and country bands. `go run ./cmd/pricebook-export` writes it; a test fails if the committed copy differs. The site formats from it and holds no price of its own. |
| +`PublicPriceBook` renders this from the embedded terms, book and country bands. `go run ./cmd/pricebook-export` writes it (`-o <file>`, `-book <version>`); the committed copy is `internal/plans/public-pricebook.json`, and `TestPublicPriceBookExport` fails if it differs from what the build generates. The site formats from it and holds no price of its own. |
| ```json |
| { |
diff --git a/internal/plans/lines.go b/internal/plans/lines.go| index 04c1e2204..825d5407f 100644 |
| --- a/internal/plans/lines.go |
| +++ b/internal/plans/lines.go |
| @@ -41,40 +41,52 @@ type OverageLine struct { |
| // surviving lines keep the fixed axis order. |
| func InvoiceOverageLines(overage OverageResult, house bool) (*InvoiceResult, []OverageLine, error) { |
| raw := [4]int64{overage.AwakeMicroEUR, overage.StorageMicroEUR, overage.TransferMicroEUR, overage.WritesMicroEUR} |
| + total, waived, lines, err := overageLines(raw, overage.TotalMicroEUR, invoiceFloorMicroEUR, house) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if waived { |
| + return &InvoiceResult{Waived: true}, nil, nil |
| + } |
| + return &InvoiceResult{OverageMicroEUR: overage.TotalMicroEUR, TotalMicroEUR: overage.TotalMicroEUR, TotalCents: total}, lines, nil |
| +} |
| + |
| +// overageLines is the arithmetic of a standalone overage invoice in any |
| +// currency whose minor unit is a hundredth: raw is the four axes' costs in |
| +// micros, total their sum, floor the amount below which the invoice is |
| +// waived. It answers with the invoice's total in minor units and its |
| +// lines, or that it is waived (house, or below the floor). |
| +func overageLines(raw [4]int64, total, floor int64, house bool) (totalMinor int64, waived bool, lines []OverageLine, err error) { |
| for _, v := range raw { |
| if v < 0 { |
| - return nil, nil, ErrNegativeInput |
| + return 0, false, nil, ErrNegativeInput |
| } |
| } |
| sum, err := safeAddChecked(raw[:]...) |
| if err != nil { |
| - return nil, nil, err |
| + return 0, false, nil, err |
| } |
| - if sum != overage.TotalMicroEUR { |
| - return nil, nil, ErrInvalidOverage |
| + if sum != total { |
| + return 0, false, nil, ErrInvalidOverage |
| } |
| - |
| - invoice, err := OverageInvoiceTotal(overage.TotalMicroEUR, house) |
| - if err != nil { |
| - return nil, nil, err |
| - } |
| - if invoice.Waived { |
| - return invoice, nil, nil |
| + if house || total < floor { |
| + return 0, true, nil, nil |
| } |
| + totalMinor = roundHalfUpDiv(total, microPerMinor) |
| var floorCents, remainder [4]int64 |
| for i, v := range raw { |
| - floorCents[i] = v / microEURPerCent |
| - remainder[i] = v % microEURPerCent |
| + floorCents[i] = v / microPerMinor |
| + remainder[i] = v % microPerMinor |
| } |
| floorSum, err := safeAddChecked(floorCents[:]...) |
| if err != nil { |
| - return nil, nil, err |
| + return 0, false, nil, err |
| } |
| - extra := invoice.TotalCents - floorSum |
| + extra := totalMinor - floorSum |
| if extra < 0 || extra > int64(len(raw)) { |
| - return nil, nil, ErrInvalidOverage |
| + return 0, false, nil, ErrInvalidOverage |
| } |
| order := []int{0, 1, 2, 3} |
| @@ -87,15 +99,14 @@ func InvoiceOverageLines(overage OverageResult, house bool) (*InvoiceResult, []O |
| finalCents[order[k]]++ |
| } |
| - lines := make([]OverageLine, 0, len(raw)) |
| + lines = make([]OverageLine, 0, len(raw)) |
| for i, cents := range finalCents { |
| if cents == 0 { |
| continue |
| } |
| lines = append(lines, OverageLine{Meter: meterOrder[i], AmountCents: cents}) |
| } |
| - |
| - return invoice, lines, nil |
| + return totalMinor, false, lines, nil |
| } |
| // safeAddChecked sums non-negative int64s, erroring on negative input or |
diff --git a/internal/plans/month.go b/internal/plans/month.go| index 3807fe81d..696df50d2 100644 |
| --- a/internal/plans/month.go |
| +++ b/internal/plans/month.go |
| @@ -19,6 +19,17 @@ type Assignment struct { |
| House bool `json:"house"` |
| Status string `json:"status"` |
| PaidThrough time.Time `json:"paid_through"` |
| + // PriceBook and Currency are what the assignment was bought under |
| + // (PriceTerms). Both are empty for an assignment recorded before price |
| + // books existed, which is EUR under the terms' own figures; such an |
| + // assignment marshals exactly as it did before these fields existed. |
| + PriceBook string `json:"price_book,omitempty"` |
| + Currency Currency `json:"currency,omitempty"` |
| +} |
| + |
| +// PriceTerms is the commercial identity the assignment was bought under. |
| +func (a Assignment) PriceTerms() PriceTerms { |
| + return PriceTerms{TermsVersion: a.TermsVersion, PriceBook: a.PriceBook, Currency: a.Currency} |
| } |
| // CoveredInterval is one span of a month during which a paid plan was in |
| @@ -44,6 +55,16 @@ type MonthAllowance struct { |
| // last, which reads exactly as it always has. |
| Partial bool `json:"partial,omitempty"` |
| Covered []CoveredInterval `json:"covered,omitempty"` |
| + // PriceBook and Currency are the paid spans' own, which must all |
| + // agree: the month's usage is priced in that one currency from that |
| + // one book. Empty for a month of legacy assignments. |
| + PriceBook string `json:"price_book,omitempty"` |
| + Currency Currency `json:"currency,omitempty"` |
| +} |
| + |
| +// PriceTerms is the commercial identity the month's usage is priced under. |
| +func (m MonthAllowance) PriceTerms() PriceTerms { |
| + return PriceTerms{TermsVersion: m.TermsVersion, PriceBook: m.PriceBook, Currency: m.Currency} |
| } |
| // AllowanceForMonth resolves the pooled allowance a month's usage is set |
| @@ -60,8 +81,12 @@ type MonthAllowance struct { |
| // ends in, a lapse, a span not paid through. What is refused |
| // (ErrAssignmentHistory) is a history that cannot be read at all (empty, |
| // unordered, zero times or revisions), a month with no paid span, and paid |
| -// spans that disagree on the terms version or the house marker, whose |
| -// usage could not be priced on one basis. |
| +// spans that disagree on the terms version, the house marker, the price |
| +// book or the currency, whose usage could not be priced on one basis. A |
| +// month paid for partly in one currency and partly in another is never |
| +// priced in either, and nothing is converted. A paid span whose price |
| +// terms this build does not know (a book it does not have, a currency the |
| +// book does not sell in) is refused the same way. |
| func AllowanceForMonth(month string, history []Assignment) (*MonthAllowance, error) { |
| start, err := time.Parse("2006-01", month) |
| if err != nil || start.Format("2006-01") != month || len(history) == 0 { |
| @@ -129,9 +154,22 @@ func AllowanceForMonth(month string, history []Assignment) (*MonthAllowance, err |
| if paidHi.Before(hi) { |
| out.Partial = true |
| } |
| + // A legacy assignment written with an explicit "eur" and one |
| + // written with none are the same terms. |
| + currency := a.Currency |
| + if a.PriceBook == LegacyPriceBook && currency == EUR { |
| + currency = "" |
| + } |
| if !paid { |
| out.TermsVersion, out.House, paid = a.TermsVersion, a.House, true |
| - } else if a.TermsVersion != out.TermsVersion || a.House != out.House { |
| + out.PriceBook, out.Currency = a.PriceBook, currency |
| + } else if a.TermsVersion != out.TermsVersion || a.House != out.House || a.PriceBook != out.PriceBook || currency != out.Currency { |
| + return nil, ErrAssignmentHistory |
| + } |
| + if err := a.PriceTerms().Validate(); err != nil { |
| + if errors.Is(err, ErrUnknownTermsVersion) { |
| + return nil, err |
| + } |
| return nil, ErrAssignmentHistory |
| } |
| quote, err := Quote(a.TermsVersion, a.Plan, a.Band, a.Quantity) |
diff --git a/internal/plans/pricebook.go b/internal/plans/pricebook.go| new file mode 100644 |
| index 000000000..0f3dcd35f |
| --- /dev/null |
| +++ b/internal/plans/pricebook.go |
| @@ -0,0 +1,682 @@ |
| +package plans |
| + |
| +import ( |
| + "bytes" |
| + _ "embed" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "sort" |
| +) |
| + |
| +// Fixed prices in more than one currency |
| +// (docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md). |
| +// |
| +// The terms (plans.json) say what a plan is: its resources, its quantities, |
| +// the band factors. A price book says what it costs, in each currency it |
| +// is sold in. A book is embedded, versioned and immutable once published, |
| +// like a terms version, and it names the terms version it prices. |
| +// |
| +// Nothing is ever converted. A purchase is made in one currency, the |
| +// currency is stored with it (PriceTerms), and every amount for that |
| +// purchase, the subscription and each metered overage invoice, is read |
| +// from the book's figures for that currency. There is no exchange rate |
| +// anywhere in this package. |
| +// |
| +// A row written before books existed has no book and no currency. That is |
| +// LegacyPriceBook: EUR, priced from the terms' own micro-EUR figures by the |
| +// arithmetic that was there before (Quote, CalculateOverage, |
| +// InvoiceOverageLines). The functions here give the same numbers for it, |
| +// and a book's EUR figures are required to equal the terms' own, so an |
| +// account priced the old way and one priced in EUR from a book on the same |
| +// terms pay the same. |
| + |
| +//go:embed pricebook.json |
| +var priceBookJSON []byte |
| + |
| +// Currency is a lower-case ISO 4217 code, as Stripe writes it. |
| +type Currency string |
| + |
| +const ( |
| + USD Currency = "usd" |
| + EUR Currency = "eur" |
| + GBP Currency = "gbp" |
| +) |
| + |
| +var ( |
| + ErrUnknownCurrency = errors.New("plans: unknown_currency") |
| + ErrUnknownPriceBook = errors.New("plans: unknown_price_book") |
| + ErrCurrencyMismatch = errors.New("plans: currency_mismatch") |
| + // ErrPriceNotWhole: a legacy price that is not a whole number of minor |
| + // units for one member. No published terms produce one. |
| + ErrPriceNotWhole = errors.New("plans: price_not_whole_minor_units") |
| +) |
| + |
| +const ( |
| + // LegacyPriceBook is the book of a purchase recorded before books |
| + // existed: none. It means EUR, from the terms' own figures. |
| + LegacyPriceBook = "" |
| + // CurrentPriceBook is the book new purchases are priced from. |
| + CurrentPriceBook = "2026-10-01-draft1" |
| + |
| + BookDraft = "draft" |
| + BookApproved = "approved" |
| +) |
| + |
| +// supportedCurrencies is every currency this build can price in, with what |
| +// a display needs. All three have two decimal places; a micro is one |
| +// millionth of the major unit, so there are 10,000 micros in a minor unit. |
| +// A currency with another number of decimals cannot be added by editing a |
| +// book: the arithmetic here would have to learn it first. |
| +var supportedCurrencies = map[Currency]struct { |
| + Symbol string |
| + MinorPerMajor int64 |
| +}{ |
| + USD: {"$", 100}, |
| + EUR: {"€", 100}, |
| + GBP: {"£", 100}, |
| +} |
| + |
| +const microPerMinor = int64(10000) |
| + |
| +var paidPlans = []string{PlanPersonal, PlanCommunity, PlanBusiness} |
| +var bandOrder = []string{BandA, BandB, BandC, BandD} |
| + |
| +type bookDoc struct { |
| + Version string `json:"version"` |
| + TermsVersion string `json:"terms_version"` |
| + Status string `json:"status"` |
| + DefaultCurrency Currency `json:"default_currency"` |
| + Currencies []Currency `json:"currencies"` |
| + CountryCurrency map[Currency][]string `json:"country_currency"` |
| + AnnualBaseMinor map[string]map[Currency]int64 `json:"annual_base_minor"` |
| + AnnualUnitMinor map[string]map[Currency]map[string]int64 `json:"annual_unit_minor"` |
| + OverageRatesMicro map[Currency]OverageRates `json:"overage_rates_micro"` |
| + OverageFloorMicro map[Currency]int64 `json:"overage_floor_micro"` |
| + |
| + byCountry map[string]Currency |
| +} |
| + |
| +type booksDoc struct { |
| + Books []bookDoc `json:"books"` |
| +} |
| + |
| +var priceBooks map[string]*bookDoc |
| + |
| +func init() { |
| + // The terms are needed to validate a book, and package-level init |
| + // order across files is not something to lean on: decode them here if |
| + // plans.go's init has not run yet. |
| + t := terms |
| + if t.Version == "" { |
| + decoded, err := decodeTerms(plansJSON) |
| + if err != nil { |
| + panic("plans: invalid embedded plans.json: " + err.Error()) |
| + } |
| + t = *decoded |
| + } |
| + books, err := decodePriceBooks(priceBookJSON, &t) |
| + if err != nil { |
| + panic("plans: invalid embedded pricebook.json: " + err.Error()) |
| + } |
| + if _, ok := books[CurrentPriceBook]; !ok { |
| + panic("plans: pricebook.json has no book " + CurrentPriceBook) |
| + } |
| + priceBooks = books |
| +} |
| + |
| +// decodePriceBooks strictly decodes and validates the embedded books |
| +// against the terms they price: an unknown field, trailing content, a |
| +// missing price, or a price that is not what the book's own rule gives, |
| +// fails here and not at a checkout. |
| +func decodePriceBooks(data []byte, t *termsDoc) (map[string]*bookDoc, error) { |
| + dec := json.NewDecoder(bytes.NewReader(data)) |
| + dec.DisallowUnknownFields() |
| + var doc booksDoc |
| + if err := dec.Decode(&doc); err != nil { |
| + return nil, err |
| + } |
| + if err := dec.Decode(new(json.RawMessage)); err != io.EOF { |
| + return nil, errors.New("trailing content after JSON document") |
| + } |
| + if len(doc.Books) == 0 { |
| + return nil, errors.New("no books") |
| + } |
| + out := map[string]*bookDoc{} |
| + for i := range doc.Books { |
| + b := &doc.Books[i] |
| + if b.Version == "" || b.Version == LegacyPriceBook { |
| + return nil, errors.New("a book has no version") |
| + } |
| + if out[b.Version] != nil { |
| + return nil, fmt.Errorf("book %q appears twice", b.Version) |
| + } |
| + if err := validateBook(b, t); err != nil { |
| + return nil, fmt.Errorf("book %q: %w", b.Version, err) |
| + } |
| + out[b.Version] = b |
| + } |
| + return out, nil |
| +} |
| + |
| +func validateBook(b *bookDoc, t *termsDoc) error { |
| + if b.TermsVersion != t.Version { |
| + return fmt.Errorf("terms_version %q is not the embedded terms %q", b.TermsVersion, t.Version) |
| + } |
| + if b.Status != BookDraft && b.Status != BookApproved { |
| + return fmt.Errorf("status %q is neither draft nor approved", b.Status) |
| + } |
| + if len(b.Currencies) == 0 { |
| + return errors.New("no currencies") |
| + } |
| + sold := map[Currency]bool{} |
| + for _, c := range b.Currencies { |
| + if _, ok := supportedCurrencies[c]; !ok { |
| + return fmt.Errorf("currency %q is not supported by this build", c) |
| + } |
| + if sold[c] { |
| + return fmt.Errorf("currency %q is listed twice", c) |
| + } |
| + sold[c] = true |
| + } |
| + if !sold[b.DefaultCurrency] { |
| + return fmt.Errorf("default_currency %q is not one of the book's currencies", b.DefaultCurrency) |
| + } |
| + b.byCountry = map[string]Currency{} |
| + for c, countries := range b.CountryCurrency { |
| + if !sold[c] { |
| + return fmt.Errorf("country_currency names %q, which the book does not sell in", c) |
| + } |
| + if c == b.DefaultCurrency { |
| + return fmt.Errorf("country_currency lists the default currency %q: the default needs no list", c) |
| + } |
| + for _, country := range countries { |
| + if len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' { |
| + return fmt.Errorf("country_currency[%q] has %q, which is not an ISO 3166-1 alpha-2 code", c, country) |
| + } |
| + if other, dup := b.byCountry[country]; dup { |
| + return fmt.Errorf("country %s is given both %q and %q", country, other, c) |
| + } |
| + b.byCountry[country] = c |
| + } |
| + } |
| + if len(b.AnnualBaseMinor) != len(paidPlans) || len(b.AnnualUnitMinor) != len(paidPlans) { |
| + return fmt.Errorf("prices must cover exactly the %d paid plans", len(paidPlans)) |
| + } |
| + for _, plan := range paidPlans { |
| + pt, ok := t.Plans[plan] |
| + if !ok { |
| + return fmt.Errorf("the terms have no plan %q", plan) |
| + } |
| + base, units := b.AnnualBaseMinor[plan], b.AnnualUnitMinor[plan] |
| + if len(base) != len(sold) || len(units) != len(sold) { |
| + return fmt.Errorf("plan %q must be priced in exactly the book's currencies", plan) |
| + } |
| + for c := range sold { |
| + if base[c] <= 0 { |
| + return fmt.Errorf("annual_base_minor[%q][%q] must be positive", plan, c) |
| + } |
| + if len(units[c]) != len(bandOrder) { |
| + return fmt.Errorf("annual_unit_minor[%q][%q] must have exactly the %d bands", plan, c, len(bandOrder)) |
| + } |
| + for _, band := range bandOrder { |
| + got, ok := units[c][band] |
| + if !ok { |
| + return fmt.Errorf("annual_unit_minor[%q][%q] has no band %q", plan, c, band) |
| + } |
| + // The one rounding rule: the base times the band's factor, |
| + // half up to a whole minor unit, for one member, before |
| + // any quantity. |
| + want := base[c] |
| + if pt.BandScaled { |
| + want = roundHalfUpDiv(base[c]*t.BandFactorsBps[band], bpsScale) |
| + } |
| + if got != want { |
| + return fmt.Errorf("annual_unit_minor[%q][%q][%q] = %d, and the rounding rule gives %d", plan, c, band, got, want) |
| + } |
| + if got <= 0 { |
| + return fmt.Errorf("annual_unit_minor[%q][%q][%q] must be positive", plan, c, band) |
| + } |
| + } |
| + } |
| + } |
| + if len(b.OverageRatesMicro) != len(sold) || len(b.OverageFloorMicro) != len(sold) { |
| + return errors.New("overage rates and floors must cover exactly the book's currencies") |
| + } |
| + for c := range sold { |
| + r, ok := b.OverageRatesMicro[c] |
| + if !ok || r.AwakeHour <= 0 || r.StorageGBMonth <= 0 || r.TransferGB <= 0 || r.Writes1000 <= 0 { |
| + return fmt.Errorf("overage_rates_micro[%q] must have four positive rates", c) |
| + } |
| + if b.OverageFloorMicro[c] <= 0 { |
| + return fmt.Errorf("overage_floor_micro[%q] must be positive", c) |
| + } |
| + } |
| + // A book's EUR is the terms' EUR. An account priced before books |
| + // existed and one priced in EUR from this book, on the same terms, |
| + // must pay the same for the same thing. |
| + if sold[EUR] { |
| + for _, plan := range paidPlans { |
| + pt := t.Plans[plan] |
| + for _, band := range bandOrder { |
| + bps := t.BandFactorsBps[band] |
| + if !pt.BandScaled { |
| + bps = bpsScale |
| + } |
| + legacy, err := mulDivFloor(pt.AnnualBaseMicroEUR, bps, bpsScale) |
| + if err != nil { |
| + return err |
| + } |
| + if legacy%microPerMinor != 0 || legacy/microPerMinor != b.AnnualUnitMinor[plan][EUR][band] { |
| + return fmt.Errorf("annual_unit_minor[%q][eur][%q] differs from the terms' own EUR price", plan, band) |
| + } |
| + } |
| + } |
| + if b.OverageRatesMicro[EUR] != t.OverageRatesMicroEUR { |
| + return errors.New("overage_rates_micro[eur] differs from the terms' own EUR rates") |
| + } |
| + if b.OverageFloorMicro[EUR] != invoiceFloorMicroEUR { |
| + return errors.New("overage_floor_micro[eur] differs from the terms' own EUR floor") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ParseCurrency accepts exactly "usd", "eur" or "gbp". Anything else, the |
| +// empty string and another letter case included, is ErrUnknownCurrency: a |
| +// currency somebody asked for is never replaced by one they did not. |
| +func ParseCurrency(s string) (Currency, error) { |
| + c := Currency(s) |
| + if _, ok := supportedCurrencies[c]; !ok { |
| + return "", ErrUnknownCurrency |
| + } |
| + return c, nil |
| +} |
| + |
| +func lookupBook(book string) (*bookDoc, error) { |
| + b, ok := priceBooks[book] |
| + if !ok { |
| + return nil, ErrUnknownPriceBook |
| + } |
| + return b, nil |
| +} |
| + |
| +// CurrencyForCountry is the currency a buyer in country is offered first |
| +// under book. country is an upper-case ISO 3166-1 alpha-2 code. A country |
| +// the book does not list, an unknown one and an empty one all get the |
| +// book's default: this is a default, not a check of the country. |
| +func CurrencyForCountry(book, country string) (Currency, error) { |
| + if book == LegacyPriceBook { |
| + return EUR, nil |
| + } |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return "", err |
| + } |
| + if c, ok := b.byCountry[country]; ok { |
| + return c, nil |
| + } |
| + return b.DefaultCurrency, nil |
| +} |
| + |
| +// ChooseCurrency is the one rule for a purchase. An explicit currency that |
| +// is one the book sells in is taken. An explicit currency that is anything |
| +// else is refused. With none given, it is the billing country's default. |
| +func ChooseCurrency(book, explicit, country string) (Currency, error) { |
| + if explicit == "" { |
| + return CurrencyForCountry(book, country) |
| + } |
| + c, err := ParseCurrency(explicit) |
| + if err != nil { |
| + return "", err |
| + } |
| + if book == LegacyPriceBook { |
| + if c != EUR { |
| + return "", ErrUnknownCurrency |
| + } |
| + return c, nil |
| + } |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !b.sells(c) { |
| + return "", ErrUnknownCurrency |
| + } |
| + return c, nil |
| +} |
| + |
| +func (b *bookDoc) sells(c Currency) bool { |
| + for _, have := range b.Currencies { |
| + if have == c { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// BookIsApproved reports whether a book's amounts have been approved for |
| +// sale. The legacy book is the approved terms' own EUR figures. |
| +func BookIsApproved(book string) (bool, error) { |
| + if book == LegacyPriceBook { |
| + return true, nil |
| + } |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return false, err |
| + } |
| + return b.Status == BookApproved, nil |
| +} |
| + |
| +// BookCurrencies lists the currencies a book sells in, in the book's order. |
| +func BookCurrencies(book string) ([]Currency, error) { |
| + if book == LegacyPriceBook { |
| + return []Currency{EUR}, nil |
| + } |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return append([]Currency(nil), b.Currencies...), nil |
| +} |
| + |
| +// PriceTerms is the commercial identity of a purchase: the terms it was |
| +// made under, the book it was priced from, and the currency it was made |
| +// in. It is stored with the purchase and does not change for it. |
| +// |
| +// The zero book and zero currency together are a legacy purchase: EUR, |
| +// from the terms' own figures. A book with no currency, or a currency with |
| +// no book other than EUR, is not valid. |
| +type PriceTerms struct { |
| + TermsVersion string `json:"terms_version"` |
| + PriceBook string `json:"price_book,omitempty"` |
| + Currency Currency `json:"currency,omitempty"` |
| +} |
| + |
| +// LegacyPriceTerms is the identity of a purchase recorded before books |
| +// existed. |
| +func LegacyPriceTerms(termsVersion string) PriceTerms { |
| + return PriceTerms{TermsVersion: termsVersion} |
| +} |
| + |
| +// Legacy reports a purchase recorded before books existed. |
| +func (t PriceTerms) Legacy() bool { return t.PriceBook == LegacyPriceBook } |
| + |
| +// Money is the currency every amount for the purchase is in. |
| +func (t PriceTerms) Money() Currency { |
| + if t.Legacy() { |
| + return EUR |
| + } |
| + return t.Currency |
| +} |
| + |
| +// Validate checks the identity names terms and a book this build has and a |
| +// currency that book sells in. |
| +func (t PriceTerms) Validate() error { |
| + if t.TermsVersion != terms.Version { |
| + return ErrUnknownTermsVersion |
| + } |
| + if t.Legacy() { |
| + if t.Currency != "" && t.Currency != EUR { |
| + return ErrCurrencyMismatch |
| + } |
| + return nil |
| + } |
| + b, err := lookupBook(t.PriceBook) |
| + if err != nil { |
| + return err |
| + } |
| + if b.TermsVersion != t.TermsVersion { |
| + return ErrUnknownTermsVersion |
| + } |
| + if _, err := ParseCurrency(string(t.Currency)); err != nil { |
| + return err |
| + } |
| + if !b.sells(t.Currency) { |
| + return ErrUnknownCurrency |
| + } |
| + return nil |
| +} |
| + |
| +// Price is what a plan costs an account for a year, and what it gets. |
| +// AnnualMinor is UnitMinor times Quantity exactly: there is no rounding |
| +// after the per-member price. Allowances are the same in every currency. |
| +type Price struct { |
| + Terms PriceTerms |
| + Plan, Band string |
| + Quantity int64 |
| + UnitMinor int64 |
| + AnnualMinor int64 |
| + Allowances Resources |
| +} |
| + |
| +// PriceFor prices plan at band for qty members under t. The quantity rules |
| +// and the allowances are Quote's. The free plan costs nothing in any |
| +// currency. |
| +func PriceFor(t PriceTerms, plan, band string, qty int64) (*Price, error) { |
| + if err := t.Validate(); err != nil { |
| + return nil, err |
| + } |
| + quote, err := Quote(t.TermsVersion, plan, band, qty) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := &Price{Terms: t, Plan: plan, Band: band, Quantity: qty, Allowances: quote.Allowances} |
| + switch { |
| + case plan == PlanFree: |
| + case t.Legacy(): |
| + if quote.AnnualPriceMicroEUR%qty != 0 || (quote.AnnualPriceMicroEUR/qty)%microPerMinor != 0 { |
| + return nil, ErrPriceNotWhole |
| + } |
| + out.UnitMinor = quote.AnnualPriceMicroEUR / qty / microPerMinor |
| + default: |
| + unit, ok := priceBooks[t.PriceBook].AnnualUnitMinor[plan][t.Currency][band] |
| + if !ok { |
| + return nil, ErrUnknownPlan |
| + } |
| + out.UnitMinor = unit |
| + } |
| + annual, err := mulDivFloor(out.UnitMinor, qty, 1) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out.AnnualMinor = annual |
| + return out, nil |
| +} |
| + |
| +// Charges is the priced excess for each resource axis, in micros of one |
| +// currency, and their sum. |
| +type Charges struct { |
| + Currency Currency `json:"currency"` |
| + AwakeMicro int64 `json:"awake_micro"` |
| + StorageMicro int64 `json:"storage_micro"` |
| + TransferMicro int64 `json:"transfer_micro"` |
| + WritesMicro int64 `json:"writes_micro"` |
| + TotalMicro int64 `json:"total_micro"` |
| +} |
| + |
| +func (t PriceTerms) overageRates() (OverageRates, int64) { |
| + if t.Legacy() { |
| + return terms.OverageRatesMicroEUR, invoiceFloorMicroEUR |
| + } |
| + b := priceBooks[t.PriceBook] |
| + return b.OverageRatesMicro[t.Currency], b.OverageFloorMicro[t.Currency] |
| +} |
| + |
| +// OverageFor prices usage beyond allowance under t, at the rates of t's |
| +// currency. It is CalculateOverage with the rates chosen by the purchase's |
| +// own stored currency, and for legacy terms it gives CalculateOverage's |
| +// numbers. |
| +func OverageFor(t PriceTerms, plan string, usage, allowance Resources) (*Charges, error) { |
| + if err := t.Validate(); err != nil { |
| + return nil, err |
| + } |
| + if _, ok := terms.Plans[plan]; !ok { |
| + return nil, ErrUnknownPlan |
| + } |
| + if err := requireNonNegative(usage); err != nil { |
| + return nil, err |
| + } |
| + if err := requireNonNegative(allowance); err != nil { |
| + return nil, err |
| + } |
| + rates, _ := t.overageRates() |
| + costs, total, err := overageCosts(rates, usage, allowance) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Charges{Currency: t.Money(), AwakeMicro: costs[0], StorageMicro: costs[1], TransferMicro: costs[2], WritesMicro: costs[3], TotalMicro: total}, nil |
| +} |
| + |
| +func overageCosts(rates OverageRates, usage, allowance Resources) ([4]int64, int64, error) { |
| + var costs [4]int64 |
| + var err error |
| + if costs[0], err = excessCost(usage.AwakeSeconds, allowance.AwakeSeconds, rates.AwakeHour, secondsPerHour); err != nil { |
| + return costs, 0, err |
| + } |
| + if costs[1], err = excessCost(usage.StorageByteMonths, allowance.StorageByteMonths, rates.StorageGBMonth, bytesPerGB); err != nil { |
| + return costs, 0, err |
| + } |
| + if costs[2], err = excessCost(usage.TransferBytes, allowance.TransferBytes, rates.TransferGB, bytesPerGB); err != nil { |
| + return costs, 0, err |
| + } |
| + if costs[3], err = excessCost(usage.Writes, allowance.Writes, rates.Writes1000, writesPerThousand); err != nil { |
| + return costs, 0, err |
| + } |
| + total, err := addChecked(costs[:]...) |
| + return costs, total, err |
| +} |
| + |
| +// Invoice is a standalone overage invoice's total in one currency. |
| +type Invoice struct { |
| + Currency Currency `json:"currency"` |
| + TotalMicro int64 `json:"total_micro"` |
| + TotalMinor int64 `json:"total_minor"` |
| + Waived bool `json:"waived"` |
| +} |
| + |
| +// OverageInvoiceFor turns charges into an invoice and its lines under t. |
| +// A house account is billed nothing. A raw total below the floor for the |
| +// currency is waived before any rounding. Otherwise the total is rounded |
| +// once, half up, to minor units, and split into lines that sum to it |
| +// exactly, as InvoiceOverageLines does. |
| +// |
| +// The charges must be in t's currency. Charges priced in one currency are |
| +// never invoiced in another. |
| +func OverageInvoiceFor(t PriceTerms, c Charges, house bool) (*Invoice, []OverageLine, error) { |
| + if err := t.Validate(); err != nil { |
| + return nil, nil, err |
| + } |
| + if c.Currency != t.Money() { |
| + return nil, nil, ErrCurrencyMismatch |
| + } |
| + _, floor := t.overageRates() |
| + total, waived, lines, err := overageLines([4]int64{c.AwakeMicro, c.StorageMicro, c.TransferMicro, c.WritesMicro}, c.TotalMicro, floor, house) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if waived { |
| + return &Invoice{Currency: c.Currency, Waived: true}, nil, nil |
| + } |
| + return &Invoice{Currency: c.Currency, TotalMicro: c.TotalMicro, TotalMinor: total}, lines, nil |
| +} |
| + |
| +// publicBook is the export the pricing site formats from. It holds no |
| +// price that is not in the embedded terms and book. |
| +type publicBook struct { |
| + Schema int `json:"schema"` |
| + PriceBook string `json:"price_book"` |
| + TermsVersion string `json:"terms_version"` |
| + Status string `json:"status"` |
| + DefaultCurrency Currency `json:"default_currency"` |
| + Currencies []publicCurrency `json:"currencies"` |
| + CountryCurrency map[string]Currency `json:"country_currency"` |
| + CountryBand map[string]string `json:"country_band"` |
| + BandFactorsBps map[string]int64 `json:"band_factors_bps"` |
| + Plans []publicPlan `json:"plans"` |
| + Overage map[Currency]publicOverage `json:"overage"` |
| +} |
| + |
| +type publicCurrency struct { |
| + Code Currency `json:"code"` |
| + Symbol string `json:"symbol"` |
| + MinorPerMajor int64 `json:"minor_per_major"` |
| +} |
| + |
| +type publicPlan struct { |
| + Key string `json:"key"` |
| + MinQuantity int64 `json:"min_quantity"` |
| + MaxQuantity int64 `json:"max_quantity"` |
| + BandScaled bool `json:"band_scaled"` |
| + AnnualUnitMinor map[Currency]map[string]int64 `json:"annual_unit_minor"` |
| + ResourcesPerMember Resources `json:"resources_per_member"` |
| +} |
| + |
| +type publicOverage struct { |
| + AwakeHourMicro int64 `json:"awake_hour_micro"` |
| + StorageGBMonthMicro int64 `json:"storage_gb_month_micro"` |
| + TransferGBMicro int64 `json:"transfer_gb_micro"` |
| + Writes1000Micro int64 `json:"writes_1000_micro"` |
| + InvoiceFloorMicro int64 `json:"invoice_floor_micro"` |
| +} |
| + |
| +// PublicPriceBook renders a book for the pricing site: every price, rate |
| +// and default the page shows, taken from the embedded terms, the book and |
| +// the country bands, so the site holds no figure of its own. The bytes are |
| +// stable for a given build: maps are written with sorted keys, lists in a |
| +// fixed order. |
| +// |
| +// Prices are for one member for one year, in minor units; an annual price |
| +// is that times the quantity. resources_per_member is band A's; another |
| +// band's pooled allowance is the figure times the quantity times the band |
| +// factor, divided by 10000 and rounded down. |
| +func PublicPriceBook(book string) ([]byte, error) { |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := publicBook{ |
| + Schema: 1, PriceBook: b.Version, TermsVersion: b.TermsVersion, Status: b.Status, DefaultCurrency: b.DefaultCurrency, |
| + CountryCurrency: map[string]Currency{}, CountryBand: CountryBands(), BandFactorsBps: map[string]int64{}, Overage: map[Currency]publicOverage{}, |
| + } |
| + for _, c := range b.Currencies { |
| + out.Currencies = append(out.Currencies, publicCurrency{Code: c, Symbol: supportedCurrencies[c].Symbol, MinorPerMajor: supportedCurrencies[c].MinorPerMajor}) |
| + r := b.OverageRatesMicro[c] |
| + out.Overage[c] = publicOverage{AwakeHourMicro: r.AwakeHour, StorageGBMonthMicro: r.StorageGBMonth, TransferGBMicro: r.TransferGB, Writes1000Micro: r.Writes1000, InvoiceFloorMicro: b.OverageFloorMicro[c]} |
| + } |
| + for country, c := range b.byCountry { |
| + out.CountryCurrency[country] = c |
| + } |
| + for band, bps := range terms.BandFactorsBps { |
| + out.BandFactorsBps[band] = bps |
| + } |
| + for _, plan := range []string{PlanFree, PlanPersonal, PlanCommunity, PlanBusiness} { |
| + pt := terms.Plans[plan] |
| + p := publicPlan{Key: plan, MinQuantity: pt.MinQuantity, MaxQuantity: pt.MaxQuantity, BandScaled: pt.BandScaled, AnnualUnitMinor: map[Currency]map[string]int64{}, ResourcesPerMember: pt.ResourcesPerMember} |
| + for _, c := range b.Currencies { |
| + p.AnnualUnitMinor[c] = map[string]int64{} |
| + for _, band := range bandOrder { |
| + p.AnnualUnitMinor[c][band] = b.AnnualUnitMinor[plan][c][band] // zero for the free plan |
| + } |
| + } |
| + out.Plans = append(out.Plans, p) |
| + } |
| + sort.Slice(out.Currencies, func(i, j int) bool { |
| + return indexOfCurrency(b.Currencies, out.Currencies[i].Code) < indexOfCurrency(b.Currencies, out.Currencies[j].Code) |
| + }) |
| + raw, err := json.MarshalIndent(out, "", " ") |
| + if err != nil { |
| + return nil, err |
| + } |
| + return append(raw, '\n'), nil |
| +} |
| + |
| +func indexOfCurrency(list []Currency, c Currency) int { |
| + for i, have := range list { |
| + if have == c { |
| + return i |
| + } |
| + } |
| + return len(list) |
| +} |
diff --git a/internal/plans/pricebook.json b/internal/plans/pricebook.json| new file mode 100644 |
| index 000000000..fcaf227f5 |
| --- /dev/null |
| +++ b/internal/plans/pricebook.json |
| @@ -0,0 +1,43 @@ |
| +{ |
| + "books": [ |
| + { |
| + "version": "2026-10-01-draft1", |
| + "terms_version": "2026-09-07-v1", |
| + "status": "draft", |
| + "default_currency": "usd", |
| + "currencies": ["usd", "eur", "gbp"], |
| + "country_currency": { |
| + "eur": ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"], |
| + "gbp": ["GB"] |
| + }, |
| + "annual_base_minor": { |
| + "personal": {"usd": 2800, "eur": 2400, "gbp": 2200}, |
| + "community": {"usd": 600, "eur": 500, "gbp": 450}, |
| + "business": {"usd": 6000, "eur": 5000, "gbp": 4500} |
| + }, |
| + "annual_unit_minor": { |
| + "personal": { |
| + "usd": {"A": 2800, "B": 2100, "C": 1400, "D": 980}, |
| + "eur": {"A": 2400, "B": 1800, "C": 1200, "D": 840}, |
| + "gbp": {"A": 2200, "B": 1650, "C": 1100, "D": 770} |
| + }, |
| + "community": { |
| + "usd": {"A": 600, "B": 450, "C": 300, "D": 210}, |
| + "eur": {"A": 500, "B": 375, "C": 250, "D": 175}, |
| + "gbp": {"A": 450, "B": 338, "C": 225, "D": 158} |
| + }, |
| + "business": { |
| + "usd": {"A": 6000, "B": 4500, "C": 3000, "D": 2100}, |
| + "eur": {"A": 5000, "B": 3750, "C": 2500, "D": 1750}, |
| + "gbp": {"A": 4500, "B": 3375, "C": 2250, "D": 1575} |
| + } |
| + }, |
| + "overage_rates_micro": { |
| + "usd": {"awake_hour": 12000, "storage_gb_month": 127200, "transfer_gb": 496800, "writes_1000": 27600}, |
| + "eur": {"awake_hour": 10000, "storage_gb_month": 106000, "transfer_gb": 414000, "writes_1000": 23000}, |
| + "gbp": {"awake_hour": 9000, "storage_gb_month": 95400, "transfer_gb": 372600, "writes_1000": 20700} |
| + }, |
| + "overage_floor_micro": {"usd": 1200000, "eur": 1000000, "gbp": 900000} |
| + } |
| + ] |
| +} |
diff --git a/internal/plans/pricebook_test.go b/internal/plans/pricebook_test.go| new file mode 100644 |
| index 000000000..27c42fc15 |
| --- /dev/null |
| +++ b/internal/plans/pricebook_test.go |
| @@ -0,0 +1,532 @@ |
| +package plans |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "math/rand" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +var allCurrencies = []Currency{USD, EUR, GBP} |
| + |
| +func bookTerms(c Currency) PriceTerms { |
| + return PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: CurrentPriceBook, Currency: c} |
| +} |
| + |
| +// The 36 prices, written here independently of the book: one member for one |
| +// year, in minor units. These are the draft amounts; a change to the book |
| +// has to change this table too, on purpose. |
| +var wantUnitMinor = map[string]map[Currency][4]int64{ |
| + PlanPersonal: {USD: {2800, 2100, 1400, 980}, EUR: {2400, 1800, 1200, 840}, GBP: {2200, 1650, 1100, 770}}, |
| + PlanCommunity: {USD: {600, 450, 300, 210}, EUR: {500, 375, 250, 175}, GBP: {450, 338, 225, 158}}, |
| + PlanBusiness: {USD: {6000, 4500, 3000, 2100}, EUR: {5000, 3750, 2500, 1750}, GBP: {4500, 3375, 2250, 1575}}, |
| +} |
| + |
| +func TestPriceBookHasThe36Prices(t *testing.T) { |
| + n := 0 |
| + for plan, byCurrency := range wantUnitMinor { |
| + qty := int64(1) |
| + if plan != PlanPersonal { |
| + qty = 10 |
| + } |
| + for c, bands := range byCurrency { |
| + for i, band := range bandOrder { |
| + p, err := PriceFor(bookTerms(c), plan, band, qty) |
| + if err != nil { |
| + t.Fatalf("%s %s %s: %v", plan, c, band, err) |
| + } |
| + if p.UnitMinor != bands[i] || p.AnnualMinor != bands[i]*qty { |
| + t.Errorf("%s %s %s: unit %d, annual %d for %d; want unit %d", plan, c, band, p.UnitMinor, p.AnnualMinor, qty, bands[i]) |
| + } |
| + n++ |
| + } |
| + } |
| + } |
| + if n != 36 { |
| + t.Fatalf("checked %d prices, want 36", n) |
| + } |
| + // The two that the rounding rule decides: 4.50 GBP at 75% is 3.375 and |
| + // at 35% is 1.575, and each goes half up for one member before any |
| + // quantity. Ten members at band D pay ten times 1.58, not 15.75. |
| + p, err := PriceFor(bookTerms(GBP), PlanCommunity, BandD, 10) |
| + if err != nil || p.UnitMinor != 158 || p.AnnualMinor != 1580 { |
| + t.Fatalf("community GBP band D for ten: %+v %v", p, err) |
| + } |
| +} |
| + |
| +// A currency changes what is paid and nothing else. The allowances for a |
| +// plan, band and quantity are identical in every currency and under the |
| +// legacy terms. |
| +func TestCurrencyNeverChangesAllowances(t *testing.T) { |
| + for _, plan := range []string{PlanFree, PlanPersonal, PlanCommunity, PlanBusiness} { |
| + for _, band := range bandOrder { |
| + for _, qty := range []int64{1, 10, 37, 1000} { |
| + legacy, err := PriceFor(LegacyPriceTerms(CurrentTermsVersion), plan, band, qty) |
| + if err != nil { |
| + if errors.Is(err, ErrInvalidQuantity) { |
| + continue |
| + } |
| + t.Fatal(err) |
| + } |
| + quote, err := Quote(CurrentTermsVersion, plan, band, qty) |
| + if err != nil || quote.Allowances != legacy.Allowances { |
| + t.Fatalf("%s %s %d: legacy allowances differ from Quote's", plan, band, qty) |
| + } |
| + for _, c := range allCurrencies { |
| + p, err := PriceFor(bookTerms(c), plan, band, qty) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Allowances != legacy.Allowances { |
| + t.Errorf("%s %s %d in %s: allowances %+v, legacy %+v", plan, band, qty, c, p.Allowances, legacy.Allowances) |
| + } |
| + if plan == PlanFree && (p.UnitMinor != 0 || p.AnnualMinor != 0) { |
| + t.Errorf("the free plan costs %d in %s", p.AnnualMinor, c) |
| + } |
| + } |
| + // EUR from the book is the legacy EUR price, to the cent. |
| + eur, _ := PriceFor(bookTerms(EUR), plan, band, qty) |
| + if eur.UnitMinor != legacy.UnitMinor || eur.AnnualMinor != legacy.AnnualMinor || legacy.AnnualMinor*microPerMinor != quote.AnnualPriceMicroEUR { |
| + t.Errorf("%s %s %d: book EUR %d, legacy %d, Quote %d micro", plan, band, qty, eur.AnnualMinor, legacy.AnnualMinor, quote.AnnualPriceMicroEUR) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestParseCurrencyAcceptsOnlyTheThree(t *testing.T) { |
| + for _, ok := range []string{"usd", "eur", "gbp"} { |
| + if c, err := ParseCurrency(ok); err != nil || string(c) != ok { |
| + t.Errorf("%q: %q %v", ok, c, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "USD", "Eur", " eur", "eur ", "jpy", "chf", "us", "usdd", "€"} { |
| + if c, err := ParseCurrency(bad); !errors.Is(err, ErrUnknownCurrency) || c != "" { |
| + t.Errorf("%q: %q %v, want ErrUnknownCurrency", bad, c, err) |
| + } |
| + } |
| +} |
| + |
| +func TestCurrencyForCountry(t *testing.T) { |
| + eu := []string{"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"} |
| + if len(eu) != 27 { |
| + t.Fatal("fixture: the EU has 27 member states") |
| + } |
| + want := map[string]Currency{"GB": GBP, "US": USD, "CA": USD, "CH": USD, "NO": USD, "IS": USD, "JE": USD, "IM": USD, "IN": USD, "": USD, "ZZ": USD, "gb": USD, "ie": USD, "EU": USD} |
| + for _, c := range eu { |
| + want[c] = EUR |
| + } |
| + for country, w := range want { |
| + got, err := CurrencyForCountry(CurrentPriceBook, country) |
| + if err != nil || got != w { |
| + t.Errorf("%q: %q %v, want %q", country, got, err, w) |
| + } |
| + } |
| + // Every country the book names is one the country terms know: a typo |
| + // here would silently give a real country the default. |
| + b := priceBooks[CurrentPriceBook] |
| + if len(b.byCountry) != 28 { |
| + t.Errorf("the book gives %d countries a currency of their own, want 28", len(b.byCountry)) |
| + } |
| + for country := range b.byCountry { |
| + if _, err := CountryBand(country); err != nil { |
| + t.Errorf("the book names %s, which has no band", country) |
| + } |
| + } |
| + if c, err := CurrencyForCountry(LegacyPriceBook, "US"); err != nil || c != EUR { |
| + t.Errorf("legacy: %q %v", c, err) |
| + } |
| + if _, err := CurrencyForCountry("no-such-book", "IE"); !errors.Is(err, ErrUnknownPriceBook) { |
| + t.Errorf("an unknown book: %v", err) |
| + } |
| +} |
| + |
| +// An explicit currency is taken or refused. It is never replaced by the |
| +// country's default. |
| +func TestChooseCurrency(t *testing.T) { |
| + for _, tc := range []struct { |
| + book, explicit, country string |
| + want Currency |
| + err error |
| + }{ |
| + {CurrentPriceBook, "", "IE", EUR, nil}, |
| + {CurrentPriceBook, "", "GB", GBP, nil}, |
| + {CurrentPriceBook, "", "US", USD, nil}, |
| + {CurrentPriceBook, "", "", USD, nil}, |
| + {CurrentPriceBook, "usd", "IE", USD, nil}, |
| + {CurrentPriceBook, "gbp", "US", GBP, nil}, |
| + {CurrentPriceBook, "eur", "GB", EUR, nil}, |
| + {CurrentPriceBook, "jpy", "IE", "", ErrUnknownCurrency}, |
| + {CurrentPriceBook, "EUR", "IE", "", ErrUnknownCurrency}, |
| + {CurrentPriceBook, " ", "IE", "", ErrUnknownCurrency}, |
| + {LegacyPriceBook, "", "US", EUR, nil}, |
| + {LegacyPriceBook, "eur", "US", EUR, nil}, |
| + {LegacyPriceBook, "usd", "US", "", ErrUnknownCurrency}, |
| + {"no-such-book", "usd", "US", "", ErrUnknownPriceBook}, |
| + } { |
| + got, err := ChooseCurrency(tc.book, tc.explicit, tc.country) |
| + if got != tc.want || !errors.Is(err, tc.err) { |
| + t.Errorf("ChooseCurrency(%q, %q, %q) = %q, %v; want %q, %v", tc.book, tc.explicit, tc.country, got, err, tc.want, tc.err) |
| + } |
| + } |
| +} |
| + |
| +func TestPriceTermsValidate(t *testing.T) { |
| + for name, tc := range map[string]struct { |
| + t PriceTerms |
| + err error |
| + }{ |
| + "legacy": {PriceTerms{TermsVersion: CurrentTermsVersion}, nil}, |
| + "legacy written with eur": {PriceTerms{TermsVersion: CurrentTermsVersion, Currency: EUR}, nil}, |
| + "legacy with another currency": {PriceTerms{TermsVersion: CurrentTermsVersion, Currency: USD}, ErrCurrencyMismatch}, |
| + "a book and a currency": {bookTerms(GBP), nil}, |
| + "a book and no currency": {PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: CurrentPriceBook}, ErrUnknownCurrency}, |
| + "a book and an unknown currency": {PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: CurrentPriceBook, Currency: "jpy"}, ErrUnknownCurrency}, |
| + "an unknown book": {PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: "nope", Currency: EUR}, ErrUnknownPriceBook}, |
| + "unknown terms": {PriceTerms{TermsVersion: "nope", PriceBook: CurrentPriceBook, Currency: EUR}, ErrUnknownTermsVersion}, |
| + "unknown terms, legacy": {PriceTerms{TermsVersion: "nope"}, ErrUnknownTermsVersion}, |
| + } { |
| + if err := tc.t.Validate(); !errors.Is(err, tc.err) || (tc.err == nil && err != nil) { |
| + t.Errorf("%s: %v, want %v", name, err, tc.err) |
| + } |
| + } |
| + if (PriceTerms{TermsVersion: CurrentTermsVersion}).Money() != EUR || bookTerms(USD).Money() != USD { |
| + t.Error("Money") |
| + } |
| + if _, err := PriceFor(PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: CurrentPriceBook}, PlanPersonal, BandA, 1); !errors.Is(err, ErrUnknownCurrency) { |
| + t.Errorf("a price with no currency: %v", err) |
| + } |
| +} |
| + |
| +// Overage in each currency is the same usage at that currency's rates. For |
| +// legacy terms, and for EUR from the book, it is CalculateOverage's and |
| +// InvoiceOverageLines' numbers exactly. |
| +func TestOverageInEachCurrency(t *testing.T) { |
| + rng := rand.New(rand.NewSource(20261001)) |
| + for i := 0; i < 2000; i++ { |
| + usage := Resources{AwakeSeconds: rng.Int63n(5_000_000), StorageByteMonths: rng.Int63n(80_000_000_000), TransferBytes: rng.Int63n(80_000_000_000), Writes: rng.Int63n(900_000)} |
| + allowance := Resources{AwakeSeconds: rng.Int63n(600_000), StorageByteMonths: rng.Int63n(5_000_000_000), TransferBytes: rng.Int63n(5_000_000_000), Writes: rng.Int63n(40_000)} |
| + house := i%17 == 0 |
| + old, err := CalculateOverage(CurrentTermsVersion, PlanBusiness, usage, allowance) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + oldInvoice, oldLines, err := InvoiceOverageLines(*old, house) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, terms := range []PriceTerms{LegacyPriceTerms(CurrentTermsVersion), bookTerms(EUR)} { |
| + c, err := OverageFor(terms, PlanBusiness, usage, allowance) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Currency != EUR || c.AwakeMicro != old.AwakeMicroEUR || c.StorageMicro != old.StorageMicroEUR || c.TransferMicro != old.TransferMicroEUR || c.WritesMicro != old.WritesMicroEUR || c.TotalMicro != old.TotalMicroEUR { |
| + t.Fatalf("EUR charges %+v differ from CalculateOverage's %+v", c, old) |
| + } |
| + inv, lines, err := OverageInvoiceFor(terms, *c, house) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if inv.Waived != oldInvoice.Waived || inv.TotalMinor != oldInvoice.TotalCents || inv.TotalMicro != oldInvoice.TotalMicroEUR || len(lines) != len(oldLines) { |
| + t.Fatalf("EUR invoice %+v differs from InvoiceOverageLines' %+v", inv, oldInvoice) |
| + } |
| + for n := range lines { |
| + if lines[n] != oldLines[n] { |
| + t.Fatalf("EUR line %d: %+v, was %+v", n, lines[n], oldLines[n]) |
| + } |
| + } |
| + } |
| + for _, cur := range allCurrencies { |
| + terms := bookTerms(cur) |
| + c, err := OverageFor(terms, PlanBusiness, usage, allowance) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rates := priceBooks[CurrentPriceBook].OverageRatesMicro[cur] |
| + wantAwake, _ := excessCost(usage.AwakeSeconds, allowance.AwakeSeconds, rates.AwakeHour, 3600) |
| + if c.Currency != cur || c.AwakeMicro != wantAwake || c.TotalMicro != c.AwakeMicro+c.StorageMicro+c.TransferMicro+c.WritesMicro { |
| + t.Fatalf("%s charges %+v", cur, c) |
| + } |
| + inv, lines, err := OverageInvoiceFor(terms, *c, house) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + floor := priceBooks[CurrentPriceBook].OverageFloorMicro[cur] |
| + if wantWaived := house || c.TotalMicro < floor; inv.Waived != wantWaived { |
| + t.Fatalf("%s total %d, floor %d, house %v: waived %v", cur, c.TotalMicro, floor, house, inv.Waived) |
| + } |
| + var sum int64 |
| + for _, l := range lines { |
| + sum += l.AmountCents |
| + } |
| + if inv.Currency != cur || sum != inv.TotalMinor || (inv.Waived && (len(lines) != 0 || inv.TotalMinor != 0)) { |
| + t.Fatalf("%s invoice %+v, lines sum to %d", cur, inv, sum) |
| + } |
| + if !inv.Waived && inv.TotalMinor != roundHalfUpDiv(c.TotalMicro, 10000) { |
| + t.Fatalf("%s total %d micro became %d minor", cur, c.TotalMicro, inv.TotalMinor) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The floor under which an overage invoice is waived is the currency's own: |
| +// 1.20 USD, 1.00 EUR, 0.90 GBP. One micro below it is waived, at it is |
| +// billed. |
| +func TestOverageFloorIsPerCurrency(t *testing.T) { |
| + for cur, floor := range map[Currency]int64{USD: 1_200_000, EUR: 1_000_000, GBP: 900_000} { |
| + terms := bookTerms(cur) |
| + below := Charges{Currency: cur, AwakeMicro: floor - 1, TotalMicro: floor - 1} |
| + if inv, lines, err := OverageInvoiceFor(terms, below, false); err != nil || !inv.Waived || len(lines) != 0 { |
| + t.Errorf("%s one micro below its floor: %+v %v", cur, inv, err) |
| + } |
| + at := Charges{Currency: cur, AwakeMicro: floor, TotalMicro: floor} |
| + inv, lines, err := OverageInvoiceFor(terms, at, false) |
| + if err != nil || inv.Waived || inv.TotalMinor != floor/10000 || len(lines) != 1 { |
| + t.Errorf("%s at its floor: %+v %v %v", cur, inv, lines, err) |
| + } |
| + if inv, _, err := OverageInvoiceFor(terms, at, true); err != nil || !inv.Waived { |
| + t.Errorf("%s house: %+v %v", cur, inv, err) |
| + } |
| + } |
| +} |
| + |
| +// Charges priced in one currency are never invoiced in another, under any |
| +// pair, the legacy terms included. |
| +func TestChargesAreNeverInvoicedInAnotherCurrency(t *testing.T) { |
| + all := []PriceTerms{LegacyPriceTerms(CurrentTermsVersion), bookTerms(USD), bookTerms(EUR), bookTerms(GBP)} |
| + for _, priced := range all { |
| + c, err := OverageFor(priced, PlanCommunity, Resources{AwakeSeconds: 9_000_000}, Resources{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, invoiced := range all { |
| + _, _, err := OverageInvoiceFor(invoiced, *c, false) |
| + same := priced.Money() == invoiced.Money() |
| + if same && err != nil { |
| + t.Errorf("priced in %s, invoiced in %s: %v", priced.Money(), invoiced.Money(), err) |
| + } |
| + if !same && !errors.Is(err, ErrCurrencyMismatch) { |
| + t.Errorf("priced in %s, invoiced in %s: %v, want ErrCurrencyMismatch", priced.Money(), invoiced.Money(), err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// A month's paid spans carry one currency and one book, and the month's |
| +// allowance says which. Spans that disagree are refused: a month paid for |
| +// in two currencies is priced in neither. |
| +func TestAllowanceForMonthKeepsOneCurrency(t *testing.T) { |
| + at := func(day int) time.Time { return time.Date(2026, 9, day, 0, 0, 0, 0, time.UTC) } |
| + paid := time.Date(2027, 8, 20, 0, 0, 0, 0, time.UTC) |
| + span := func(rev int64, day int, book string, c Currency) Assignment { |
| + return Assignment{EffectiveAt: at(day), Revision: rev, TermsVersion: CurrentTermsVersion, Plan: PlanCommunity, Band: BandA, Quantity: 10, Status: "active", PaidThrough: paid, PriceBook: book, Currency: c} |
| + } |
| + first := span(1, 1, CurrentPriceBook, GBP) |
| + first.EffectiveAt = time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) |
| + got, err := AllowanceForMonth("2026-09", []Assignment{first, span(2, 10, CurrentPriceBook, GBP)}) |
| + if err != nil || got.Currency != GBP || got.PriceBook != CurrentPriceBook || got.PriceTerms() != bookTerms(GBP) { |
| + t.Fatalf("one currency all month: %+v %v", got, err) |
| + } |
| + legacyFirst := first |
| + legacyFirst.PriceBook, legacyFirst.Currency = "", "" |
| + legacy, err := AllowanceForMonth("2026-09", []Assignment{legacyFirst, span(2, 10, "", EUR)}) |
| + if err != nil || legacy.Currency != "" || legacy.PriceBook != "" || !legacy.PriceTerms().Legacy() { |
| + t.Fatalf("legacy spans, one written with eur and one with none: %+v %v", legacy, err) |
| + } |
| + if legacy.Resources != got.Resources { |
| + t.Fatalf("the allowance depends on the currency: %+v and %+v", legacy.Resources, got.Resources) |
| + } |
| + for name, second := range map[string]Assignment{ |
| + "another currency": span(2, 10, CurrentPriceBook, USD), |
| + "the legacy book": span(2, 10, "", ""), |
| + "a book this build lacks": span(2, 10, "2031-01-01-v9", GBP), |
| + "a currency the book lacks": span(2, 10, CurrentPriceBook, "jpy"), |
| + "a book and no currency": span(2, 10, CurrentPriceBook, ""), |
| + "legacy with a currency": span(2, 10, "", GBP), |
| + } { |
| + if _, err := AllowanceForMonth("2026-09", []Assignment{first, second}); !errors.Is(err, ErrAssignmentHistory) { |
| + t.Errorf("%s: %v, want ErrAssignmentHistory", name, err) |
| + } |
| + } |
| +} |
| + |
| +// An assignment recorded before books existed marshals exactly as it did: |
| +// a frozen month's calculation holds its assignments, and its digest is of |
| +// those bytes. |
| +func TestLegacyAssignmentMarshalsAsBefore(t *testing.T) { |
| + a := Assignment{EffectiveAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Revision: 3, TermsVersion: CurrentTermsVersion, Plan: PlanPersonal, Band: BandB, Quantity: 1, Status: "active", PaidThrough: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)} |
| + raw, err := json.Marshal(a) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + const before = `{"effective_at":"2026-09-01T00:00:00Z","revision":3,"terms_version":"2026-09-07-v1","plan":"personal","band":"B","quantity":1,"house":false,"status":"active","paid_through":"2027-09-01T00:00:00Z"}` |
| + if string(raw) != before { |
| + t.Fatalf("a legacy assignment marshals as\n%s\nand used to marshal as\n%s", raw, before) |
| + } |
| + m := MonthAllowance{Month: "2026-09", TermsVersion: CurrentTermsVersion, Assignments: []Assignment{a}} |
| + raw, _ = json.Marshal(m) |
| + if strings.Contains(string(raw), "currency") || strings.Contains(string(raw), "price_book") { |
| + t.Fatalf("a legacy month allowance gained a field: %s", raw) |
| + } |
| +} |
| + |
| +// What a book must be before it is trusted. Each case is the embedded |
| +// book with one thing wrong. |
| +func TestPriceBookValidation(t *testing.T) { |
| + tm, err := decodeTerms(plansJSON) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := decodePriceBooks(priceBookJSON, tm); err != nil { |
| + t.Fatalf("the embedded book: %v", err) |
| + } |
| + for name, edit := range map[string]func(s string) string{ |
| + "a unit price one off the rounding rule (rounded down)": func(s string) string { return strings.Replace(s, `"B": 338`, `"B": 337`, 1) }, |
| + "a unit price that ignores the band": func(s string) string { return strings.Replace(s, `"D": 980`, `"D": 2800`, 1) }, |
| + "a missing band": func(s string) string { return strings.Replace(s, `"C": 1400, `, ``, 1) }, |
| + "a band the terms do not have": func(s string) string { return strings.Replace(s, `"D": 980`, `"E": 980`, 1) }, |
| + "an unknown field": func(s string) string { return strings.Replace(s, `"status"`, `"note": "x", "status"`, 1) }, |
| + "a status that is neither": func(s string) string { return strings.Replace(s, `"draft"`, `"final"`, 1) }, |
| + "a default the book does not sell in": func(s string) string { |
| + return strings.Replace(s, `"default_currency": "usd"`, `"default_currency": "chf"`, 1) |
| + }, |
| + "a currency this build cannot price": func(s string) string { return strings.Replace(s, `["usd", "eur", "gbp"]`, `["usd", "eur", "jpy"]`, 1) }, |
| + "a currency listed twice": func(s string) string { return strings.Replace(s, `["usd", "eur", "gbp"]`, `["usd", "eur", "eur"]`, 1) }, |
| + "a currency sold with no prices": func(s string) string { |
| + return strings.Replace(s, `"personal": {"usd": 2800, "eur": 2400, "gbp": 2200}`, `"personal": {"usd": 2800, "eur": 2400}`, 1) |
| + }, |
| + "a country in two currencies": func(s string) string { return strings.Replace(s, `"gbp": ["GB"]`, `"gbp": ["GB", "IE"]`, 1) }, |
| + "a country code that is not one": func(s string) string { return strings.Replace(s, `"gbp": ["GB"]`, `"gbp": ["gb"]`, 1) }, |
| + "the default currency given a country list": func(s string) string { return strings.Replace(s, `"gbp": ["GB"]`, `"gbp": ["GB"], "usd": ["US"]`, 1) }, |
| + "EUR that is not the terms' EUR": func(s string) string { |
| + return strings.Replace(strings.Replace(s, `"eur": 2400`, `"eur": 2500`, 1), `"eur": {"A": 2400, "B": 1800, "C": 1200, "D": 840}`, `"eur": {"A": 2500, "B": 1875, "C": 1250, "D": 875}`, 1) |
| + }, |
| + "an EUR overage rate that is not the terms'": func(s string) string { |
| + return strings.Replace(s, `"eur": {"awake_hour": 10000`, `"eur": {"awake_hour": 10001`, 1) |
| + }, |
| + "an EUR floor that is not the terms'": func(s string) string { return strings.Replace(s, `"eur": 1000000`, `"eur": 999999`, 1) }, |
| + "a zero overage rate": func(s string) string { |
| + return strings.Replace(s, `"usd": {"awake_hour": 12000`, `"usd": {"awake_hour": 0`, 1) |
| + }, |
| + "a missing floor": func(s string) string { return strings.Replace(s, `"usd": 1200000, `, ``, 1) }, |
| + "terms this build does not have": func(s string) string { |
| + return strings.Replace(s, `"terms_version": "2026-09-07-v1"`, `"terms_version": "2020-01-01-v0"`, 1) |
| + }, |
| + "no version": func(s string) string { return strings.Replace(s, `"version": "2026-10-01-draft1"`, `"version": ""`, 1) }, |
| + "trailing content": func(s string) string { return s + "{}" }, |
| + } { |
| + edited := edit(string(priceBookJSON)) |
| + if edited == string(priceBookJSON) { |
| + t.Errorf("%s: fixture: the edit changed nothing", name) |
| + continue |
| + } |
| + if _, err := decodePriceBooks([]byte(edited), tm); err == nil { |
| + t.Errorf("%s: accepted", name) |
| + } |
| + } |
| + // The same book twice. |
| + var doc map[string][]json.RawMessage |
| + if err := json.Unmarshal(priceBookJSON, &doc); err != nil { |
| + t.Fatal(err) |
| + } |
| + doc["books"] = append(doc["books"], doc["books"][0]) |
| + twice, _ := json.Marshal(doc) |
| + if _, err := decodePriceBooks(twice, tm); err == nil { |
| + t.Error("one version twice: accepted") |
| + } |
| +} |
| + |
| +func TestBookStatus(t *testing.T) { |
| + if ok, err := BookIsApproved(LegacyPriceBook); err != nil || !ok { |
| + t.Errorf("the legacy book: %v %v", ok, err) |
| + } |
| + // The amounts in the current book have not been approved. When they |
| + // are, that is a new book version, and this line changes with it. |
| + if ok, err := BookIsApproved(CurrentPriceBook); err != nil || ok { |
| + t.Errorf("the current book is a draft: %v %v", ok, err) |
| + } |
| + if _, err := BookIsApproved("nope"); !errors.Is(err, ErrUnknownPriceBook) { |
| + t.Errorf("an unknown book: %v", err) |
| + } |
| + if cs, err := BookCurrencies(CurrentPriceBook); err != nil || len(cs) != 3 || cs[0] != USD { |
| + t.Errorf("currencies: %v %v", cs, err) |
| + } |
| +} |
| + |
| +// The public export is generated, byte-stable, and committed. This fails |
| +// when the committed copy is not what this build generates; run with |
| +// CARLOS_UPDATE_PRICEBOOK_EXPORT=1 to rewrite it. |
| +func TestPublicPriceBookExport(t *testing.T) { |
| + raw, err := PublicPriceBook(CurrentPriceBook) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + again, _ := PublicPriceBook(CurrentPriceBook) |
| + if !bytes.Equal(raw, again) { |
| + t.Fatal("two renderings differ") |
| + } |
| + const path = "public-pricebook.json" |
| + if os.Getenv("CARLOS_UPDATE_PRICEBOOK_EXPORT") == "1" { |
| + if err := os.WriteFile(path, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + committed, err := os.ReadFile(path) |
| + if err != nil || !bytes.Equal(committed, raw) { |
| + t.Fatalf("%s is not what this build generates (%v); run go test ./internal/plans with CARLOS_UPDATE_PRICEBOOK_EXPORT=1", path, err) |
| + } |
| + var doc struct { |
| + Schema int `json:"schema"` |
| + PriceBook string `json:"price_book"` |
| + Status string `json:"status"` |
| + DefaultCurrency string `json:"default_currency"` |
| + Currencies []struct{ Code string } `json:"currencies"` |
| + CountryCurrency map[string]string `json:"country_currency"` |
| + CountryBand map[string]string `json:"country_band"` |
| + Plans []struct { |
| + Key string `json:"key"` |
| + AnnualUnitMinor map[string]map[string]int64 `json:"annual_unit_minor"` |
| + } `json:"plans"` |
| + Overage map[string]map[string]int64 `json:"overage"` |
| + } |
| + if err := json.Unmarshal(raw, &doc); err != nil { |
| + t.Fatal(err) |
| + } |
| + if doc.Schema != 1 || doc.PriceBook != CurrentPriceBook || doc.Status != "draft" || doc.DefaultCurrency != "usd" || len(doc.Currencies) != 3 || doc.Currencies[0].Code != "usd" { |
| + t.Fatalf("header: %+v", doc) |
| + } |
| + if len(doc.CountryCurrency) != 28 || doc.CountryCurrency["IE"] != "eur" || doc.CountryCurrency["GB"] != "gbp" || doc.CountryCurrency["US"] != "" { |
| + t.Errorf("country_currency: %v", doc.CountryCurrency) |
| + } |
| + if len(doc.CountryBand) != len(CountryBands()) { |
| + t.Errorf("country_band has %d countries, the terms %d", len(doc.CountryBand), len(CountryBands())) |
| + } |
| + keys := []string{} |
| + for _, p := range doc.Plans { |
| + keys = append(keys, p.Key) |
| + for c, bands := range p.AnnualUnitMinor { |
| + for i, band := range bandOrder { |
| + want := int64(0) |
| + if p.Key != PlanFree { |
| + want = wantUnitMinor[p.Key][Currency(c)][i] |
| + } |
| + if bands[band] != want { |
| + t.Errorf("export %s %s %s = %d, want %d", p.Key, c, band, bands[band], want) |
| + } |
| + } |
| + } |
| + if len(p.AnnualUnitMinor) != 3 { |
| + t.Errorf("export %s is priced in %d currencies", p.Key, len(p.AnnualUnitMinor)) |
| + } |
| + } |
| + if strings.Join(keys, ",") != "free,personal,community,business" { |
| + t.Errorf("plans in the export: %v", keys) |
| + } |
| + if doc.Overage["usd"]["invoice_floor_micro"] != 1_200_000 || doc.Overage["gbp"]["awake_hour_micro"] != 9000 || len(doc.Overage) != 3 { |
| + t.Errorf("overage: %v", doc.Overage) |
| + } |
| +} |
diff --git a/internal/plans/public-pricebook.json b/internal/plans/public-pricebook.json| new file mode 100644 |
| index 000000000..9e53c66d0 |
| --- /dev/null |
| +++ b/internal/plans/public-pricebook.json |
| @@ -0,0 +1,414 @@ |
| +{ |
| + "schema": 1, |
| + "price_book": "2026-10-01-draft1", |
| + "terms_version": "2026-09-07-v1", |
| + "status": "draft", |
| + "default_currency": "usd", |
| + "currencies": [ |
| + { |
| + "code": "usd", |
| + "symbol": "$", |
| + "minor_per_major": 100 |
| + }, |
| + { |
| + "code": "eur", |
| + "symbol": "€", |
| + "minor_per_major": 100 |
| + }, |
| + { |
| + "code": "gbp", |
| + "symbol": "£", |
| + "minor_per_major": 100 |
| + } |
| + ], |
| + "country_currency": { |
| + "AT": "eur", |
| + "BE": "eur", |
| + "BG": "eur", |
| + "CY": "eur", |
| + "CZ": "eur", |
| + "DE": "eur", |
| + "DK": "eur", |
| + "EE": "eur", |
| + "ES": "eur", |
| + "FI": "eur", |
| + "FR": "eur", |
| + "GB": "gbp", |
| + "GR": "eur", |
| + "HR": "eur", |
| + "HU": "eur", |
| + "IE": "eur", |
| + "IT": "eur", |
| + "LT": "eur", |
| + "LU": "eur", |
| + "LV": "eur", |
| + "MT": "eur", |
| + "NL": "eur", |
| + "PL": "eur", |
| + "PT": "eur", |
| + "RO": "eur", |
| + "SE": "eur", |
| + "SI": "eur", |
| + "SK": "eur" |
| + }, |
| + "country_band": { |
| + "AD": "B", |
| + "AE": "B", |
| + "AF": "D", |
| + "AG": "B", |
| + "AL": "C", |
| + "AM": "D", |
| + "AO": "D", |
| + "AR": "C", |
| + "AT": "A", |
| + "AU": "A", |
| + "AW": "B", |
| + "AZ": "D", |
| + "BA": "D", |
| + "BB": "A", |
| + "BD": "D", |
| + "BE": "A", |
| + "BF": "D", |
| + "BG": "D", |
| + "BH": "C", |
| + "BI": "D", |
| + "BJ": "D", |
| + "BM": "A", |
| + "BN": "D", |
| + "BO": "D", |
| + "BR": "C", |
| + "BS": "A", |
| + "BT": "D", |
| + "BW": "D", |
| + "BY": "D", |
| + "BZ": "C", |
| + "CA": "A", |
| + "CD": "D", |
| + "CF": "D", |
| + "CG": "D", |
| + "CH": "A", |
| + "CI": "D", |
| + "CL": "C", |
| + "CM": "D", |
| + "CN": "C", |
| + "CO": "D", |
| + "CR": "C", |
| + "CV": "C", |
| + "CW": "B", |
| + "CY": "C", |
| + "CZ": "C", |
| + "DE": "A", |
| + "DJ": "C", |
| + "DK": "A", |
| + "DM": "C", |
| + "DO": "D", |
| + "DZ": "D", |
| + "EC": "C", |
| + "EE": "C", |
| + "EG": "D", |
| + "ES": "A", |
| + "ET": "D", |
| + "FI": "A", |
| + "FJ": "D", |
| + "FM": "A", |
| + "FO": "A", |
| + "FR": "A", |
| + "GA": "D", |
| + "GB": "A", |
| + "GD": "C", |
| + "GE": "D", |
| + "GH": "D", |
| + "GL": "B", |
| + "GM": "D", |
| + "GN": "D", |
| + "GQ": "D", |
| + "GR": "A", |
| + "GT": "C", |
| + "GW": "D", |
| + "GY": "D", |
| + "HK": "B", |
| + "HN": "C", |
| + "HR": "C", |
| + "HT": "B", |
| + "HU": "C", |
| + "ID": "D", |
| + "IE": "A", |
| + "IL": "A", |
| + "IN": "D", |
| + "IQ": "D", |
| + "IR": "D", |
| + "IS": "A", |
| + "IT": "A", |
| + "JM": "C", |
| + "JO": "C", |
| + "JP": "C", |
| + "KE": "D", |
| + "KG": "D", |
| + "KH": "D", |
| + "KI": "B", |
| + "KM": "C", |
| + "KN": "B", |
| + "KR": "C", |
| + "KW": "C", |
| + "KY": "A", |
| + "KZ": "D", |
| + "LA": "D", |
| + "LB": "D", |
| + "LC": "C", |
| + "LK": "D", |
| + "LR": "C", |
| + "LS": "D", |
| + "LT": "C", |
| + "LU": "A", |
| + "LV": "C", |
| + "LY": "C", |
| + "MA": "D", |
| + "MD": "D", |
| + "ME": "D", |
| + "MG": "D", |
| + "MH": "A", |
| + "MK": "D", |
| + "ML": "D", |
| + "MM": "D", |
| + "MN": "D", |
| + "MO": "C", |
| + "MR": "D", |
| + "MT": "B", |
| + "MU": "D", |
| + "MV": "C", |
| + "MW": "D", |
| + "MX": "C", |
| + "MY": "D", |
| + "MZ": "D", |
| + "NA": "D", |
| + "NE": "D", |
| + "NG": "D", |
| + "NI": "D", |
| + "NL": "A", |
| + "NO": "B", |
| + "NP": "D", |
| + "NR": "A", |
| + "NZ": "A", |
| + "OM": "C", |
| + "PA": "C", |
| + "PE": "C", |
| + "PG": "C", |
| + "PH": "D", |
| + "PK": "D", |
| + "PL": "C", |
| + "PR": "B", |
| + "PS": "B", |
| + "PT": "A", |
| + "PW": "B", |
| + "PY": "D", |
| + "QA": "C", |
| + "RO": "D", |
| + "RS": "D", |
| + "RU": "D", |
| + "RW": "D", |
| + "SA": "C", |
| + "SB": "B", |
| + "SC": "C", |
| + "SD": "C", |
| + "SE": "A", |
| + "SG": "C", |
| + "SI": "C", |
| + "SK": "C", |
| + "SL": "D", |
| + "SM": "B", |
| + "SN": "D", |
| + "SO": "D", |
| + "SR": "D", |
| + "ST": "C", |
| + "SV": "D", |
| + "SX": "B", |
| + "SY": "D", |
| + "SZ": "D", |
| + "TC": "A", |
| + "TD": "D", |
| + "TG": "D", |
| + "TH": "D", |
| + "TJ": "D", |
| + "TL": "D", |
| + "TM": "D", |
| + "TN": "D", |
| + "TO": "B", |
| + "TR": "D", |
| + "TT": "C", |
| + "TV": "A", |
| + "TZ": "D", |
| + "UA": "D", |
| + "UG": "D", |
| + "US": "A", |
| + "UY": "B", |
| + "UZ": "D", |
| + "VC": "C", |
| + "VI": "A", |
| + "VN": "D", |
| + "VU": "A", |
| + "WS": "C", |
| + "XK": "D", |
| + "ZA": "D", |
| + "ZM": "D", |
| + "ZW": "D" |
| + }, |
| + "band_factors_bps": { |
| + "A": 10000, |
| + "B": 7500, |
| + "C": 5000, |
| + "D": 3500 |
| + }, |
| + "plans": [ |
| + { |
| + "key": "free", |
| + "min_quantity": 1, |
| + "max_quantity": 1, |
| + "band_scaled": false, |
| + "annual_unit_minor": { |
| + "eur": { |
| + "A": 0, |
| + "B": 0, |
| + "C": 0, |
| + "D": 0 |
| + }, |
| + "gbp": { |
| + "A": 0, |
| + "B": 0, |
| + "C": 0, |
| + "D": 0 |
| + }, |
| + "usd": { |
| + "A": 0, |
| + "B": 0, |
| + "C": 0, |
| + "D": 0 |
| + } |
| + }, |
| + "resources_per_member": { |
| + "awake_seconds": 360000, |
| + "storage_byte_months": 250000000, |
| + "transfer_bytes": 1000000000, |
| + "writes": 0 |
| + } |
| + }, |
| + { |
| + "key": "personal", |
| + "min_quantity": 1, |
| + "max_quantity": 1, |
| + "band_scaled": true, |
| + "annual_unit_minor": { |
| + "eur": { |
| + "A": 2400, |
| + "B": 1800, |
| + "C": 1200, |
| + "D": 840 |
| + }, |
| + "gbp": { |
| + "A": 2200, |
| + "B": 1650, |
| + "C": 1100, |
| + "D": 770 |
| + }, |
| + "usd": { |
| + "A": 2800, |
| + "B": 2100, |
| + "C": 1400, |
| + "D": 980 |
| + } |
| + }, |
| + "resources_per_member": { |
| + "awake_seconds": 306000, |
| + "storage_byte_months": 2000000000, |
| + "transfer_bytes": 2000000000, |
| + "writes": 0 |
| + } |
| + }, |
| + { |
| + "key": "community", |
| + "min_quantity": 10, |
| + "max_quantity": 1000000, |
| + "band_scaled": true, |
| + "annual_unit_minor": { |
| + "eur": { |
| + "A": 500, |
| + "B": 375, |
| + "C": 250, |
| + "D": 175 |
| + }, |
| + "gbp": { |
| + "A": 450, |
| + "B": 338, |
| + "C": 225, |
| + "D": 158 |
| + }, |
| + "usd": { |
| + "A": 600, |
| + "B": 450, |
| + "C": 300, |
| + "D": 210 |
| + } |
| + }, |
| + "resources_per_member": { |
| + "awake_seconds": 61200, |
| + "storage_byte_months": 500000000, |
| + "transfer_bytes": 400000000, |
| + "writes": 0 |
| + } |
| + }, |
| + { |
| + "key": "business", |
| + "min_quantity": 10, |
| + "max_quantity": 1000000, |
| + "band_scaled": true, |
| + "annual_unit_minor": { |
| + "eur": { |
| + "A": 5000, |
| + "B": 3750, |
| + "C": 2500, |
| + "D": 1750 |
| + }, |
| + "gbp": { |
| + "A": 4500, |
| + "B": 3375, |
| + "C": 2250, |
| + "D": 1575 |
| + }, |
| + "usd": { |
| + "A": 6000, |
| + "B": 4500, |
| + "C": 3000, |
| + "D": 2100 |
| + } |
| + }, |
| + "resources_per_member": { |
| + "awake_seconds": 612000, |
| + "storage_byte_months": 5000000000, |
| + "transfer_bytes": 3000000000, |
| + "writes": 30000 |
| + } |
| + } |
| + ], |
| + "overage": { |
| + "eur": { |
| + "awake_hour_micro": 10000, |
| + "storage_gb_month_micro": 106000, |
| + "transfer_gb_micro": 414000, |
| + "writes_1000_micro": 23000, |
| + "invoice_floor_micro": 1000000 |
| + }, |
| + "gbp": { |
| + "awake_hour_micro": 9000, |
| + "storage_gb_month_micro": 95400, |
| + "transfer_gb_micro": 372600, |
| + "writes_1000_micro": 20700, |
| + "invoice_floor_micro": 900000 |
| + }, |
| + "usd": { |
| + "awake_hour_micro": 12000, |
| + "storage_gb_month_micro": 127200, |
| + "transfer_gb_micro": 496800, |
| + "writes_1000_micro": 27600, |
| + "invoice_floor_micro": 1200000 |
| + } |
| + } |
| +} |