Pricing Plan Strings

Pricing Plan Strings #

The plans endpoints (GET /api/v1/onboarding/plans, GET /api/v1/billings/plans) return each plan’s description, tagline and attributes in the caller’s language. name is never translated. gRPC ListPricingPlans and the MCP list_pricing_plans stay in English.

Where the text lives #

  • Catalog: config/i18n/<locale>/pricing-plans-strings.json, translated by mise run i18n from config/i18n/en/pricing-plans-strings.json. It holds all the text, English included, and bonsapi compiles it in (apps/bonsapi/build.rs). The webapp and tofu-web bundles do not carry it (BACKEND_NAMESPACE_FILES in tools/local/scripts/i18n-gen/src/app-namespaces.ts).
    • plans.<plan_key>.description: a plan’s description.
    • strings.<key>: everything Stripe names by key: taglines (tagline_<plan_key>) and attributes.
  • Stripe product metadata carries keys, not text: plan_key, tagline (tagline_pro) and attributes, a JSON array of keys (["unlimited_team_members","integrations_xero_qbo_8_more"]).
  • The product description stays literal English in Stripe, since Checkout shows it. English keeps Stripe’s description (so gRPC and MCP read as before); another language reads plans.<plan_key>.description and falls back to Stripe’s English. The catalog’s English only fills in when Stripe’s description is empty.

A key is lowercase letters and digits in words joined by single underscores, with at least one underscore (^[a-z][a-z0-9]*(_[a-z0-9]+)+$, as is_key in apps/bonsapi/src/service/billing/pricing_plans_strings.rs). Any other Stripe value, such as unlimited or Up to 2 entities, is literal text and passes through in English. That keeps production working while the webapp still reads its Stripe metadata directly (/api/stripe/products); migrate production’s metadata to keys once the switches are on and that route is gone.

Changing plan copy #

  1. Add or change the text under plans / strings in config/i18n/en/pricing-plans-strings.json.
  2. Run mise run i18n. It retranslates the keys whose English changed.
  3. Commit the English and locale files together, then point Stripe’s tagline / attributes at the key. A new key must ship before Stripe uses it.

A key missing from a locale reads English, and strings_fallback is true. A key missing from the catalog is dropped and logged at error level as plan_copy_unknown_key. plan_copy_untranslated (info) lists the plans with strings not in the requested language. gRPC ListPricingPlans and the MCP tool resolve the keys to English.

Which language is used #

Both plans endpoints require a locale query parameter (a tag from config/i18n/locales.json, the same list the webapp supports, matched case-insensitively; pt_BR and zh-Hans-CN resolve to pt-BR and zh-CN). Clients must always send it, but a missing, empty or unsupported one reads English: a plans request never fails over a language. The response’s locale is the language used and strings_fallback says whether some strings stayed English.

bonsapi does not look up the user’s saved language in Clerk: that call would add its latency to every plans request, and the client already knows the language it shows. The webapp passes useLocale().locale.