User Profile Lifecycle¶
General Flow¶
Upon registration, the entity is considered undecided. The client makes the entity decision upon the first order of the FTMO Challenge.
The profile entity is locked based on the decision made (regardless of whether the order was paid). The client can no longer change or add company information to the order form.
API Endpoint¶
GET /user/profile (Controller_user::action_profile)
{
"entityType": "undecided|person|company",
"verifiedIdentity": true,
"verifiedPhoneNumber": true,
"isImpersonator": false,
"company_name": "",
"icnumber": "",
"dicnumber": "",
"secondaryTaxNumber": "",
"taxCountry": "",
"taxState": "",
"isUnderVIES": false,
"mode": "ftmo|vrgk|jv"
}
Footnote: - Entity — in future it shall be in IDP -
verifiedIdentityandverifiedPhoneNumber— in future, should be obtained by calling Verified Identity
Entity (Client) State¶
The system contains three states:
| State | Name | Description |
|---|---|---|
| 1 | Undecided | No non-free-trial order exists yet |
| 2 | Company | Has a non-free-trial order AND company data is present |
| 3 | Person | Has a non-free-trial order AND no company data |
The user interface presents the undecided state as a natural person with the option to switch to a company.
Decision Logic¶
Implemented in EntityTypeHelper::getEntityType():
1. Does the client have at least one non-free-trial order?
NO → Undecided (return early)
YES → continue
2. Is any of company_name, icnumber, or dicnumber non-empty?
YES → Company
NO → Person
Important: The order check comes first. A client with company data but no non-free-trial order is still Undecided.
Company Check¶
IdentityCompanyHelper::isCompany() — returns true if any of these are non-empty:
- company_name
- icnumber (Business Number / ABN)
- dicnumber (VAT Number / ACN)
Non-Free-Trial Order Check¶
SELECT 1 AS `exists`
FROM orders
WHERE payment_method <> 'free_trial'
AND clients_id = :clients_id
LIMIT 1;
Legal Regimes¶
The platform operates under three legal regimes, determined by the user's GDPR groups in their JWT token:
| Regime | Enum | Description |
|---|---|---|
| Global (FTMO) | LegalRegime::Ftmo |
Default regime, global users |
| VRGK | LegalRegime::Vrgk |
Australian users |
| JV | LegalRegime::Jv |
US joint-venture users |
Resolved in LegalRegimeResolver::getRegime() based on GDPR groups:
- VRGK groups: VRGK
- JV groups: JV_PROP_EVAL_US, OANDA_PROP_TRADING_US
- Global groups: FTMO_EVAL_GLOBAL, FTMO_EVAL_US, FTMO_TRADING_GLOBAL, FTMO_TRADING_US
A user cannot belong to multiple regimes simultaneously (throws MultipleLegalRegimesException). Users with no GDPR groups default to Global.
Transfer Countries¶
Countries that require a transfer flow: Australia (AU) and United States (US), defined in TransferCountry enum.
Profile Locking¶
Inputs on the order form and profile form are locked based on actions taken, ultimately leading to the entire profile being locked.
Key Code References¶
- Profile page view:
ViewComponentProfilePersonalInformation.php - Profile controller:
Controller_profile.php(trader) - Profile API:
Controller_user::action_profile(api) - Order form view:
ViewTraderComponentOrderBillingInfo.php - Order form fill strategy:
OrderFormFillStrategy.php
Locking Variables (Profile Page)¶
Three boolean flags control locking in ViewComponentProfilePersonalInformation:
$shouldPersonalInformationBeReadonly =
hasVerifiedIdentity() || is_impersonated;
$shouldPhoneNumberBeReadonly =
hasVerifiedPhoneNumber() || is_impersonated;
$shouldCountryAndStateBeReadonly =
disableCountryAndStateChange || hasVerifiedIdentity() || is_impersonated;
Where disableCountryAndStateChange is computed in Controller_profile:
$disableCountryStateChange =
hasTag(ALLOW_SANCTIONED_COUNTRY) || hasTag(NO_ORDERS)
|| ForbiddenCountryChange::tryFrom(country) !== null; // currently only AU
A) Email Locking¶
Email is always locked and cannot be changed under any circumstances.
Both profile and order forms render the email input with disabled and readonly attributes hardcoded.
B) Entity Locking¶
The entity decision (person vs. company) is locked once the first non-free-trial order is placed.
Company Name¶
Locked on the order form when any of:
- hasVerifiedIdentity() is true
- hasAnyNonFreeTrialOrder is true (locked regardless of whether company name is filled)
- is_impersonated is true
Note: The company name input is locked as soon as the user has any non-free-trial order, even if the field is empty. The user cannot retroactively add company information after their first paid order.
VAT Number / ACN (dicnumber)¶
Locked on the order form when any of:
- hasVerifiedIdentity() is true
- is_impersonated is true
- hasAnyNonFreeTrialOrder AND the value is non-empty
For VRGK (AU) users, this field is labeled ACN (Australian Company Number). For all other users, it is the VAT Number. When the country is under VIES, the VAT input splits into a country code select + number input.
Business Number / ABN (icnumber)¶
Locked on the order form when any of:
- hasVerifiedIdentity() is true
- is_impersonated is true
- hasAnyNonFreeTrialOrder AND the value is non-empty
For VRGK (AU) users, this field is labeled ABN (Australian Business Number). For all other users, it is the Business Number.
Note: There is interconnected validation: when a company is required to fill in the VAT Number, Business Number, or both, the form enforces this via
CompanyDataNotSetException.
Secondary Tax Number¶
Once filled, it is locked. It can only be filled out for a specific country or state (e.g., certain Canadian provinces). The value is carried over from existing billing data and is not editable on the order form UI.
taxCountry, taxState¶
Internal attributes for the edge case when the country of residence differs from the company tax registration country. Once filled (only with the first submission), they are locked. These values are preserved from existing billing data in OrderFormFillStrategy and not exposed as editable inputs.
C) Address Locking (City, Street, Postal Code)¶
Locked on the profile page when either:
- hasVerifiedIdentity() is true (KYC/KYB verified)
- is_impersonated is true
On the order form, the same conditions apply.
D) Phone Number¶
Locked when either:
- hasVerifiedPhoneNumber() is true
- is_impersonated is true
The phone number may be verified as part of KYC/KYB (majority of cases). There is a dedicated API attribute (verifiedPhoneNumber).
Backend enforcement: When both identity and phone are verified, no profile attributes are editable. When only identity is verified (but not phone), only PHONE remains editable via the API.
$attributes = match ([hasVerifiedIdentity(), hasVerifiedPhoneNumber()]) {
[true, true] => [], // nothing editable
[true, false] => [PHONE], // only phone
default => [FIRST_NAME, LAST_NAME, TITLE, PHONE, COUNTRY, CITY, STREET, ZIP_CODE, STATE],
};
E) Country¶
There are regulated countries which cannot be changed from or into. Australia and the USA have special error messages and require a transfer flow.
Country Select is Disabled When¶
On the profile page (ViewComponentProfilePersonalInformation):
shouldCountryAndStateBeReadonly =
disableCountryAndStateChange
OR hasVerifiedIdentity()
OR is_impersonated
where disableCountryAndStateChange =
hasTag(ALLOW_SANCTIONED_COUNTRY) OR hasTag(NO_ORDERS)
OR country == 'AU'
On the order form (ViewTraderComponentOrderBillingInfo):
Country Change Validation (Backend)¶
The following validations apply when a country change is submitted:
- Australia (VRGK): Changing from AU is forbidden (
ForbiddenChangeCountryValidator::validateVrgkRequirements). Changing to AU is also forbidden if the user is not already AU (Controller_profile.php:595-598). - JV regime: If the user is under the JV legal regime, any country or state change is forbidden (
ForbiddenChangeCountryValidator::validateJvRequirements). - Forbidden countries: Validated via
CountriesAndStatesService::isForbiddenCountry()(sanctioned countries from DB). - Available countries: The new country must be in the user's available countries list.
Australia Special Behavior¶
- When the user's country is AU, a special message is shown:
AUSTRALIA_DISABLED_COUNTRY_CHANGE_MESSAGE_1with a link to customer support. - When a user selects AU in the country dropdown (frontend), an alert is shown (
alert-au-order) and the submit button is hidden — handled inassets/src/trader/js/pages/profile.ts.
F) States¶
Forbidden States¶
Certain states (primarily in the USA) are considered forbidden/sanctioned. These are loaded from the database via CountriesAndStatesService::getForbiddenStatesCodes() (sourced from countriesLocationSettingsRepository::getSanctionedStates()).
State Change with Moved Confirmation¶
When a user changes from a forbidden state to a non-forbidden state, a "moved confirmation" is required (ClientsConfirmationCountriesAndStatesService::stateChangeRequiresMovedConfirmation):
- If the new state is forbidden → throws
BlacklistedStateValidationException - If the prior state was not forbidden → no confirmation needed
- If the prior state was forbidden and the user has not confirmed moving → blocks the change
The confirmation key is USA_STATES_RESTRICTION_2023_MOVED.
US ZIP-to-State Validation¶
For US users, the ZIP code must match the selected state (ZipToStateValidator::isValid).
Edition of Profile with Verified Identity¶
Once identity is verified, there are only two options to update profile data:
- NBO tool "Change IMS customer attributes" — allowed only for rare cases like typo fixes. Requires the special role
user_profile_editor. - Create a new KYC/KYB — triggers a new identity verification process.
Both methods are only available on individual request and for limited cases.
Termination of User Profile¶
No data is deleted or anonymised in SSO yet (there is a sleeping feature for data anonymisation).
Two features technically terminate user access:
1. Blacklisting¶
Adding the role blacklisted to the user. The login page shows a blocked message after sign-in.
2. Disabling¶
Setting enabled to false on the client's profile.
Both can be performed from the "Advance" card in NBO.
The API endpoint DELETE /profile (Controller_profile in api) handles termination requests using DiscardIdentityService::discardIdentity() with the action TERMINATION_DURING_TRANSFER. This cannot be used when impersonated.