Cardholder Registration
The Whole Lifecycle
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.
Account Creation
Public route — no auth required. CreateAccountComponent at /account/create.
- firstName — min 2, max 50
- lastName — min 2, max 50
- email — email format
- password — 8–128 chars, no composition rules
- passwordConfirm — must match
- dateOfBirth — formatted
yyyy-MM-dd - gender — optional (M/F)
- nationality — optional
- locale — auto-set from
navigator.language - privacyPolicy — required true
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-service —
POST /core/api/v1/{partner}/personwith name, email, DOB, gender, nationality. Returns apersonUuid. - ③ Create user in Frontegg —
POST /identity/resources/vendor-only/users/v1with email, password, role, tenant, and metadata containing thepersonUuidfrom 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.
OAuth Callback + Person Guard
After Frontegg auth, the browser returns to /oauth/callback/default.
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.
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.
if no metadata → redirect /complete-person-details
if metadata but no personUuid → redirect /complete-person-details
if personUuid exists → allow
if metadata but no personUuid → allow
if personUuid exists → redirect /cards (already done)
Person Details Completion
MissingPersonDetailsDialogComponent at /complete-person-details. Guarded: requires auth + no existing personUuid.
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.
Card Lookup
CardLookupComponent. The user enters a card's external reference and last-four digits.
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-service —
POST /api/cpm/v1/partners/{partner}/cards/findOne?accessLevel=3. Returns the fullPaymentCardincludingrequiresKycandkycLocked(pass-through from axis, not computed locally). - ② Fetch person-card link from core-service —
GET /core/api/v1/{partner}/cards/{cardId}/link. ReturnsCardLinkPerson(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.
navigate to /cards.
navigate to /cards/{externalId}/balance.
show login dialog → loginWithRedirect().
show registration-required dialog → showRequiredRegistration: stores the card's externalId in sessionStorage[KYC_CARD] and navigates to /cards/kyc to begin the KYC flow.
KYC Verification + Card Linking
Conditional — only when the card's design requires KYC. CardKycComponent at /cards/kyc. Guarded by fronteggAuthGuard.
.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 panel 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.
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.
| Level | Fields | Trigger |
|---|---|---|
LEVEL_NONE | No 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_1 | Phone, address, country, nationality, birth country, gender | Default for verification-required cards |
LEVEL_2_A | L1 + source of funds | Higher-value cards or escalation from L1 |
LEVEL_2_B | L1 + national ID + source of funds | Escalation from L2A; country-specific ID rules |
LEVEL_3 | (defined but not used in frontend) | — |
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/kycwith{ kycLevel }, adds personUuid from JWT, forwards asPOST /core/api/v1/{partner}/person/{personUuid}/kyc. Frontend checks responsestatus === 'PASSED'. RequiresFronteggUser+FronteggUserPersonLinkauth policies. - ② Link card — .NET receives
POST /cambrist/core/person/linkwith{ cardId, currency, amount }, adds personUuid, forwards asPOST /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.
Card Enrollment (Without KYC)
When a card's design does not require KYC, enrollment happens directly — no verification gate.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /enrolled-cards | FronteggUser | Enroll a card — saves {cardId, cardNumber} to UserRegisteredCards |
| GET | /enrolled-cards/cards | FronteggUser | List enrolled cards (paginated) — filtered by authenticated user's sub claim |
| DELETE | /enrolled-cards/{externalId} | FronteggUser + PersonLink | Soft-delete enrollment + unlink card from person in core-service |
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). ExternalCardNumber across all users — returns 409 Conflict if found. A card can only be enrolled by one user. DELETE /core/api/v1/{partner}/person/{personUuid}/card/{cardId}), then soft-deletes the local row (sets DateRemoved). Core 404s are tolerated — migration compatibility. 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.
| Method | Frontend calls (.NET) | Delegates to | .NET does |
|---|---|---|---|
| POST | /users/current | Frontegg + core + local | Orchestrates: email check → create person in core → create user in Frontegg → assign apps → save UserPersonLink locally |
| POST | /existing-users/person | core + Frontegg | Legacy path for users created outside /users/current |
| GET | /users/current | local | Returns user profile from local DB |
| PUT | /users/current | local | Updates user profile in local DB |
| GET | /lookup/{externalId} | axis + core | Fetches card from axis, joins person-link from core, validates status + design blacklist + ownership |
| GET | /cambrist/core/cards/{cardId}/kyc-level | core | Translates core’s {requiresKyc, kycLevel} → single level string (LEVEL_NONE when !requiresKyc). No auth required. |
| POST | /cambrist/core/person/kyc | core | Adds personUuid, forwards to POST /core/.../person/{uuid}/kyc. Auth: FronteggUser + PersonLink |
| POST | /cambrist/core/person/link | core | Adds personUuid, forwards to POST /core/.../person/{uuid}/card/{cardId}. Auth: FronteggUser + PersonLink |
| GET | /cambrist/core/person/cards | core | Adds personUuid, forwards. Wraps response in PersonCardPair |
| PUT | /cambrist/cpm/owners/{id}/personal/conditional | axis | Feature flag gate (enablePersonalDataUpdate) → 403 if disabled, else forwards to axis |
| POST | /enrolled-cards | local only | Local DB insert. Global duplicate check (409). No delegation. |
| GET | /enrolled-cards/cards | local only | Local DB query, scoped to JWT sub. No delegation. |
| DELETE | /enrolled-cards/{externalId} | axis + core + local | Unlinks card in core, soft-deletes local row. Tolerates core 404s. |
| GET | /cambrist/cpm/kyc-inquiry/personal-data | local DB query | Queries local KycInquiry table by email — but no data exists (dead feature) |
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.