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/create.yyyy-MM-ddnavigator.languageClient-side validation. Form is disabled to prevent double-submit.
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}/person with name, email, DOB, gender, nationality. Returns a personUuid.
③ Create user in Frontegg — POST /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.
Shows toast + dialog telling the user to check their email. Stores redirect URL in sessionStorage.
Hands off to Frontegg hosted login for email verification. User clicks the verification link, then authenticates.
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./oauth/callback/default.Frontegg sets the access token in the session. The callback component activates.
Card Balance app: reads SSKey.OAUTH_CALLBACK_REDIRECT_URL from localStorage (default /cards). Incomm app: checks user metadata for first_login_redirect_url instead.
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.
/complete-person-details. Guarded: requires auth + no existing personUuid.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.
Fields: firstName, lastName, email, dateOfBirth, gender, nationality, privacyPolicy. Countries loaded from CountryCodeService.
Calls UserManagementService.existingUserCreatePerson(). Backend creates the person record and associates it with the Frontegg user, setting personUuid in user metadata.
Person is created. The existingUserPersonGuard now passes (personUuid exists), granting access to the app.
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.Calls LandingLookupService which resolves the card and checks its state. Returns a LookupValidation with a status and optional action callback.
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 full PaymentCard including requiresKyc and kycLocked (pass-through from axis, not computed locally).
② Fetch person-card link from core-service — GET /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.
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.
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.
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./cards/kyc. Guarded by fronteggAuthGuard.Fetch the authenticated user's profile data.
Resolve the card from sessionStorage[KYC_CARD] via cardLookupService.findCard(externalId).
Fetch the card's activation amount and currency via cardValueCurrencyService.
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.
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.
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.Core-service returns two fields: { requiresKyc: boolean, kycLevel: string|null }. The .NET API collapses them into a single level string for the frontend:
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.
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.
Sends phone, address, country, nationality, birth country, gender. Blocking — shows error toast on failure.
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.
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.
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.
/cards/helpful-tips if the user's preference showHelpfulTips is true, otherwise to /cards.| 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.
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.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) |
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.
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.
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.
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.