Onboard a New Country/FSP Market¶
Onboarding a new country into Yaya looks like a small change from inside yaya-engine — a config array entry, an Fsp seeder, and a geography backfill — but the Ethiopia pilot proved it is really a coordinated change across four repos and several independent layers of the same data. This guide walks through the yaya-engine-side mechanics and then walks through the specific mistakes the Ethiopia launch made, because the mistakes are more likely to repeat than the happy path is to go wrong.
What "new country" actually configures¶
config/countries.php is a small, per-country array keyed by ISO-2 code. Each entry has calendar (gregorian or ethiopic), timezone, default_locale, and an optional date_pattern — Kenya (KE) uses gregorian/Africa/Nairobi/sw with the default date pattern, while Ethiopia (ET) overrides all four fields, including ethiopic for the calendar and a 12-hour, month-first date_pattern [@countries-config]. CountryLocale::forCountry() reads this config, falling back field-by-field to gregorian/UTC/en/the default pattern if a country code is missing or a field is left blank, so a new country entry only needs to override what actually differs from the default [@country-locale]. AlertDateFormatter::formatForUser() is the consumer: it builds an ICU locale string from the user's own locale plus the FSP's country calendar, and falls back all the way to a hardcoded en/UTC/Gregorian formatter if ICU itself rejects the country's timezone or pattern — a typo in countries.php degrades date formatting rather than crashing dispatch [@alert-date-formatter].
Because this config drives date formatting for every alert a user in that country receives, get the calendar and pattern right at onboarding time rather than discovering the display bug in production. Ethiopia's ethiopic calendar plus 12-hour Amharic-facing pattern is the concrete precedent to copy from for any other country using a non-Gregorian calendar.
Seeding the FSP row¶
database/seeders/EthiopiaPilotFspSeeder.php is the template for seeding a new FSP/country pairing. It calls Fsp::query()->updateOrCreate(['slug' => 'ethiopia-pilot'], [...]), keyed on slug rather than an auto-increment id, so re-running the seeder in any environment updates the same row in place instead of duplicating it [@ethiopia-seeder]. The seeded attributes set country to the ISO-2 code (ET), default_locale to the country's primary language (am), branding colors, a tos_link, and airtime_support_enabled: true for markets where cash payouts route through mobile airtime rather than M-Pesa [@ethiopia-seeder]. See FSP as a multi-tenancy concept for why this single Fsp row is what drives branding, channel credentials, and country scoping for every user attached to it.
For a country onboarded without a third-party bank partner — an "FSP-less" pilot, in the Ethiopia launch's own terminology — the seeded FSP is Atram itself acting as the fallback tenant, not a partner institution; see Atram, Yaya, FSP, and Rada vocabulary for that fallback-FSP concept.
Geography and thresholds¶
A new country's weather geography (towns, states, rain thresholds) has to exist in yaya-engine's own Postgres tables before real users in that country can receive alerts scoped to a location. php artisan rada:backfill-geography pulls towns_24h_rain_thresholds and states_24h_rain_thresholds from Supabase and upserts them into yaya-engine's towns/states tables, idempotently, keyed on (country, state, town) and (country, state) respectively [@backfill-command]. This command is the yaya-engine side of a broader Rada v2 migration described in Supabase Integration and the Rada v2 Cutover — during the migration window, a new country's geography still originates in Supabase via rada-weather ingest scripts, and this backfill command is what carries it into yaya-engine.
Do not assume "the migration merged" means "the data is there." The Ethiopia geography-cutover incident found towns/states sitting empty in production for roughly two weeks after the backfill command and its migration had both shipped, because the one-time operational step of actually running the command against prod was never done — closing the ticket tracked code shipped, not the backfill actually executing [@thresholds-dual-system]. Verify row counts for the new country's rows after running the backfill, not just that the command exists and returns success in a lower environment.
Mistakes the Ethiopia launch actually made¶
The Ethiopia v1 pilot (1,148 woredas, 14 regions, four repos) is documented as a cross-repo launch postmortem, and its lessons are the most useful part of onboarding a new country because they are exactly the mistakes most likely to recur [@launch-summary].
- Country keys must stay lowercase everywhere. Prod's
scheduled_alerts.countryand the composer's routing maps are lowercase strings. A PR that introduced Title-Case map keys for the new country would have silently regressed Kenya FSP routing (no match falls through to a default) and Colombia's language selection (no match falls through to English) — caught before merge, but only because someone happened to review it closely [@launch-summary]. Any new country's config or lookup keys should be added as lowercase to match the existing convention, not "however the source data happens to be cased." - A geography rename has to move at every layer that ingests independently. The HDX woreda dataset labeled 18 contested border woredas as "Contested"; the decision to merge them into Tigray had to be applied in three separate places — a one-shot SQL update against Supabase prod, a fix in the
rada-weatheringest script (so the next re-ingest doesn't reintroduce the old label), and a fix in the frontend topojson build — because each layer reads the source data independently and a fix in only one layer gets silently overwritten by the next ingest run from another layer [@launch-summary]. Any renamed or merged geography unit for a new country needs the same "which layers ingest this independently" audit. - Key the PWA link (and similar per-user links) by country, not by an existing per-user heuristic. The composer originally picked the PWA link URL using
yaya_user_id != nullas a proxy for "this user is on the Fortune Credit deployment" — which worked by accident for Kenya but silently sent the wrong PWA link to any other non-null-yaya_user_iduser, including new Ethiopia users. The fix was a country-keyed lookup instead of an incidental per-user heuristic [@launch-summary]. When onboarding a new country, check whether any existing per-user proxy logic like this needs to become explicit country-keyed logic instead. - Dual source-of-truth during the Rada v2 migration window means threshold and geography updates need two writes, not one. After the Rada v2 tables landed in yaya-engine but before Supabase's legacy tables were retired, an Ethiopia threshold change had to update both systems — Supabase (still read by legacy
rada-message-systemcron andrada-weatherETL) and yaya-engine's owntowns/statestables (the new source of truth) — because writes to yaya-engine do not flow back to Supabase and vice versa [@thresholds-dual-system]. Until the legacy Supabase paths are fully retired, any per-country data change (thresholds, geography, renames) needs a "did I update both systems" check, not just a merged migration. - Large country-onboarding branches are easier to review stacked. The lowercase-keys fix above landed as a PR stacked on top of the original country-onboarding PR rather than folded into it or forced through a rebase, which preserved the original review history and kept the fix itself small and reviewable [@launch-summary]. For a country-onboarding change that touches dozens of files, plan for a stack of small PRs rather than one large one from the start.
Verification¶
After seeding the FSP and running the geography backfill, confirm the new country end-to-end rather than trusting that each step succeeded independently: check that config('countries.<CODE>') resolves through CountryLocale::forCountry() to the calendar/timezone/locale you intended, that the seeded Fsp row has the right country and default_locale, and that a row-count query against towns/states for the new country's ISO-2 code returns a non-zero count matching what Supabase (or the source dataset) has for that country. The Ethiopia incident's lesson is specifically that a migration merging and a command existing are not evidence the data landed — query the actual row counts.