Skip to content

feat: enforce 10-digit NUBAN validation on Swap (fix 11-digit acceptance)#372

Open
sundayonah wants to merge 3 commits intomainfrom
fix/nuban-10-digit-validation
Open

feat: enforce 10-digit NUBAN validation on Swap (fix 11-digit acceptance)#372
sundayonah wants to merge 3 commits intomainfrom
fix/nuban-10-digit-validation

Conversation

@sundayonah
Copy link
Collaborator

@sundayonah sundayonah commented Feb 16, 2026

Description

Purpose: Enforce the NUBAN rule that account numbers must be exactly 10 digits (or 6 for SAFAKEPC) on the Swap flow. Previously, 11-digit values (e.g. OPay-style phone numbers like 08012345678) were accepted, the UI showed a successful account name resolution, and the transaction later failed at execution. This change validates length before resolving the account name so invalid input is rejected in the UI and at the API.

Background: NUBAN (Nigerian Uniform Bank Account Number) is 10 digits. The previous check used minLength: 10, so 11+ digits still passed. We now require exact length (10 or 6 for SAFAKEPC) and block submission / API calls when the length is wrong.

Changes:

  • RecipientDetailsForm (Swap screen):

    • Account number input: type="number"type="text" with inputMode="numeric" and maxLength 10 (or 6 for SAFAKEPC).
    • Validation: custom validate that strips non-digits and requires length 10 (or 6 for SAFAKEPC). Invalid input shows: "Please enter a valid 10-digit account Number." (or the 6-digit message for SAFAKEPC).
    • Account-name resolution effect: only runs when digit length is exactly 10 (or 6). For wrong length (e.g. 11 digits), we set the same error message and do not call the verify API, so the UI never shows "success" for invalid numbers.
  • API routes (defence in depth):

    • POST /api/v1/account/verify: Before calling the aggregator, validates that accountIdentifier (digits only) has length 10 or 6 (SAFAKEPC). Otherwise returns 400 with the same error message.
    • POST /api/v1/recipients: Before saving a beneficiary, same length check so 11-digit (or other invalid length) account numbers are never stored.

Impacts:

  • Users can no longer enter 11-digit numbers and get a false "success" or proceed to a failing transaction.
  • Existing saved beneficiaries with 11-digit account numbers remain in the DB; selecting them will now show the validation error until the user corrects the number or re-adds with a valid 10-digit NUBAN.
  • No change to the contract or response shape of the APIs; only an additional validation step and 400 responses when length is invalid.

Alternatives considered: Normalising 11-digit numbers (e.g. strip leading 0) was considered for backward compatibility but not implemented so we stay aligned with NUBAN and avoid provider-specific assumptions.


References

#Closes #371


Testing

Manual testing:

  1. Swap screen – 11-digit rejection

    • Go to Swap, choose a Fintech provider (e.g. OPay or PalmPay).
    • Enter an 11-digit number (e.g. 08012345678) in Account Number.
    • Expected: Error under the field: "Please enter a valid 10-digit account Number." No account name resolution, no "Successful" state.
    • Enter exactly 10 digits (e.g. 8012345678). Expected: Account name resolves if valid; no length error.
  2. 10-digit acceptance

    • Enter a valid 10-digit NUBAN. Expected: Account name fetches and displays; user can continue to preview/confirm.
  3. Saving beneficiary

    • Try to save a beneficiary with an 11-digit account number (e.g. via a client that bypasses the form). Expected: API returns 400 with "Please enter a valid 10-digit account Number." and the beneficiary is not saved.
  • This change adds test coverage for new/changed/fixed functionality

Checklist

  • I have added documentation and tests for new/changed functionality in this PR
  • All active GitHub checks for tests, formatting, and security are passing
  • The correct base branch is being used, if not main

By submitting a PR, I agree to Paycrest's Contributor Code of Conduct and Contribution Guide.

image

Summary by CodeRabbit

  • Bug Fixes
    • Enforced institution-specific account number digit lengths (6 or 10) in both form and API
    • Inputs accept numeric characters only and enforce dynamic max length
    • Clear, contextual error messages for invalid-length account numbers
    • Recipient name lookup now runs only for valid-length inputs
    • Account numbers are sanitized so consistent numeric values are sent, logged, and stored

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 16, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Added digit-only length validation for account identifiers in both the recipients POST API and the RecipientDetailsForm: strips non-digits, requires 6 digits for SAFAKEPC and 10 otherwise, rejects mismatches with specific 400 responses/errors, and stores sanitized digits on successful insert/upsert.

Changes

Cohort / File(s) Summary
Backend Validation
app/api/v1/recipients/route.ts
Strip non-digits from accountIdentifier, compute required length (6 for SAFAKEPC, else 10); on mismatch, log API error, track validation error, and return 400 with a targeted message. On success, store sanitized digits and trimmed institution_code in insert/upsert.
Frontend Validation
app/components/recipient/RecipientDetailsForm.tsx
Pre-fetch validation extracts digits and enforces institution-specific length (6 or 10). If invalid, set recipientNameError and block name-resolve fetch. Input changed to text with numeric inputMode, dynamic maxLength, and custom validate replacing prior minLength. Clears errors when corrected.

Sequence Diagram(s)

sequenceDiagram
  participant User as User (UI)
  participant Form as RecipientDetailsForm
  participant API as Recipients API
  participant DB as Database
  participant Tracker as Error Tracker

  rect rgba(200,200,255,0.5)
  User->>Form: Enter accountIdentifier
  Form->>Form: extract digits, determine required length (6 or 10)
  alt invalid length
    Form-->>User: show length error (6 or 10 digits)
  else valid length
    Form->>API: POST sanitized digits / request name-resolve
    API->>API: validate digits length again
    alt invalid length
      API->>Tracker: track validation error
      API-->>Form: 400 with required-length message
    else valid
      API->>DB: insert/upsert sanitized digits & trimmed institution_code
      DB-->>API: confirm write
      API-->>Form: 200 OK / recipient data
    end
  end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop on digits, trim and check,
Six or ten—no extra speck,
I stop the fetch when lengths won't fit,
Store clean numbers, neat and lit.
Hooray for tidy hops and code that sticks!

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: enforce 10-digit NUBAN validation on Swap (fix 11-digit acceptance)' clearly summarizes the main change: enforcing exact 10-digit account number validation to reject the previously-accepted 11-digit numbers.
Description check ✅ Passed The PR description fully covers all required template sections: detailed purpose explaining the NUBAN validation fix, comprehensive change list for both UI and API layers, impact analysis, alternatives considered, manual testing steps, and completed checklist items.
Linked Issues check ✅ Passed The PR fully addresses issue #371 by implementing client-side and API-level validation to reject 11-digit account numbers, displaying the appropriate error message, and preventing false success states before account-name resolution.
Out of Scope Changes check ✅ Passed All changes in RecipientDetailsForm and API routes directly support the 10-digit NUBAN validation objective with no extraneous modifications; the institution_code trimming is a supporting change for consistent validation and storage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/nuban-10-digit-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.

In `@app/api/v1/recipients/route.ts`:
- Around line 155-176: Validation currently strips non-digits into the variable
digits and uses digits.length to validate, but the code later persists the raw
accountIdentifier (trimmed) which may retain spaces or non-digit chars; update
the persistence to save the sanitized digits variable (or a normalized version
like digits.trim()) instead of accountIdentifier.trim(), and ensure any
downstream uses (e.g., recipient creation code around the save) expect the
numeric form; also standardize the error message text to use consistent
capitalization ("account number") in both NextResponse.json branches so messages
match.

In `@app/components/recipient/RecipientDetailsForm.tsx`:
- Around line 392-415: Extract the repeated ternary (selectedInstitution?.code
=== "SAFAKEPC" ? 6 : 10) into a single derived constant (e.g.,
requiredAccountLen) at the component level, then replace all usages in
maxLength, the register validate function for "accountIdentifier", and the
useEffect that currently computes requiredLen so they all reference
requiredAccountLen; ensure validation logic still compares digits.length to
requiredAccountLen and error messages remain conditional based on whether
requiredAccountLen === 10.
- Around line 394-396: The text input in RecipientDetailsForm (the input with
type="text" and inputMode="numeric") currently allows letters to be typed or
pasted; add an onInput handler on that input element in the RecipientDetailsForm
component that strips non-digit characters (e.g., replace non-digits with empty
string) so maxLength counts only digits and pasted text is sanitized; ensure the
handler uses e.currentTarget.value (or the equivalent event target) to update
the input value in-place.
🧹 Nitpick comments (2)
🤖 Fix all nitpicks with AI agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.

In `@app/components/recipient/RecipientDetailsForm.tsx`:
- Around line 392-415: Extract the repeated ternary (selectedInstitution?.code
=== "SAFAKEPC" ? 6 : 10) into a single derived constant (e.g.,
requiredAccountLen) at the component level, then replace all usages in
maxLength, the register validate function for "accountIdentifier", and the
useEffect that currently computes requiredLen so they all reference
requiredAccountLen; ensure validation logic still compares digits.length to
requiredAccountLen and error messages remain conditional based on whether
requiredAccountLen === 10.
- Around line 394-396: The text input in RecipientDetailsForm (the input with
type="text" and inputMode="numeric") currently allows letters to be typed or
pasted; add an onInput handler on that input element in the RecipientDetailsForm
component that strips non-digit characters (e.g., replace non-digits with empty
string) so maxLength counts only digits and pasted text is sanitized; ensure the
handler uses e.currentTarget.value (or the equivalent event target) to update
the input value in-place.
app/components/recipient/RecipientDetailsForm.tsx (2)

392-415: Consider extracting duplicate requiredLen logic.

The expression selectedInstitution?.code === "SAFAKEPC" ? 6 : 10 is repeated in three places: the useEffect (Line 253), maxLength (Line 398), and validate (Line 406). A derived constant at the component level would reduce duplication and the risk of these falling out of sync.

Suggested refactor

Add near the other derived values (e.g., after Line 46):

const requiredAccountLen = selectedInstitution?.code === "SAFAKEPC" ? 6 : 10;

Then reference requiredAccountLen in all three locations.

🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.
In `@app/components/recipient/RecipientDetailsForm.tsx` around lines 392 - 415,
Extract the repeated ternary (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10)
into a single derived constant (e.g., requiredAccountLen) at the component
level, then replace all usages in maxLength, the register validate function for
"accountIdentifier", and the useEffect that currently computes requiredLen so
they all reference requiredAccountLen; ensure validation logic still compares
digits.length to requiredAccountLen and error messages remain conditional based
on whether requiredAccountLen === 10.

394-396: Optional: filter non-digit input on keystrokes.

Since the input is type="text", users can type or paste letters. While validation catches this, you could strip non-digits on input for a smoother UX:

onInput={(e) => {
  e.currentTarget.value = e.currentTarget.value.replace(/\D/g, "");
}}

This would make maxLength count only actual digits and prevent confusion.

🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.
In `@app/components/recipient/RecipientDetailsForm.tsx` around lines 394 - 396,
The text input in RecipientDetailsForm (the input with type="text" and
inputMode="numeric") currently allows letters to be typed or pasted; add an
onInput handler on that input element in the RecipientDetailsForm component that
strips non-digit characters (e.g., replace non-digits with empty string) so
maxLength counts only digits and pasted text is sanitized; ensure the handler
uses e.currentTarget.value (or the equivalent event target) to update the input
value in-place.

…nitized digits for consistent database format
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/api/v1/recipients/route.ts (1)

155-176: institutionCode should be trimmed before the comparison on Line 157.

On Line 234, institutionCode is trimmed for DB storage, but on Line 157 it's compared raw. If the client sends " SAFAKEPC ", the 6-digit branch won't match and the user gets an incorrect "enter a valid 10-digit account number" error for a SAFAKEPC account.

Proposed fix
-    const requiredLen = institutionCode === "SAFAKEPC" ? 6 : 10;
+    const trimmedInstitutionCode = institutionCode.trim();
+    const requiredLen = trimmedInstitutionCode === "SAFAKEPC" ? 6 : 10;

Then reuse trimmedInstitutionCode on Line 234 as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/v1/recipients/route.ts` around lines 155 - 176, Trim institutionCode
before using it to decide requiredLen and reuse that trimmed value later;
specifically, create or move the trimmedInstitutionCode (or compute const
trimmedInstitutionCode = String(institutionCode).trim()) above the validation
block that computes requiredLen and use trimmedInstitutionCode in the ternary
check (for "SAFAKEPC") and replace later raw institutionCode use on DB storage
with trimmedInstitutionCode so both validation and storage use the same
normalized value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/api/v1/recipients/route.ts`:
- Around line 225-235: The saved_recipients upsert was previously persisting
un-sanitized account identifiers; replace any use of accountIdentifier.trim()
with the sanitized digits variable so the DB stores only digits — ensure the
upsert object in the route.ts upsert call uses account_identifier: digits (and
keep name.trim(), institution.trim(), institution_code.trim(),
normalized_wallet_address/wallet_address as shown) to maintain consistent,
sanitized storage.

---

Nitpick comments:
In `@app/api/v1/recipients/route.ts`:
- Around line 155-176: Trim institutionCode before using it to decide
requiredLen and reuse that trimmed value later; specifically, create or move the
trimmedInstitutionCode (or compute const trimmedInstitutionCode =
String(institutionCode).trim()) above the validation block that computes
requiredLen and use trimmedInstitutionCode in the ternary check (for "SAFAKEPC")
and replace later raw institutionCode use on DB storage with
trimmedInstitutionCode so both validation and storage use the same normalized
value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid 11-Digit Account Validation

1 participant

Comments