Fix KYB endpoints for business customers migrated onto individual entities - #1305
Conversation
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.
✅ Deploy Preview for vortexfi ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vrtx-dashboard canceled.
|
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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
updatedAtordering, 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
accountTypeis a valid enum, an owner can retry an existing individual tax-ID row withCOMPANY(or vice versa); this now calls Avenia and rewritescustomerType, whereas the old typed-entity comparison rejected it. Keep the profile-level ownership exception for migrated rows, but also require the row's authoritativecustomerTypeto match the requested account type.
if (existing && !(await findCustomerEntityIdsForProfile(effectiveUserId)).includes(existing.customerEntityId)) {
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:createBusinessCustomer),getUploadUrls,getKybAttemptStatus, thecreateSubaccountconflict check) 403'd/409'd the legitimate owner,Verified blast radius in production (2026-08-03): 13 business-typed
provider_customersrows on individual-typed entities across 11 profiles.Approach
Legacy-tolerant reads at the consumer level, no data migration:
findAlfredpayCustomertyped lookups scope by the row'scustomer_typeacross all entities the profile owns, and never create entities. Type-less lookups keep resolving the active entity (the quote/ramp account context is unchanged).createAlfredpayCustomerco-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
getOrCreateCustomerEntityForProfilewas 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) anddocs/architecture-identity-model.mdare updated in the same change.Test plan
alfredpay-kyb-legacy-entity.integration.test.tsseeds 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.brla.controller.test.tscover legacy-ownership acceptance forgetUploadUrls,getKybAttemptStatus, and thecreateSubaccountretry, plus cross-profile rejection.bun verifyand monorepobun typecheckclean.