CorebanqCorebanq Developer Docs
Profile

Description

Purpose and use

Profile gives a signed-in user the context needed to work inside CoreBanq: identity, customer links, available roles, and tray data used by the application shell.

Who uses this. Every authenticated staff or customer user uses profile data indirectly when the application loads their workspace, permissions, and customer context.

How it works. The profile tray combines user identity with customer memberships, role information, and related context needed to decide which records and screens the user can access.

What users do. Users open the application, switch or review customer context where allowed, and rely on profile data to see the correct navigation and operational scope.

Outcomes and side effects. Profile reads are contextual and do not post money or change business records. Incorrect profile context can hide records or expose the wrong workspace, so role and customer links should be reviewed through the user and RBAC manuals.

Related manuals: Users, Roles, RBAC, Customers.

Overview

Two read-only routes, both scoped to the signed-in user by the query itself — neither handler runs a record-level RBAC check, because the tray view already filters on the user id.

Endpoints

Get profile tray

GET /v1/profile/tray

Reads profiles.v_userlinks filtered to the caller. Returns a bare array — no envelope, no total, no pagination — and reads no query parameter. Always an array; [] when the user is linked to nothing. The order is unspecified: the query has no ORDER BY and the view is a UNION ALL of two branches, so Postgres may return the rows in any order and that order may change with the plan. Sort client-side if the switcher needs a stable one.

Success response (200):

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Personal Profile",
    "type": "company",
    "status": "ACTIVE",
    "route": "/company"
  }
]

Two fields are rewritten on the way out, both resolved for the request's Accept-Language:

  • name — when the view's column is NULL or an empty string, the server substitutes the profile_m.new_company_text template with {id4} replaced by the last four characters of id.
  • type — the view SELECTs this as a literal, so there are exactly two values before the rewrite: company for a customer link, and kyb_flow for a KYB flow with no customer row yet. company is sent unchanged and is what the metrics route filters on; kyb_flow is replaced by profile_m.kyb_flow_text. The Go code also maps a NULL type to "", but the view cannot produce one.

status is customers.customers.status for a company entry — the platform vocabulary is uppercase (KYB, SIGN, REVIEW, ACTIVE, SUSPENDED), which is why GetCustomerRoute matches on KYB and SIGN — and the literal KYB for a kyb_flow entry, so those rows always route to /company/kyb.

Neither rewrite produces end-user text. Both templates are tenant configuration, and every shipped bundle seeds the same frontend translation token in all five of its languages — cz, de, en, fr, it — so what actually arrives is TEXT.COMPANY_LIST__KYB_COMPANY_NAME ** and TEXT.COMPANY_LIST__KYB_COMPANY_STATUS, for the client to resolve.

An unseeded language empties both fields. config.GetMessage has no default-language fallback: an Accept-Language resolving to anything outside the seeded set returns an empty template, so a blank name arrives as "" after all and a kyb_flow row's type arrives as "". Every kyb_flow row is exposed to this: the view's second branch matches only flows whose customer row does not exist, so their name is always NULL and always goes through the template. config.GetLanguage makes this easier to hit than it looks — it takes the first member of the header and keeps the part before the region subtag, but does not strip a q-value, so de-CH,de is read as de while de;q=0.9 is read as the language de;q=0.9. An absent or empty header falls back to the configured default language instead, and is safe.

route is derived from status by customers.GetCustomerRoute, and takes exactly three values: /company/kyb for status KYB, /company/documents for status SIGN, and /company for everything else, an empty status included. The Go tag carries omitempty, but the default branch returns the base path rather than an empty string, so the field is always present.

Get portfolio metrics

GET /v1/profile/portfolio-metrics

Counts users, accounts and payments for each requested company. This route was absent from the OpenAPI spec entirely until now.

customer_ids is effectively required. It reads like an optional filter, but omitting it does not mean "every company in my tray" — the authorisation step returns an empty set for an empty request, so the answer is 200 {"data": []}. Send the ids you want.

Ids you may not see are dropped silently. The requested ids are intersected with the companies in the caller's own tray — entries whose type is exactly company — and anything outside that set is removed with no error and no marker. A response can be shorter than the request, and the only way to tell which ids survived is to compare customer_id values.

Query parameters

  • customer_ids — comma-separated UUIDs. Whitespace around each is trimmed, empty members are skipped, and duplicates are collapsed. At most 32 after de-duplication; more is 400 profile.portfolio_metrics_too_many_customers. A member that does not parse fails the whole request with 400 profile.portfolio_metrics_invalid_customer — there is no partial parse.

Success response (200):

{
  "data": [
    {
      "customer_id": "550e8400-e29b-41d4-a716-446655440000",
      "status": "ok",
      "metrics": { "users": 12, "accounts": 4, "payments": 318 }
    },
    {
      "customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "status": "error",
      "error": { "code": "profile.portfolio_metrics_customer_failed" }
    }
  ]
}

A per-company failure does not fail the request. That row comes back with status: "error" and an opaque code while the others still carry metrics — the response is still 200. Exactly one of metrics and error is present, chosen by status. Companies are counted concurrently, at most six at a time, so row order follows the request rather than completion.

Errors

The envelope

{"status": 400, "message": "One or more customer IDs are invalid", "code": "profile.portfolio_metrics_invalid_customer", "class": "validation"}

message is the resolved i18n template for code. code, class, retryable and details are stamped only outside the 2xx range. ClassForStatus has no empty branch: 400 and 422 are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry retryable: true; everything else, 500 included, is business. details is never populated by this module.

Codes

The module's own codes use two different prefixes — profile. and profile_m. — and this is not a typo in the documentation. The rest of the table is codes from shared packages that these two routes forward unchanged:

CodeWhere
profile_m.invalid_file_typeDeclared, but produced by no route in this module
profile.portfolio_metrics_too_many_customers400 — more than 32 distinct customer_ids
profile.portfolio_metrics_invalid_customer400 — a member of customer_ids is not a UUID
profile.portfolio_metrics_customer_failedNot an envelope code — it is the value of error.code inside a 200 row
db_m.failed_to_run_query500 — the tray query or its row scan failed
auth.failed_to_extract_claims500 — the handler ran with no claims in the request context
auth.failed_to_parse_user_id500 — claims are present but their user id does not parse
common.server_error500 — the transfer lister is not configured, or one of auth.RateLimitMiddleware's checks failed while the switcher is on: the RBAC role fetch, the caching of the caller's limits, the cached-limits lookup, or the counter increment against the rate-limit cache

Three codes this page used to list do not exist as constants anywhere: profile.not_found, profile.unauthorized, and profile.invalid_file_type under that spelling — the real constant is profile_m.invalid_file_type, and nothing in this module raises it.

profiles.v_userlinks failing is reported with db_m.failed_to_run_query built without WithCode, so HandleAppErrorWithCode short-circuits on the empty Code before reaching its status switch at all. The row-scan failure carries WithCode(500) and takes the switch's 500 case. Same status either way.

A missing user id is not common.server_error. Both handlers open with auth.GetUserID, which returns auth.failed_to_extract_claims or auth.failed_to_parse_user_id, and pass that AppError to apireply.InternalServerError500. The helper's own default code is substituted only when it is called with no AppError, which neither handler does — so a client branching on code sees one of the two auth. codes here, on both routes.

Rate limiting

auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent from the chain entirely — not mounted and idle — and no route can answer 429. Changing the flag takes a restart. When it does fire the 429 code is not common.too_many_requests — that key is only the fallback the reply helper stamps when called with no AppError. The codes that really arrive are rate_limits_m.exceeded, rate_limits_m.global_exceeded and rate_limits_m.failed_to_increment_ip_limit, plus two free-text strings that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded. The message for rate_limits_m.exceeded interpolates two parameters — {IP} is the caller's address and {endpoint} the uuid of the matched RBAC endpoint — so it never arrives as a bare sentence.

Refusals the middleware writes before the handler runs

These apply to both routes, but they do not all come from the same place. health.LifecycleMiddleware and — when the switcher is on — auth.RateLimitMiddleware are mounted on the root router. auth.Middleware is not: the module wraps each of its own handlers in auth.WrapWithMiddlewares in . A route added to this module without that wrapper gets no authentication, no RBAC grant check and no licence check at all.

StatusCodeCause
401common.unauthorizedauth.Middleware: no bearer token, no Bearer prefix, a token that does not parse, a blacklisted token, or an error during the blacklist lookup. All five call Unauthorized401 with no AppError, so the body carries the helper's default
401permission denied — free text, not an i18n keyauth.RateLimitMiddleware, and only while the switcher is on. It runs on the root router, ahead of the per-route auth.Middleware, and resolves the endpoint against the caller's cached limits — which are built only from endpoints the caller has an RBAC row for. No match returns errs.New("permission denied").WithCode(401), so an authenticated caller without the endpoint grant gets this 401 and never reaches the 403 on the next row
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missingThe licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client
500common.server_errorauth.RateLimitMiddleware, when the switcher is on and any of its checks fails — the RBAC role fetch, the caching of the caller's limits, the cached-limits lookup, or the counter increment against the rate-limit cache. All of them reach handleRateLimitError's default branch, which calls InternalServerError500 with no AppError, so the body carries the helper's default — indistinguishable by code either from each other or from the handler's own common.server_error
503{"overall_status": "unhealthy", …} — not the envelopeGraceful shutdown. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — but not the rate limiter, which registers one r.Use earlier when the switcher is on. With it on, a request arriving during a drain can be answered 429, or 401 permission denied, instead of this 503
503auth_m.internal_server_error → Internal server errorThe auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request

The 503 is two different bodies, and a client has to branch on the shape rather than assume the envelope. While the server is draining — and no earlier middleware has already refused the request — health.LifecycleMiddleware writes a bare map — {"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} — with no status, code, class or retryable field on it at all, and whose message is a fixed English string, not an i18n key. The auth-cache 503 is the envelope, and its code is auth_m.internal_server_error — not common.server_error: ensureCacheAvailable builds it from the auth package's own MsgInternalServerError, which shadows the errs constant of the same name and resolves to a different code. It carries WithCode(503), so it is handed to the reply helper and the helper's own default is never substituted. The rendered message is no help either — both keys are seeded with the same text in all five languages, so only code tells them apart.

Response status codes

StatusWhere
200Both routes
400customer_ids unparseable or over 32
401Auth middleware, or the rate limiter denying the endpoint when the switcher is on
403Endpoint grant or licence
429Rate limiter, when the switcher is on
500Tray query failure, missing user context, unconfigured dependency, or the rate limiter's own lookup failing
503Graceful shutdown, or the auth cache

Neither route can answer 404. There is no lookup by id here: the tray is a filtered read and the metrics route drops unknown ids instead of reporting them.

On this page