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 bymise run i18nfromconfig/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_FILESintools/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) andattributes, a JSON array of keys (["unlimited_team_members","integrations_xero_qbo_8_more"]). - The product
descriptionstays literal English in Stripe, since Checkout shows it. English keeps Stripe’s description (so gRPC and MCP read as before); another language readsplans.<plan_key>.descriptionand 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 #
- Add or change the text under
plans/stringsinconfig/i18n/en/pricing-plans-strings.json. - Run
mise run i18n. It retranslates the keys whose English changed. - Commit the English and locale files together, then point Stripe’s
tagline/attributesat 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.