Skip to content

Support individual and business entity types#355

Open
larseidsvoll wants to merge 4 commits into
mainfrom
feat/kyc-entity-types
Open

Support individual and business entity types#355
larseidsvoll wants to merge 4 commits into
mainfrom
feat/kyc-entity-types

Conversation

@larseidsvoll

Copy link
Copy Markdown
Contributor

Built in collaboration with @schenkty as part of project Gildor.

Re-opened from the same repo (was previously a fork PR, #354) so CI runs with repository secrets. All review comments from #354 are already addressed in these commits.

What this does

Lets a kyc provider declare which entity types it can verify (individual, business, or both) in its service metadata, and lets a verification request declare which type it is for. This folds business verification (KYB) into the existing kyc service rather than standing up a separate service.

How it works

Both individual and business are redirect flows: the provider returns a webURL to a hosted experience and the client polls for the certificate. The entityType tells the provider which hosted experience to present.

The provider hosts and owns the collection experience for both entity types (the demo-kyc-provider pattern: a hosted form served alongside the anchor API). So the request does not carry entity-specific input details, only which kind of experience to start. KYB detail collection lives in the anchor's hosted form, not in the SDK.

This is intentionally a minimal surface. An earlier draft carried a business-details block in the request and made webURL optional for a synchronous business path; that was dropped once we settled on the anchor hosting the KYB form (so business redirects too, and webURL stays required for both).

Changes

  • common.ts: add entityType to the request (defaults to individual). webURL stays required. KYCEntityType is imported from the resolver for one source of truth.
  • server.ts: add entityTypes to the kyc config (defaults to ['individual']) and publish it in the service metadata as a presence map.
  • client.ts: pass entityType through to the resolver lookup.
  • resolver.ts: add a named KYCEntityType type; add entityTypes (a presence map { individual?: true; business?: true }) to the kyc service metadata; add an optional entityType filter to the kyc search criteria; filter providers by it in lookupKYCServices. A provider with no declared entityTypes is treated as individual-only.
  • server.test.ts: business entity test covering metadata advertisement and entityType-filtered resolution.
  • client.test.ts: business createVerification through the client returns a hosted webURL.

The cert schema needs no KYB changes

The existing KYC certificate attribute set (generated from oids.json) is already generic and entity-agnostic, so KYB needs nothing added:

  • EntityType already has an organization arm (SEQUENCE OF GenericOrganizationIdentification) alongside person.
  • OrganizationIdentification already carries bic, lei, and a generic other ({ id, schemeName, issuer }).
  • Document is a single generic container (number, front/back/selfie references, dates, issuing authority) that every document type already reuses.

Business identifiers (EIN, registration number, LEI, DUNS) map to entityType.organization[] via ISO20022 scheme names, and KYB documents map to the generic Document. No per-document cert fields are required.

Back-compat

entityType defaults to individual and entityTypes defaults to ['individual'], so existing providers and callers are unaffected.

Verification

  • npx tsc --noEmit: 0 errors
  • make do-lint: clean
  • make test: the only failures are the pre-existing common.test.ts error round-trip flake, which fails identically on a clean main checkout (2 failed / 549 passed there too). The changed files (server, client, resolver tests) all pass.

Let a kyc provider declare which entity types it can verify (individual,
business, or both) and let a verification request declare which type it is
for. Both are redirect flows: the provider returns a webURL to a hosted
experience and the client polls for the certificate. The entity type tells
the provider which hosted experience to present.

The provider hosts and owns the collection experience for both entity types
(see the demo-kyc-provider app pattern: a hosted form served alongside the
anchor API). So the package does not carry entity-specific input details in
the request -- it only carries which kind of experience to start. This keeps
the surface minimal and leaves KYB detail collection to the anchor's hosted
form rather than the SDK.

Changes:
- common.ts: add entityType to the request (defaults to individual). webURL
  stays required (both flows redirect). KYCEntityType is derived from the
  metadata type.
- server.ts: add entityTypes to the kyc config (default ['individual']) and
  publish it in the service metadata.
- client.ts: pass entityType through to the resolver lookup.
- resolver.ts: add entityTypes to the kyc service metadata, add an optional
  entityType filter to the kyc search criteria, and filter providers by it
  in lookupKYCServices (a provider with no declared entityTypes is treated
  as individual-only).
- server.test.ts: business entity test covering metadata advertisement,
  entityType-filtered resolution, and a business createVerification that
  returns a hosted webURL.

The cert schema needs no change for KYB: the existing ISO20022 attribute set
(EntityType.organization, OrganizationIdentification bic/lei/other, the
generic Document container) already represents business identifiers and
documents.

Back-compat: entityType defaults to individual and entityTypes defaults to
['individual'], so existing providers and callers are unaffected.

tsc clean, make do-lint clean. (Pre-existing common.test.ts error round-trip
flake fails identically on clean main -- not introduced here.)
- entityTypes metadata is now a presence map ({ individual?: true,
  business?: true }) so a type cannot be declared twice, per review
- extract a named KYCEntityType type in resolver and reuse it across
  the metadata, search criteria, common, and server modules
- move the business createVerification assertion into the client test
  where the rest of the client flow lives; server test keeps the
  metadata-publish and resolver-lookup coverage
Comment thread src/lib/resolver.ts Outdated
* returns a `webURL`. A provider that supports both
* sets both keys to `true`.
*/
entityTypes?: { [entityType in KYCEntityType]?: true };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why couldn't these be false? That is what we do in other places for supportedOperations in asset movement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Switched it to a map of explicit booleans in d949bb0, matching what asset movement does for supportedOperations. The resolver now reads each key via ('boolean') and matches only when it's explicitly true, the same way supportedAffinities is read on FX. A false or missing key both mean unsupported.

readonly kycProviderURL: NonNullable<KeetaAnchorKYCServerConfig['kycProviderURL']>;
readonly routes: NonNullable<KeetaAnchorKYCServerConfig['routes']>;
readonly #countryCodes?: CurrencyInfo.Country[] | undefined;
readonly #entityTypes: KYCEntityType[];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should be the object, but maybe required keys and defaults to false, or defaults to just { individual: true, buisness: false }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d949bb0. The server now publishes an explicit boolean for every known entity type, defaulting to { individual: true, business: false }. The author-facing server config stays a friendly KYCEntityType[] array; it's folded into the explicit-boolean object map at publish time.

expect(individualMatch).toBeDefined();
expect(individualMatch !== undefined && 'Test' in individualMatch).toBe(true);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should probably test the case where the metadata only allows business but individual is requested, as well as other explicit combinations because the existing tests only test the implicit case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in d949bb0: a combination matrix test that stands up individual-only, business-only, both-types, and undeclared providers and asserts the resolver lookup matches/rejects for each requested entity type. Confirms a provider advertising only one type isn't matched for the other, plus the undeclared = individual-only classic behavior.

Comment thread src/lib/resolver.ts Outdated
* Both are hosted redirect flows: the provider returns a `webURL` to a
* hosted experience it owns, and the client polls for the certificate.
*/
type KYCEntityType = 'individual' | 'business';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should these be capitalized ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept them lowercase to match the convention for descriptive categorical unions in the service layer: KYCRedirectStatus ('completed' | 'cancelled' | 'failed'), FX affinity ('from' | 'to'), FX purpose ('quote' | 'estimate'), and StorageObjectVisibility ('public' | 'private') are all lowercase. UPPERCASE is used for wire-protocol enums instead (rail tokens like ACH/KEETA_SEND, notification channels like FCM/RECEIVE_FUNDS). entityType is a categorical descriptor in that first group, so lowercase is consistent. Happy to switch if you'd prefer the wire-enum style here.

@rkeene rkeene requested a review from Copilot June 5, 2026 03:10
@rkeene rkeene changed the title kyc: support individual and business entity types Support individual and business entity types Jun 5, 2026

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

This PR extends the Anchor KYC service to support both individual (KYC) and business (KYB) entity types by advertising supported entity types in KYC service metadata, allowing requests to specify an entityType, and enabling the resolver/client to filter providers accordingly.

Changes:

  • Add entityType to KYC create-verification requests and pass it through client resolution.
  • Add entityTypes to KYC provider config/metadata and filter resolver matches by requested entity type.
  • Add tests covering KYB metadata advertisement and entity-type filtered resolution + client createVerification behavior.

Reviewed changes

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

Show a summary per file
File Description
src/services/kyc/server.ts Adds entityTypes to server config and publishes it into KYC service metadata.
src/services/kyc/server.test.ts Adds coverage for business entity type metadata advertisement and resolver filtering.
src/services/kyc/common.ts Introduces request-level entityType and documents the entity-type model.
src/services/kyc/client.ts Passes entityType into resolver lookup when provided.
src/services/kyc/client.test.ts Adds a business createVerification path asserting redirect webURL behavior.
src/lib/resolver.ts Defines KYCEntityType, adds metadata field + search criteria, and filters KYC providers by entity type.

Comment thread src/services/kyc/server.ts Outdated
Comment on lines +138 to +142
* Defaults to `['individual']` (the classic KYC redirect flow).
* Add `'business'` to advertise Know Your Business (KYB) support,
* which is performed synchronously from the request's business
* details with no hosted journey / webURL. A provider that does
* both lists both: `['individual', 'business']`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, the doc was stale from an earlier design. Fixed in d949bb0. Business KYB is a hosted redirect flow that returns a webURL like the individual flow (the provider hosts the KYB collection form), not a synchronous no-webURL flow, and the request carries no business-details block. The doc now describes both as hosted redirect flows.

Comment thread src/lib/resolver.ts Outdated
Comment on lines +1765 to +1771
let entityTypes: string[] = ['individual'];
if ('entityTypes' in checkKYCService) {
const declared = await checkKYCService.entityTypes?.('object');
if (declared !== undefined) {
entityTypes = Object.keys(declared);
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d949bb0. entityTypes is now a map of explicit booleans, and the filter reads the requested key via ('boolean') and treats only an explicit true as supported. A false, missing, or otherwise non-true value all mean unsupported, so invalid metadata can't accidentally opt a provider into an entity type. This mirrors how supportedAffinities is read on FX.

Address review on PR #355:

- entityTypes metadata is now a map of explicit booleans (mirroring
  supportedOperations on asset movement), and the resolver reads each key
  via ('boolean') and matches only when explicitly true (mirroring how
  supportedAffinities is read on FX). A false or missing key means
  unsupported, so invalid metadata cannot opt a provider into a type.

- The server publishes an explicit boolean for every known entity type,
  defaulting to { individual: true, business: false }.

- Add an entity-type combination matrix test covering individual-only,
  business-only, both, and undeclared providers against both requested
  types, so the explicit combinations are exercised, not just the implicit
  case.

- Correct the entityTypes doc on the server config: business is a hosted
  redirect flow returning a webURL like individual, not a synchronous
  no-webURL flow.
@larseidsvoll

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in d949bb0 (signed):

  • entityTypes is now a map of explicit booleans (Ezra + Copilot), mirroring supportedOperations on asset movement. The resolver reads each key via ('boolean') and matches only on an explicit true, the way supportedAffinities is read on FX, so false/missing/invalid metadata can't opt a provider into an entity type.
  • Server publishes an explicit boolean for every known type, defaulting to { individual: true, business: false }. The author-facing config stays a KYCEntityType[] array, folded into the object map at publish time.
  • Combination matrix test (Roy): individual-only, business-only, both, and undeclared providers, each checked against both requested types.
  • Capitalization (Roy): kept lowercase to match the service-layer convention (KYCRedirectStatus, FX affinity/purpose, StorageObjectVisibility); UPPERCASE is reserved for wire enums. Flagged on the thread in case you'd prefer the wire-enum style.
  • Doc fix (Copilot): business is a hosted redirect flow returning a webURL, not a synchronous no-webURL flow.

tsc, eslint, cspell, and the kyc test suite are green. Re-requesting review.

@larseidsvoll larseidsvoll requested review from ezraripps and rkeene June 5, 2026 04:44
@sonarqubecloud

sonarqubecloud Bot commented Jun 5, 2026

Copy link
Copy Markdown

Comment thread src/services/kyc/server.test.ts Outdated
* requested entity type. A fresh signer/account per provider keeps the
* published metadata isolated.
*/
async function lookupWith(entityTypes: ('individual' | 'business')[] | undefined, requested: 'individual' | 'business') {

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.

Rather than creating and starting a server every time this function is called would it make sense to construct the resolver metadata once for different combinations of individual and business and then test the resolver metadata in isolation to verify lookups return the correct providers?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 675e34c. Refactored so the provider/resolver is built once per distinct config and the metadata round-trip happens once, then the resolver is queried repeatedly. The matrix now stands up five providers up front (individual-only, business-only, both, undeclared, both over US+CA) and reuses each resolver across every lookup instead of starting a server per assertion. Servers are disposed in a finally block.

Comment thread src/services/kyc/server.test.ts Outdated
}

/* Individual-only provider: matches individual, rejects business. */
expect(await lookupWith(['individual'], 'individual')).toBe(true);

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.

Could these tests be optimized to loop through an array of test cases and expected results?

Also what about combining country codes with different entity types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 675e34c. The assertions are now a table of { provider, requested entity type, requested country codes, expected } looped through a single expect, with the case name passed as the assertion message so a failure names the exact combination.

Added country-code x entity-type cases too: a both-types provider scoped to US rejects business and individual when CA is requested (the country and entity filters are ANDed, not ORed), and a provider declaring US+CA matches both entity types in CA. So the two filters are now exercised together rather than in isolation.

client: client,
kyc: {
countryCodes: ['US'],
entityTypes: ['individual', 'business'],

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.

What happens in the case where a provider only supports KYB in specific countries but KYC in other countries? Currently the country codes are separate from the entity types and this structure doesn’t seem to allow for that level of granularity.

Will the server config support handling different countries for KYB and KYC separately?

Will multiple servers need to be created to handle them separately purely to handle the country code list?

Or can a single server handle it via multiple metadata entries separating KYB/KYC and the country code list, but then how would that be handled in the server config.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question, and the granularity is already expressible, just at the level of separate named KYC service entries rather than inside one entry.

The resolver loops every entry under services.kyc and ANDs countryCodes with entityType within each entry (lookupKYCServices in resolver.ts). A single operator account's metadata can carry multiple named KYC entries, so "KYB in some countries, KYC in others" is published as two entries under one account:

services: {
  kyc: {
    US_KYB: { countryCodes: ['US'], entityTypes: { business: true } },
    EU_KYC: { countryCodes: ['DE', 'FR', ...], entityTypes: { individual: true } }
  }
}

A lookup for (business, US) matches US_KYB; (individual, DE) matches EU_KYC; (business, DE) matches neither. No separate accounts or servers are required at the protocol level, since one account's metadata map holds both entries, and the resolver already returns the correct subset.

What this PR does not add is per-country entity granularity inside a single entry: within one entry, the declared entityTypes apply across that entry's whole countryCodes list. That is intentional for this PR (entry = one homogeneous country+entity scope), and the multi-entry pattern above covers the mixed case without it.

The one real ergonomic gap is the server config helper: KeetaNetKYCAnchorHTTPServer's kyc block currently produces a single metadata entry via serviceMetadata(), so composing the two-entry example today means either two server instances or hand-merging two serviceMetadata() results into the metadata map. If we want first-class support for one server advertising several country/entity scopes, I would do it as a follow-up: let the server config take an array of scopes and emit multiple named entries, with a test crossing per-country KYB vs KYC. Happy to file that and keep this PR scoped to introducing entity types, or fold it in here if you would rather have it now. Which do you prefer?

…try codes

Addresses Srayman's review on PR #355:

- Build each provider/resolver once and reuse it across lookups instead
  of standing up a fresh server and republishing metadata on every
  assertion. The matrix now constructs one provider per distinct config
  (individual-only, business-only, both, undeclared, both over US+CA)
  and queries each resolver repeatedly.
- Drive the assertions from a table of { provider, requested entity
  type, requested country codes, expected } cases looped with a single
  expect, with the case name passed as the assertion message so a
  failure names the exact combination.
- Cross entity types with country codes: a supported entity type in an
  unsupported country is still rejected (the two filters are ANDed), and
  a provider declaring US+CA matches business and individual in CA.
- Dispose every provider server in a finally block.
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.

5 participants