Skip to content

Per-FSP Locale Override for Telegram Registration

Telegram registration derived a new user's locale entirely from Telegram's language_code field, but Telegram's native client does not offer Amharic as an option at all, so an Ethiopian user's language_code was never am — it was whatever their phone's system language happened to be, overwhelmingly en [@design-doc]. TelegramUserResolver::resolveLocale()'s mapping actively made this worse for the pilot: its 'en' => LocaleEnum::English arm meant Ethiopia-pilot users with an English-language phone were placed into English, and Amharic was only ever reached by accident, through the default arm for missing or unrecognized codes [@telegram-user-resolver]. The fix is a new nullable fsps.default_locale column: when an FSP sets it, that value is authoritative at registration, overriding whatever language_code would otherwise have produced.

Context

The FSP already carried a country column (ET for the Ethiopia pilot), and CountryLocale::forCountry('ET') already knew how to map that to am — but that mapping was only ever consumed for date formatting, never for a user's own locale, and the Telegram registration flow never even set users.country [@design-doc]. The team considered deriving user locale from FSP country generally, and rejected it: an earlier migration had backfilled every pre-existing FSP's country to KE by default, so a country-derived locale rule would have silently flipped Atram, Fortune Credit (10,605 users), and Bancamia registrations toward Swahili, none of which was wanted [@design-doc]. The chosen mechanism therefore had to be opt-in per FSP, not an automatic inference from a field that was already populated for unrelated reasons.

Decision

fsps.default_locale is a nullable string(5) column added with no default value and no backfill in its own migration, so every existing FSP starts null and deploying the migration is behaviorally a no-op until an operator explicitly sets a value [@default-locale-migration]. App\Models\Fsp casts it to LocaleEnum, so a set column reads back as a LocaleEnum case and an unset one reads back as null.

The only change to the registration path itself is the locale expression inside TelegramUserResolver::findOrCreate()'s User::create([...]) call:

'locale' => $fsp->default_locale ?? $this->resolveLocale($result->user['language_code'] ?? null),

[@telegram-user-resolver]

When the FSP has no override, this is exactly the previous behavior — resolveLocale() is untouched, private, and still maps language_code prefixes en/sw/es with an Amharic default for anything else [@telegram-user-resolver]. When the FSP does have an override, it wins unconditionally, regardless of what language_code Telegram sent — which is precisely the case that was broken before: an Ethiopia-pilot user with language_code: 'en' now gets am because the FSP's default_locale short-circuits the ?? before resolveLocale() is ever called.

Fixing the ~717 users who had already registered under the old, broken mapping required a second mechanism, since the migration itself deliberately does not backfill: php artisan users:align-fsp-locale [--dry-run] [--fsp=] selects every FSP with a non-null default_locale, finds users on that FSP whose locale differs from it, and bulk-updates them [@align-fsp-locale-command]. The command supports --dry-run to preview a per-FSP count before writing anything, and --fsp= to scope a run to one FSP by id or slug — running it twice in a row is idempotent, since a user already aligned to the target locale is excluded from the next run's diff. See the Telegram bot architecture for where TelegramUserResolver::findOrCreate() sits in the registration flow, and FSP for how per-FSP configuration like this fits the multi-tenancy model.

Status

Approved and implemented.

Consequences

Configuring an FSP's default_locale is now a Filament form field, not a code change — an operator sets it once per FSP and every subsequent Telegram registration for that FSP is unaffected by whatever language the user's Telegram client happens to be set to. The design deliberately narrows its own blast radius: it does not touch the Filament "Create User" admin path, which still hardcodes locale => 'en' for ops-created users, and it does not attempt to re-sync Mixpanel profiles after a bulk backfill, since the bulk update() used by the alignment command bypasses model events entirely and is intentional — a chunked, event-firing update was rejected because a straight SQL UPDATE needs no per-row memory even at Fortune Credit's user scale [@design-doc]. Both gaps are recorded as explicit follow-ups rather than silently accepted, so a future maintainer looking at a newly ops-created Ethiopia-pilot user in English, or a stale Mixpanel locale property after a backfill, is looking at a known, deliberate omission rather than a bug in this design.