← Code knowledge base
Reviewed Flutter implementation · 2026-10-27

MobileOnboardingController.cs

backend/src/NeoBanking.Api/Controllers/MobileOnboardingController.cs

Immutable source snapshot based on revision de78e63ed65c. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.

The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.

Lines 1–266

using System.Net.Http;using System.Text.Json;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using NeoBanking.Api.Onboarding;using NeoBanking.Application.DTOs.Onboarding;using NeoBanking.Application.Security;using NeoBanking.Application.UseCases.Hoppa;using NeoBanking.Domain.Entities;using NeoBanking.Infrastructure.Persistence; namespace NeoBanking.Api.Controllers; [Authorize(Policy = AuthorizationPolicyNames.User)][Route("api/v1/mobile/onboarding")]public sealed class MobileOnboardingController(    NeoBankingDbContext dbContext,    IProxyHoppaRequestUseCase proxyHoppa) : ApiControllerBase{    [HttpGet("status")]    public async Task<ActionResult<object>> GetStatus(CancellationToken cancellationToken)    {        if (!TryGetLocalUserId(out var userId) || !Guid.TryParse(userId, out var parsedUserId))        {            return MissingIdentity<object>();        }         return Ok(await BuildStatusAsync(parsedUserId, cancellationToken));    }     [HttpPost("start")]    public async Task<ActionResult<object>> Start(        [FromBody] StartOnboardingRequestDto request,        CancellationToken cancellationToken)    {        if (!TryGetLocalUserId(out var userId) || !Guid.TryParse(userId, out var parsedUserId))        {            return MissingIdentity<object>();        }         var user = await dbContext.Users            .SingleOrDefaultAsync(candidate => candidate.Id == parsedUserId, cancellationToken);        if (user is null)        {            return NotFound();        }         var accountType = await GetResolvedAccountTypeAsync(user.MetadataJson, cancellationToken);        var application = await GetLatestApplicationAsync(parsedUserId, cancellationToken);        if (application is null)        {            application = new OnboardingApplication            {                CompanyInstallationId = user.CompanyInstallationId,                ApplicantUserId = parsedUserId,                Kind = accountType == "business"                    ? "business"                    : "individual",                Status = "started",                CurrentStep = "profile",                FormDataJson = JsonSerializer.Serialize(request),                CreatedAt = DateTimeOffset.UtcNow,                UpdatedAt = DateTimeOffset.UtcNow            };            dbContext.OnboardingApplications.Add(application);        }        else        {            application.Kind = accountType == "business" ? "business" : "individual";            application.Status = application.Status == "completed" ? application.Status : "started";            application.CurrentStep = application.CurrentStep == "start" ? "profile" : application.CurrentStep;            application.FormDataJson = JsonSerializer.Serialize(request);            application.UpdatedAt = DateTimeOffset.UtcNow;        }         await dbContext.SaveChangesAsync(cancellationToken);         return Ok(await BuildStatusAsync(parsedUserId, cancellationToken));    }     [HttpPatch("steps")]    public async Task<ActionResult<object>> UpdateStep(        [FromBody] UpdateOnboardingStepRequestDto request,        CancellationToken cancellationToken)    {        if (!TryGetLocalUserId(out var userId) || !Guid.TryParse(userId, out var parsedUserId))        {            return MissingIdentity<object>();        }         var application = await GetLatestApplicationAsync(parsedUserId, cancellationToken);        if (application is null)        {            return NotFound();        }         if (!string.IsNullOrWhiteSpace(request.Step))        {            application.CurrentStep = request.Step.Trim();        }         if (!string.IsNullOrWhiteSpace(request.Status))        {            application.Status = request.Status.Trim();            if (string.Equals(application.Status, "completed", StringComparison.OrdinalIgnoreCase))            {                application.CompletedAt = DateTimeOffset.UtcNow;            }        }         application.MetadataJson = JsonSerializer.Serialize(new { request.Notes });        application.UpdatedAt = DateTimeOffset.UtcNow;        await dbContext.SaveChangesAsync(cancellationToken);         return Ok(await BuildStatusAsync(parsedUserId, cancellationToken));    }     private Task<OnboardingApplication?> GetLatestApplicationAsync(Guid userId, CancellationToken cancellationToken)    {        return dbContext.OnboardingApplications            .Where(application => application.ApplicantUserId == userId)            .OrderByDescending(application => application.CreatedAt)            .FirstOrDefaultAsync(cancellationToken);    }     private async Task<object> BuildStatusAsync(Guid userId, CancellationToken cancellationToken)    {        // Both Hoppa lookups go out before the database is consulted, so the        // response takes one upstream round trip instead of two in a row.        var liveAccountTypeTask = GetLiveHoppaAccountTypeAsync(cancellationToken);        var liveBankingStatusTask = GetLiveBankingStatusAsync(cancellationToken);         var user = await dbContext.Users            .AsNoTracking()            .Where(candidate => candidate.Id == userId)            .Select(candidate => new { candidate.MetadataJson })            .SingleOrDefaultAsync(cancellationToken);        var application = await dbContext.OnboardingApplications            .AsNoTracking()            .Where(candidate => candidate.ApplicantUserId == userId)            .OrderByDescending(candidate => candidate.CreatedAt)            .FirstOrDefaultAsync(cancellationToken);         var accountType = await liveAccountTypeTask ?? GetAccountType(user?.MetadataJson);        var liveBankingStatus = await liveBankingStatusTask;         return MobileOnboardingStatus.Compose(userId, accountType, application, liveBankingStatus);    }     private async Task<string> GetResolvedAccountTypeAsync(string? metadataJson, CancellationToken cancellationToken)    {        var localAccountType = GetAccountType(metadataJson);        var liveAccountType = await GetLiveHoppaAccountTypeAsync(cancellationToken);         return liveAccountType ?? localAccountType;    }     private async Task<string?> GetLiveHoppaAccountTypeAsync(CancellationToken cancellationToken)    {        if (!TryGetCurrentUserId(out var hoppaUserId))        {            return null;        }         var result = await proxyHoppa.ExecuteAsync(            new ProxyHoppaRequestCommand<object?>            {                Method = HttpMethod.Get,                UpstreamPath = $"/api/v2/users/{Segment(hoppaUserId)}",                FailureCode = "mobile.onboarding.user.failed",                FailureMessage = "We could not load user account type."            },            cancellationToken);         if (!result.IsSuccess || result.Value is null)        {            return null;        }         return GetAccountTypeFromPayload(result.Value.Value);    }     private async Task<string?> GetLiveBankingStatusAsync(CancellationToken cancellationToken)    {        if (!TryGetCurrentUserId(out var hoppaUserId))        {            return null;        }         var result = await proxyHoppa.ExecuteAsync(            new ProxyHoppaRequestCommand<object?>            {                Method = HttpMethod.Get,                UpstreamPath = $"/api/v2/users/{Segment(hoppaUserId)}/kyc/detailed-status",                FailureCode = "mobile.onboarding.banking_status.failed",                FailureMessage = "We could not load banking status."            },            cancellationToken);         if (!result.IsSuccess || result.Value is null)        {            return null;        }         return MobileOnboardingStatus.NormalizeBankingStatus(result.Value.Value);    }     private static string GetAccountType(string? metadataJson)    {        if (string.IsNullOrWhiteSpace(metadataJson))        {            return "personal";        }         try        {            using var document = JsonDocument.Parse(metadataJson);            if (document.RootElement.TryGetProperty("accountType", out var value) &&                value.ValueKind == JsonValueKind.String)            {                return NormalizeAccountType(value.GetString());            }        }        catch (JsonException)        {            return "personal";        }         return "personal";    }     private static string? GetAccountTypeFromPayload(JsonElement payload)    {        var userPayload = UnwrapPayload(payload);        var accountType = MobileOnboardingStatus.GetString(userPayload, "AccountType", "accountType");         return string.IsNullOrWhiteSpace(accountType) ? null : NormalizeAccountType(accountType);    }     private static JsonElement UnwrapPayload(JsonElement payload)    {        if (payload.ValueKind != JsonValueKind.Object)        {            return payload;        }         foreach (var propertyName in new[] { "Data", "data", "User", "user" })        {            if (payload.TryGetProperty(propertyName, out var nested) &&                nested.ValueKind == JsonValueKind.Object)            {                return nested;            }        }         return payload;    }     private static string NormalizeAccountType(string? accountType)    {        return string.Equals(accountType?.Trim(), "business", StringComparison.OrdinalIgnoreCase)            ? "business"            : "personal";    }}
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources ↗

Your question and recent conversation are sent to our AI provider. Don’t include API keys, personal data or card details.