Internal Docs/Cardholder registration — end to end
cardholder registration · account · kyc · enrollment · as-built

cardholder
registration

A cardholder's journey from first visit through card enrollment. The Angular frontend talks only to the .NET card-balance-api — it never calls core-service or axis-service directly. The .NET API orchestrates across those backends: creating users via Frontegg + core, translating KYC level responses, and managing enrollment state locally. Frontend service names like CambristCoreCardService are misleading — the /cambrist/* URL paths all route to the .NET API, which delegates and transforms.

00

the whole lifecycle

step 1
/account/create
name, email, password, DOB
step 2
Frontegg OAuth
email verify + login
step 3
person details
guard-driven completion
step 4
card lookup
find card by ext ref
kyc?
step 5 · conditional
KYC + link
verify → link → enroll
step 6
enrolled
card in dashboard
Angular frontend Frontegg (auth) .NET card-balance-api core-service axis-service / CPM
01

account creation

Public route — no auth required. CreateAccountComponent at /account/create.
form fields
  • firstName — min 2, max 50
  • lastName — min 2, max 50
  • email — email format
  • password — 8–16 chars, upper + lower + digit + special
  • passwordConfirm — must match
  • dateOfBirth — formatted yyyy-MM-dd
  • gender — optional (M/F)
  • nationality — optional
  • locale — auto-set from navigator.language
  • privacyPolicy — required true
flow · onSubmit()

fe validate + disable form

Client-side validation. Form is disabled to prevent double-submit.

.net POST /users/current — orchestrates three systems

Sends firstName, lastName, email, password, gender, nationality, dateOfBirth, locale. The .NET API orchestrates the full creation:

Check email in Frontegg — calls ManagementUserFindByEmail. Returns 409 if already exists.

Create person in core-servicePOST /core/api/v1/{partner}/person with name, email, DOB, gender, nationality. Returns a personUuid.

Create user in FronteggPOST /identity/resources/vendor-only/users/v1 with email, password, role, tenant, and metadata containing the personUuid from step ②.

Assign to Frontegg applications — parallel calls to assign the user to each configured app.

Set active tenant in Frontegg.

Save UserPersonLink — inserts {userUuid, personUuid} into the local DB, bridging Frontegg identity to core-service person.

fe success → confirmation dialog

Shows toast + dialog telling the user to check their email. Stores redirect URL in sessionStorage.

frontegg loginWithRedirect()

Hands off to Frontegg hosted login for email verification. User clicks the verification link, then authenticates.

personUuid is set at creation time. Because the .NET API creates the person in core and embeds the personUuid in Frontegg metadata during step ②–③, newly created users already have a personUuid when they first authenticate. The /complete-person-details flow (section 03) only triggers for users who were created through a different path — e.g. directly in Frontegg without going through this endpoint.
02

OAuth callback + person guard

After Frontegg auth, the browser returns to /oauth/callback/default.
CallbackComponent

frontegg OAuth redirect lands

Frontegg sets the access token in the session. The callback component activates.

fe read redirect URL

Card Balance app: reads SSKey.OAUTH_CALLBACK_REDIRECT_URL from localStorage (default /cards). Incomm app: checks user metadata for first_login_redirect_url instead.

fe navigate

Routes to the stored URL. The root layout's existingUserPersonGuard fires on the target route.

the guard pair

Two guards control access to the app vs. the person-completion form. Together they form a loop: you can't use the app without a personUuid, and you can't re-visit the form once you have one.

existingUserPersonGuard
applied to: AppLayoutComponent (root)
if not authenticatedallow
if no metadataredirect /complete-person-details
if metadata but no personUuidredirect /complete-person-details
if personUuid existsallow
existingUserPersonGoodGuard
applied to: /complete-person-details
if no metadataallow (needs to complete)
if metadata but no personUuidallow
if personUuid existsredirect /cards (already done)
03

person details completion

MissingPersonDetailsDialogComponent at /complete-person-details. Guarded: requires auth + no existing personUuid.
flow

fe pre-populate from email

Sets email from authService.user?.email. Attempts to fetch existing KYC inquiry data via GET /cambrist/cpm/kyc-inquiry/personal-data?email={email} to pre-fill name, DOB, gender.

fe user completes form

Fields: firstName, lastName, email, dateOfBirth, gender, nationality, privacyPolicy. Countries loaded from CountryCodeService.

.net POST /existing-users/person

Calls UserManagementService.existingUserCreatePerson(). Backend creates the person record and associates it with the Frontegg user, setting personUuid in user metadata.

fe navigate to /cards

Person is created. The existingUserPersonGuard now passes (personUuid exists), granting access to the app.

Dead endpoint. The GET /cambrist/cpm/kyc-inquiry/personal-data call in step 1 has no backend handler — it 404s silently (the error handler is (err) => {}). The form just doesn't get pre-populated. Additionally, the email is passed as a query parameter with no server-side ownership check — if implemented, it must validate the email matches the authenticated caller to avoid an IDOR.
04

card lookup

CardLookupComponent. The user enters a card's external reference and last-four digits.
flow · onClickLookup()

fe validateLookup(externalId, lastFour)

Calls LandingLookupService which resolves the card and checks its state. Returns a LookupValidation with a status and optional action callback.

.net GET /lookup/{externalId} — card fetch + enrichment

The .NET API does real work here, not just proxying:

Fetch card from axis-servicePOST /api/cpm/v1/partners/{partner}/cards/findOne?accessLevel=3. Returns the full PaymentCard including requiresKyc and kycLocked (pass-through from axis, not computed locally).

Fetch person-card link from core-serviceGET /core/api/v1/{partner}/cards/{cardId}/link. Returns CardLinkPerson (personUuid, linkCreatedAt). This is a separate API call — the .NET API joins card data from axis with link data from core.

Validate locally — checks card status (rejects NotActivated, Lost, Stolen, Expired), checks for active blocks, checks card design against a local blacklist table. Authenticated lookups also verify the card is unlinked or linked to the requesting user.

fe branch on result

Authenticated + person linked: navigate to /cards. Anonymous + person linked: navigate to /cards/{externalId}/balance. Anonymous + no link: show login dialog → loginWithRedirect(). No person link: show registration-required dialog.

fe showRequiredRegistration(externalId)

If the card has no linked person: stores the card's externalId in sessionStorage[KYC_CARD] and navigates to /cards/kyc to begin the KYC flow.

Also triggered from balance view. The BalanceComponent checks card.requiresKyc && card.kycLocked on load. If both are true and the user is authenticated, it shows a confirmation dialog that stores the card ref in SSKey.KYC_CARD and navigates to /cards/kyc. If anonymous, it prompts login first.
05

KYC verification + card linking

Conditional — only when the card's design requires KYC. CardKycComponent at /cards/kyc. Guarded by fronteggAuthGuard.
initialization · DefaultCardKycInitBehaviour

.net GET /users/current

Fetch the authenticated user's profile data.

fe card lookup

Resolve the card from sessionStorage[KYC_CARD] via cardLookupService.findCard(externalId).

fe get card value + currency

Fetch the card's activation amount and currency via cardValueCurrencyService.

.net → core GET /cambrist/core/cards/{cardId}/kyc-level?amount={amount}

Determines the required KYC level. The .NET API forwards to core at GET /core/api/v1/{partner}/cards/{cardId}/kyc?amount={amount}, then translates core's two-field response (requiresKyc + kycLevel) into a single level string (see the LEVEL_NONE section below). This endpoint has no auth requirement — it's public on the .NET side, unlike the KYC submit/link calls. Frontend falls back to LEVEL_1 on error.

fe configure form

KycLevelConfiguratorService sets field requirements based on level and country. LEVEL_2_A / LEVEL_2_B add national ID and source-of-funds fields. Italy and US require national ID specifically for LEVEL_2_B.

Naming mismatch. The frontend calls everything "KYC" — requiresKyc, kycLocked, SSKey.KYC_CARD, the /cards/kyc route — but the concept is broader than identity verification. When the level is LEVEL_NONE, the user still goes through the full form (phone, address, person update, card link) — the only thing skipped is the actual KYC verification call (POST /cambrist/core/person/kyc). So "requires KYC" really means "requires registration" — the card's design forces person-data collection and card-person linking, but whether identity verification happens depends on the level. The code at internalSubmitKycAndLinkCard makes this explicit: if (kycLevel !== KycLevel.None) { submitKyc(...) } — then submitLinkCard() runs unconditionally.
how LEVEL_NONE is produced — the .NET translation layer

Core-service returns two fields: { requiresKyc: boolean, kycLevel: string|null }. The .NET API collapses them into a single level string for the frontend:

DefaultCambristCoreCards.FindKycLevel()
var curLevel = (kycRequired ? kycLevel : "LEVEL_NONE") ?? "LEVEL_1";

core returns requiresKyc: true + kycLevel: "LEVEL_1" → frontend gets "LEVEL_1"
core returns requiresKyc: true + kycLevel: null → frontend gets "LEVEL_1" (null coalesce)
core returns requiresKyc: false + kycLevel: null → frontend gets "LEVEL_NONE"

When does LEVEL_NONE actually reach the form? Normally, never. The frontend only enters /cards/kyc when card.requiresKyc && card.kycLocked — and card.requiresKyc comes from the same ProgramConfigurationEntity.isKycRequired flag that drives core's requiresKyc response. If the config says false, the card never enters the KYC flow. However, if the program config changes between the card lookup (which sets the frontend flag) and the KYC level check (which queries core), a race can produce LEVEL_NONE at the form. The submit behaviour's guard handles this gracefully — it skips verification and just links the card.

kyc levels
LevelFieldsTrigger
LEVEL_NONENo KYC verification — person data + card link only.NET API returns this when core says requiresKyc: false. Not a core-service concept — core returns null, .NET translates it. In practice only reachable via a config race.
LEVEL_1Phone, address, country, nationality, birth country, genderDefault for verification-required cards
LEVEL_2_AL1 + source of fundsHigher-value cards or escalation from L1
LEVEL_2_BL1 + national ID + source of fundsEscalation from L2A; country-specific ID rules
LEVEL_3(defined but not used in frontend)
submit chain · DefaultCardKycSubmitBehaviour

Five steps, executed sequentially. A failure at any blocking step aborts the chain. Every backend call goes through the .NET API, which injects the personUuid (from the JWT) and partnerId (from config) — the frontend never sends these.

gate sanction check (frontend-only)

Checks countryOfBirth against sanctioned list: RU, BY. If matched, shows EU sanctions dialog and sends alert email via mailService.sendSanction(). Blocking — aborts if sanctioned or dialog dismissed.

.net PUT /users/current — update person

Sends phone, address, country, nationality, birth country, gender. Blocking — shows error toast on failure.

.net → core KYC submit + card link

Two-part call. The frontend sends only the kycLevel, cardId, currency, and amount — the .NET API adds the personUuid and partnerId before forwarding to core:

Submit KYC — .NET receives POST /cambrist/core/person/kyc with { kycLevel }, adds personUuid from JWT, forwards as POST /core/api/v1/{partner}/person/{personUuid}/kyc. Frontend checks response status === 'PASSED'. Requires FronteggUser + FronteggUserPersonLink auth policies.

Link card — .NET receives POST /cambrist/core/person/link with { cardId, currency, amount }, adds personUuid, forwards as POST /core/api/v1/{partner}/person/{personUuid}/card/{cardId}. Same auth policies.

Auto-escalation (frontend logic): if KYC is rejected, the error is parsed for a next level. If it differs from the current level, the entire step retries recursively at the higher level. This means a LEVEL_1 rejection can escalate through LEVEL_2_A → LEVEL_2_B automatically.

.net → axis update owner personal data (fire-and-forget)

PUT /cambrist/cpm/owners/{ownerId}/personal/conditional — sends name, email, phone, DOB, gender, address. The .NET API checks a feature flag (enablePersonalDataUpdate) before forwarding — returns 403 if disabled. When enabled, delegates to axis-service at POST /api/cpm/v1/partners/{partner}/updatePersonalData. Errors are caught and swallowed by the frontend.

.net POST /enrolled-cards — enroll card (fire-and-forget)

Creates an EnrolledCard record with cardId and cardNumber (cardExtRef) in the .NET database. This is a local .NET operation — no delegation to core or axis. Checks for duplicate enrollment globally (409 if card already enrolled by any user). Errors are caught and swallowed by the frontend.

After success: navigates to /cards/helpful-tips if the user's preference showHelpfulTips is true, otherwise to /cards.
06

card enrollment (without KYC)

When a card's design does not require KYC, enrollment happens directly — no verification gate.
the .NET enrolled-cards API
MethodEndpointAuthPurpose
POST/enrolled-cardsFronteggUserEnroll a card — saves {cardId, cardNumber} to UserRegisteredCards
GET/enrolled-cards/cardsFronteggUserList enrolled cards (paginated) — filtered by authenticated user's sub claim
DELETE/enrolled-cards/{externalId}FronteggUser + PersonLinkSoft-delete enrollment + unlink card from person in core-service
Authorization model: all endpoints use the JWT sub claim (Frontegg user ID) to scope data. Users can only see and manage their own enrolled cards — the WHERE clause always includes UserId = token.sub. DELETE additionally requires the FronteggUserPersonLink policy (user must have a personUuid).
Duplicate prevention: POST checks for an existing card with the same ExternalCardNumber across all users — returns 409 Conflict if found. A card can only be enrolled by one user.
DELETE is two-phase: first calls core-service to unlink the card from the person (DELETE /core/api/v1/{partner}/person/{personUuid}/card/{cardId}), then soft-deletes the local row (sets DateRemoved). Core 404s are tolerated — migration compatibility.
Dual write. Card enrollment exists in both the .NET UserRegisteredCards table and core-service's person-card linkage. The two can drift — the .NET record is the UI's source of truth for "which cards does the user see," while core-service is the domain authority for person-card relationships. DELETE attempts to clean up both, but the two systems are not transactionally linked.
07

all endpoints touched

Every frontend HTTP call hits the .NET card-balance-api. The "delegates to" column shows where the .NET API forwards. .NET adds personUuid (from JWT) and partnerId (from config) to every delegated call — the frontend never sends these.

MethodFrontend calls (.NET)Delegates to.NET does
POST/users/currentFrontegg + core + localOrchestrates: email check → create person in core → create user in Frontegg → assign apps → save UserPersonLink locally
POST/existing-users/personcore + FronteggLegacy path for users created outside /users/current
GET/users/currentlocalReturns user profile from local DB
PUT/users/currentlocalUpdates user profile in local DB
GET/lookup/{externalId}axis + coreFetches card from axis, joins person-link from core, validates status + design blacklist + ownership
GET/cambrist/core/cards/{cardId}/kyc-levelcoreTranslates core's {requiresKyc, kycLevel} → single level string (LEVEL_NONE when !requiresKyc). No auth required.
POST/cambrist/core/person/kyccoreAdds personUuid, forwards to POST /core/.../person/{uuid}/kyc. Auth: FronteggUser + PersonLink
POST/cambrist/core/person/linkcoreAdds personUuid, forwards to POST /core/.../person/{uuid}/card/{cardId}. Auth: FronteggUser + PersonLink
GET/cambrist/core/person/cardscoreAdds personUuid, forwards. Wraps response in PersonCardPair
PUT/cambrist/cpm/owners/{id}/personal/conditionalaxisFeature flag gate (enablePersonalDataUpdate) → 403 if disabled, else forwards to axis
POST/enrolled-cardslocal onlyLocal DB insert. Global duplicate check (409). No delegation.
GET/enrolled-cards/cardslocal onlyLocal DB query, scoped to JWT sub. No delegation.
DELETE/enrolled-cards/{externalId}axis + core + localUnlinks card in core, soft-deletes local row. Tolerates core 404s.
GET/cambrist/cpm/kyc-inquiry/personal-datalocal DB queryQueries local KycInquiry table by email — but no data exists (dead feature)
08

known issues

dead KYC inquiry endpoint

The person-details form calls GET /cambrist/cpm/kyc-inquiry/personal-data?email={email} but no backend handler exists. The call 404s silently. If implemented, the endpoint takes an email as a query param with no ownership validation — any authenticated user could query any email. Must validate email matches the caller.

enrollment dual write

Card-person relationships live in both the .NET UserRegisteredCards table and core-service. No transactional link between them — DELETE tries to clean up both but tolerates core failures. The .NET side appears to be legacy; core-service is the domain authority.

fire-and-forget steps in KYC submit

Steps 4 and 5 of the KYC submit chain (owner personal data update and card enrollment) swallow errors. If either fails, the user sees success but the owner data is stale or the card doesn't appear on the dashboard.

countryOfBirth field mismatch

Commented out in person-details form (Feb 2026): the country_of_birth_iso_code column in Postgres actually stores nationality, not country of birth. The field is not populated from KYC inquiry data.