Skip to content

Fix KYB endpoints for business customers migrated onto individual entities - #1305

Merged
ebma merged 2 commits into
stagingfrom
fix/kyb-business-entity-resolution
Aug 3, 2026
Merged

Fix KYB endpoints for business customers migrated onto individual entities#1305
ebma merged 2 commits into
stagingfrom
fix/kyb-business-entity-resolution

Conversation

@ebma

@ebma ebma commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Migration 040 backfilled legacy provider rows onto the individual entities created by migration 038 — including business-typed (KYB) rows. Typed business lookups resolved (and findOrCreate'd) a same-typed entity instead, so for every migrated business customer:

  • all Alfredpay KYB wizard endpoints 404'd with "Alfredpay business customer not found" (or created a duplicate Alfredpay customer via createBusinessCustomer),
  • the Avenia KYB paths (getUploadUrls, getKybAttemptStatus, the createSubaccount conflict check) 403'd/409'd the legitimate owner,
  • while type-less dashboard/ramp lookups (active entity) still found the rows — showing a resumable KYB the wizard could not act on,
  • and every typed read left a stray empty business entity behind as a side effect.

Verified blast radius in production (2026-08-03): 13 business-typed provider_customers rows on individual-typed entities across 11 profiles.

Approach

Legacy-tolerant reads at the consumer level, no data migration:

  • findAlfredpayCustomer typed lookups scope by the row's customer_type across all entities the profile owns, and never create entities. Type-less lookups keep resolving the active entity (the quote/ramp account context is unchanged).
  • The three Avenia ownership checks compare profile-level ownership instead of a typed entity id; cross-profile requests still fail closed.
  • createAlfredpayCustomer co-locates a new corridor's row with the profile's existing same-type rows, so ramp registration (active-entity scoped) keeps seeing every corridor of a migrated profile.

A re-homing data migration was rejected: type-less ramp registration resolves the active entity, so moving rows breaks ramping unless the active pointer moves too. A fallback inside the shared getOrCreateCustomerEntityForProfile was rejected because Monerium explicitly 400s on type-mismatched entities — it would have broken Monerium business onboarding for exactly these profiles.

Security spec (05-integrations/alfredpay.md, 05-integrations/brla.md) and docs/architecture-identity-model.md are updated in the same change.

Test plan

  • New integration suite alfredpay-kyb-legacy-entity.integration.test.ts seeds the exact migrated shape (business row + kyb case on the active individual entity); all 6 tests fail on unfixed staging code and pass with the fix — including "no stray entity created" and "no duplicate Alfredpay customer" regressions.
  • New unit tests in brla.controller.test.ts cover legacy-ownership acceptance for getUploadUrls, getKybAttemptStatus, and the createSubaccount retry, plus cross-profile rejection.
  • Full api suite: 933 pass / 0 fail. bun verify and monorepo bun typecheck clean.

Migration 040 attached legacy business-typed provider rows to the
profile's individual entity from the 038 backfill. Typed lookups
resolved (and findOrCreate'd) the same-typed entity instead, so every
migrated business customer 404'd on the KYB wizard endpoints while
type-less dashboard/ramp lookups still saw the rows - and each typed
read left a stray empty business entity behind. Typed alfredpay lookups
now scope by the row's customer_type across all profile-owned entities,
avenia ownership checks compare profile ownership instead of a typed
entity id, and new alfredpay rows co-locate with existing same-type rows
so ramp registration (active-entity scoped) keeps seeing every corridor.
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi ready!

Name Link
🔨 Latest commit 573c959
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a70c02feef4a60008496ffe
😎 Deploy Preview https://deploy-preview-1305--vortexfi.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 573c959
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a70c02f9adc020008517ece
😎 Deploy Preview https://deploy-preview-1305--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit 573c959
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6a70c02fc0faee0008ed9014

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes legacy KYB ownership resolution for business customers whose provider rows remain on individual entities.

Changes:

  • Adds profile-wide entity ownership lookups for Alfredpay and Avenia.
  • Co-locates new Alfredpay corridor records and adds regression tests.
  • Updates identity and security documentation.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/security-spec/05-integrations/brla.md Documents profile-level Avenia ownership.
docs/security-spec/05-integrations/alfredpay.md Documents legacy-tolerant Alfredpay resolution.
docs/architecture-identity-model.md Records the migration placement caveat.
apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts Adds migrated KYB regression coverage.
apps/api/src/api/services/customer-entity.service.ts Adds read-only profile entity enumeration.
apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts Widens typed lookups and changes record placement.
apps/api/src/api/controllers/brla.controller.ts Uses profile-level Avenia ownership checks.
apps/api/src/api/controllers/brla.controller.test.ts Tests legacy and cross-profile Avenia access.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/api/src/api/controllers/brla.controller.ts
Comment thread apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts
Review follow-ups on #1305: createSubaccount resolved the typed entity
before the ownership check, so a migrated company's retry still created
the stray business entity the fix is meant to prevent - entity creation
now happens only in the branches that persist a new row. And
createAlfredpayCustomer picked the most recently updated sibling row,
which for a profile carrying a pre-fix duplicate on a stray business
entity would home a new corridor where quote/ramp resolution (active
entity) cannot see it - the active entity's rows now take precedence.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts:208

  • When duplicate business rows already exist across entities, this widened lookup still chooses the newest row globally. A pre-fix duplicate on the stray business entity is newer than the migrated row on the active entity, so every typed KYB endpoint operates on that duplicate while quote/ramp resolution continues to read only the active entity; completing KYB can therefore still leave ramp registration blocked. Prefer a matching row on the profile's effective active/default entity, then fall back to the existing updatedAt ordering, and cover the split-row lookup case.
      customerEntityId: entityIds,

apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts:330

  • This reads only the nullable pointer, but quote/ramp resolution uses getOrCreateCustomerEntityForProfile, which falls back to the oldest entity when no active pointer is set. For a supported profile with no pointer and split same-type rows, this picks the newest stray sibling while ramping reads the oldest entity, recreating the unrampable-corridor bug. Resolve the effective active/default entity through the same helper and add a null-pointer split-row case.
  const activeEntityId = siblings.length > 0 ? (await User.findByPk(userId))?.activeCustomerEntityId : null;
  const sibling = siblings.find(row => row.customerEntityId === activeEntityId) ?? siblings[0];

apps/api/src/api/controllers/brla.controller.ts:375

  • Profile ownership alone also removes the previous account-type guard. Because validation only checks that accountType is a valid enum, an owner can retry an existing individual tax-ID row with COMPANY (or vice versa); this now calls Avenia and rewrites customerType, whereas the old typed-entity comparison rejected it. Keep the profile-level ownership exception for migrated rows, but also require the row's authoritative customerType to match the requested account type.
    if (existing && !(await findCustomerEntityIdsForProfile(effectiveUserId)).includes(existing.customerEntityId)) {

@ebma
ebma merged commit 0dfa221 into staging Aug 3, 2026
7 checks passed
@ebma
ebma deleted the fix/kyb-business-entity-resolution branch August 3, 2026 17:17
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.

2 participants