DEV Community

Samuel Airehrour
Samuel Airehrour

Posted on

Running One Compliance Flow Across Two Countries That Don't Agree on Much

Running One Compliance Flow Across Two Countries That Don't Agree on Much

Nigeria and Kenya have different numbers of steps in their compliance flow. They don't structure director and shareholder data the same way. Business type changes what's even required. And on top of all that, there's an admin review process where a reviewer can reject three fields out of forty and the applicant has to fix exactly those three, nothing else.

None of that is one hard problem. It's five or six medium problems that all had to agree with each other, because if the schema says one thing and the step routing says another, or the visibility logic doesn't line up with what the review flow thinks is editable, you don't get a bug in one spot, you get a user stuck somewhere in the middle of a KYC flow with no way forward.

Here's roughly how it's put together.

Config per country

Nigeria and Kenya each get their own schema object, sections, subsections, fields, validation rules, all described as data rather than JSX:

export const NgnComplianceConfig: ComplianceFormConfig = {
  countryCode: "NG",
  sections: [
    {
      id: "business_profile",
      sub_category: [
        {
          id: "TELL_US_MORE_ABOUT_YOUR_BUSINESS",
          fields: [
            { key: "registered_business_name", type: "text", validation: { required: true, min: 4, max: 255 } },
            {
              key: "business_sub_category",
              type: "select",
              dynamicOptions: { financial_services: [ /* ... */ ], retail: [ /* ... */ ] },
              conditional: { dependsOn: "business_category", showWhen: ["financial_services", "retail"] },
            },
          ],
        },
      ],
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

This part is honestly the easy half. Describing what fields exist for a country is straightforward once you commit to doing it as config. What the schema doesn't tell you is what screen step "3" maps to for Nigeria versus Kenya, or what order those screens come in, or which fields on a given screen a specific business type in a specific country should even see. That's tracked separately, and keeping it lined up with the schema is most of the actual work.

One renderer for every field type, no country logic inside it

FormBuilderRenderer is a switch statement over field type, text, select, checkbox, file, and so on. Nothing unusual there. What's in it that's worth mentioning: phone validation runs through libphonenumber-js with per country digit limits hardcoded in (10 for Nigeria, 9 for Kenya), file fields drive an S3 presigned upload with change detection so a re render doesn't accidentally kick off a duplicate upload, and the percentage ownership field has two different layouts depending on where it sits in the grid because design wanted it inline with a label in one place and standalone in another.

The renderer itself has zero awareness of which country it's rendering. It gets a field definition, it produces a control. All the country specific behavior lives above it.

Visibility rules live in the schema, not in components

Whether a field shows up is dependsOn plus showWhen or hideWhen, checked by one shared function used across the whole app:

export const isFieldCurrentlyVisible = (
  field: FieldItem,
  formValues: Record<string, any>,
): boolean => {
  if (!field.conditional) return true;

  const dependentValue = formValues[field.conditional.dependsOn];
  const { showWhen, hideWhen } = field.conditional;

  if (hideWhen !== undefined) {
    return Array.isArray(hideWhen)
      ? !hideWhen.includes(String(dependentValue))
      : String(dependentValue) !== hideWhen;
  }

  if (showWhen === undefined) return !!dependentValue;

  return Array.isArray(showWhen)
    ? showWhen.includes(String(dependentValue))
    : String(dependentValue) === showWhen;
};
Enter fullscreen mode Exit fullscreen mode

useFormWithVisibleFields watches the entire form and filters the field list live as answers change. Simple enough on its own. It gets more complicated once the review flow reuses it, because a field being hidden and a field being irrelevant to the admin's rejection turn out to not be the same thing, and I'll get to why that matters.

A director isn't the same object in both countries

Depending on country, business type, and which step you're on, the same conceptual field, first name, phone, whatever, needs a different key prefix on the backend: dir_, sh_, sp_, or llc_ind_. One function figures out which:

export const getDirectorPrefix = (
  compliance: ComplianceFormConfig,
  isSolePropreitorship: boolean,
  isLimitedLiability: boolean,
  selectedDirectorType: string | null,
  step: string,
): "llc_ind" | "sh" | "sp" | "dir" => {
  const isCountryKenya = compliance?.countryCode?.toLowerCase() === "ke";

  if (isCountryKenya) {
    if (isSolePropreitorship) return "sp";
    if (isLimitedLiability) {
      if (selectedDirectorType === "organization") return "llc_ind";
      if (step === "2b") return "sh";
      return "dir";
    }
  }

  if (compliance?.countryCode?.toLowerCase() === "ng") {
    return step === "2b" || step === "shareholder" ? "sh" : "dir";
  }

  return "dir";
};
Enter fullscreen mode Exit fullscreen mode

mapToDirectors and mapFromDirectors handle the actual key translation both ways, so everything above this layer works with one normalized Directors object and never has to think about which prefix a given backend response used. It's one seam. The point isn't this function, it's that everything downstream, step routing, autosave, review, gets to assume a clean shape exists, and this is the thing quietly making that assumption true.

Deciding what screen exists for which country

This is the part that took the most actual thinking, and it's the least glamorous to talk about. Nigeria's flow has six steps: company info, KYC documents, directors, bank details, service agreement, review. Kenya's is shorter, and director and shareholder information get handled across sub steps rather than a dedicated top level step the way Nigeria does it.

Something has to decide, per country, which component renders at which step, hand it the right slice of compliance data, and stay in agreement with wherever step order and step completion get tracked elsewhere. That agreement doesn't happen automatically. It's not a schema field, it's not a renderer switch case, it's a layer that has to be built on purpose and then not drift out of sync as steps get added or reshuffled. Cross cutting concerns like this are the ones that quietly rot if nobody's paying attention, because a single field's validation rule breaking is loud and obvious, and a step routing mismatch just means someone gets stuck on step "2a" that doesn't exist anywhere.

Review and resubmission read off the same model

Once an admin rejects specific fields, the flow locks everything else, figures out which fields on the current page are actually reviewable, and has to handle the case where a rejected field is conditionally hidden because whatever it depends on hasn't been fixed yet. That logic reads from the same schema, the same visibility function, and the same step context as the regular fill and submit path. It's not a second parallel system bolted on top.

That reuse only worked because the schema, the visibility engine, and the director mapping were never written assuming there'd only ever be one caller. If they'd been coupled tightly to the plain submission flow, the review logic would've needed its own copy of all of it, and then you're maintaining two versions of "what does visible mean" that can quietly disagree with each other over time.

The thing I'd actually point to

If someone asked me what the hardest part of this was, it's not any one function. It's that a schema change for one country, a new field, a new business type rule, has to not break the step routing for the other country, has to not break autosave, has to not break what the review flow thinks is editable. None of those things call each other directly. They all just have to agree on the same shape of the world, and keeping that agreement true as the system grows is most of what the work actually was.

Top comments (1)

Collapse
 
ikeogoli profile image
Ikeogoli Ishie

πŸ‘πŸΎπŸ‘πŸΎ