@cobre-npm/library-portal-core
v0.41.0
Published
Shared configurations and resources for Portal MFEs
Keywords
Readme
@cobre-npm/library-portal-core
Shared catalogs, configuration and interfaces for Cobre's Portal MFEs (Vue 3 + TS). It is a static data library (no app runtime): account types, counterparties, countries, currencies, movement types, document types, etc., together with their translated labels.
- No runtime dependencies (only
devDependencies). - Published compiled to
dist/(CommonJS +.d.ts). - Managed with pnpm; built with plain
tsc.
Table of contents
- Install & import (subpaths)
- Architecture: screaming architecture
- The consumption model (the "shape A")
- Domains and their API
- i18n / languages
- Legacy code (
.) and its deprecation - Development
Install & import (subpaths)
Each domain is exposed as its own package subpath. Always import from the domain's subpath, not from the root:
import { getAccountTypes } from "@cobre-npm/library-portal-core/accounts"
import { getCurrencies } from "@cobre-npm/library-portal-core/currencies"
import { getCountries } from "@cobre-npm/library-portal-core/countries"| Subpath | Contents |
|---|---|
| @cobre-npm/library-portal-core/accounts | Account providers, types and statuses + Account interfaces |
| @cobre-npm/library-portal-core/counterparties | Counterparty types + Counterparty interfaces |
| @cobre-npm/library-portal-core/countries | Countries (base) and ISO countries (dialing codes, etc.) |
| @cobre-npm/library-portal-core/movements | Movement types and statuses |
| @cobre-npm/library-portal-core/subclients | Subclient statuses + Subclient/SubclientPerson interfaces |
| @cobre-npm/library-portal-core/documentTypes | Document types (definition + per-country applicability) |
| @cobre-npm/library-portal-core/currencies | Currencies |
| @cobre-npm/library-portal-core/transactions | Transaction types + interfaces |
| @cobre-npm/library-portal-core/canonicalTransactions | Canonical transaction/money-movement detail interfaces |
| @cobre-npm/library-portal-core/search | Search / aggregation interfaces |
| @cobre-npm/library-portal-core/trustedSessions | Trusted session (OTP elevation window) interfaces + constants |
| @cobre-npm/library-portal-core/lang | Supported locales (Locale, defaults) |
| @cobre-npm/library-portal-core (root) | ⚠️ Legacy / deprecated — see below |
Architecture: screaming architecture
Each domain is a self-contained folder whose name screams the domain (not the file type). The reference pattern is a folder with:
src/<domain>/
catalog.ts # source of truth: object keyed by code + derived types
utils/<x>.utils.ts # the domain's public API (functions)
locales/{en,es-mex}.json # translations (only when the label is i18n)
constants/<domain>.constants.ts # plain exported constants (only when the domain has any)
index.ts # barrel: exposes ONLY the public APIA domain that only needs standalone constants (no catalog, no i18n) still gets its own constants/
subfolder — e.g. trustedSessions/constants/trustedSessions.constants.ts exports
TRUSTED_SESSION_TTL_SECONDS. Same rule as everything else here: the file name screams the domain,
never constants.ts alone, and the barrel re-exports it (export * from "./constants/<domain>.constants").
When a domain groups several catalogs, each one lives in its subfolder (e.g. accounts/providers/ and
accounts/types/), and the entity interfaces in <domain>/interfaces/.
The source of truth: catalog.ts
The catalog is an as const object keyed by code (not an array), and the types are derived from it:
// countries/base/catalog.ts
export const COUNTRIES = {
col: { code: "col" },
mex: { code: "mex" },
// ...
} as const
export type Country = keyof typeof COUNTRIES // "col" | "mex" | ...
export type CountryRecord = (typeof COUNTRIES)[Country]Keying by code gives direct CATALOG[code] access without .find(), and the types never drift from the data.
The consumption model (the "shape A")
The mental rule is: the library behaves like an API that returns the source of truth ready to consume.
Each domain exposes one getX() function that returns the localized catalog: the same object keyed by
code, but with the label already included. The raw (untranslated) catalog is an internal detail and is not
exported — just like an API doesn't expose its untranslated table.
import { getAccountTypes } from "@cobre-npm/library-portal-core/accounts"
const types = getAccountTypes("es-mex")
// ^ { ch: { code: "ch", geo: ["col"], label: "Ahorros" }, cc: {...}, ... }
types.ch.label // "Ahorros" → direct access by code
types.ch.geo // ["col"] → all metadata comes included
Object.values(types) // [ {...}, {...} ] → the list, when you need itEvery use case comes out of this, without the consumer having to walk internal structures:
| I need… | How |
|---|---|
| The label of a code | getX(lang)[code].label |
| The full list (dropdown, table) | Object.values(getX(lang)) |
| Filter by some property | Object.values(getX(lang)).filter(x => …) |
The library delivers the data; the consumer decides how to filter it. There are no
getXByGeo-style helpers: since each record already carries its metadata (geo,type, etc.), filtering is a trivial.filter()on the consumer side.
That is changing, one domain at a time. Catalogs are growing records that only make sense in one flow
(cny/eur, counterparty settlement only) — something the consumer cannot tell from the record alone, so
"filter it yourself" quietly leaks them into every picker. Domains are gaining a queryX() that
returns the list already narrowed, with a safe default.
currencies is the first: see currencies for the query rules, which every later queryX()
will follow. transactions/types is the second, and the first i18n one: lang travels inside the
query object (required there, no default) rather than as a positional argument — see
transactions for what that changes. documentTypes is the third, and the first
many-to-many one — see documentTypes for how the query applies to a
catalog flattened by geo. Until a domain has one, .filter() is still the way — and it stays fine for
one-off predicates even where queryX() exists.
Two classes of domain
| Class | When | Signature | Examples |
|---|---|---|---|
| With i18n | the label depends on the language | getX(lang) | accountTypes, counterpartyTypes, movementTypes, transactionTypes, countries, countriesISO, documentTypes |
| Without i18n | the label is fixed (or there is none) | getX() | currencies (inline label), accountProviders |
currencies/providers don't take lang because there's no translation — the signature is honest.
Type convention
XOption= the catalog record +label(whatgetX(lang)returns in i18n domains). This is the type consumers use to type results.XRecord= the raw record. In non-i18n domains (currencies/providers) it is the result type (the label is already inline); in i18n domains it is internal (only input toXOption) and is not exported.X(e.g.AccountType,Currency) = the union of codes. Always public (for typing variables/params).
Domains and their API
accounts
import {
getAccountTypes, getAccountProviders, getAccountStatuses, queryAccountStatuses,
type AccountType, type AccountTypeOption,
type AccountProvider, type AccountProviderRecord,
type AccountStatus, type AccountStatusOption, type AccountStatusQuery,
type Account, type AccountResponsePaginated, /* ... interfaces */
} from "@cobre-npm/library-portal-core/accounts"
getAccountTypes("en") // Record<AccountType, AccountTypeOption> → { ch: { code, geo, label }, ... }
getAccountProviders() // Record<AccountProvider, AccountProviderRecord> (no i18n)
getAccountStatuses("en").closed // { code: "closed", type: "neutral", label: "Inactive" }
queryAccountStatuses({ lang: "en", type: "error" }) // [ disconnected, failed ] → the list a picker needs- types (i18n):
getAccountTypes(lang); each record carriescode,geo(Country[]) andlabel. - providers (no i18n):
getAccountProviders(). - status (i18n):
getAccountStatuses(lang); account statuses (creating,connected,connecting,disconnected,failed,closed).queryAccountStatuses({ lang, ... })returns the same catalog as a list, narrowed bycode/type— same matching rules asqueryCurrencies, exceptlangis required (i18n domain, no default) and lives inside the query object rather than as a separate argument. - interfaces:
Account,AccountResponsePaginated,AccountMetadata,AccountConnectivity,ParamsGetAccounts,CreateAccountRequest,UpdateAccountRequest,AccountDropdownMapper, …
counterparties
import {
getCounterpartyTypes,
type CounterpartyType, type CounterpartyTypeOption,
type Counterparty, CounterpartyVerificationStatus, /* ... */
} from "@cobre-npm/library-portal-core/counterparties"
getCounterpartyTypes("es-mex") // Record<CounterpartyType, CounterpartyTypeOption> ({ code, geo, label })- types (i18n):
getCounterpartyTypes(lang). - interfaces:
Counterparty,CounterpartyMetadata,CounterpartyBankDetails,CounterpartyPaymentInformation,CounterpartyVerificationStatus(enum),ParamsGetCounterparties,CreateCounterpartyRequest, …
countries
Groups two catalogs:
import {
getCountries, type Country, type CountryOption,
getCountriesISO, getIndicativesWithCodes, hasCountryISOFlag,
type CountryISO, type CountryISOOption, type IndicativeWithCode,
} from "@cobre-npm/library-portal-core/countries"
getCountries("en").col.label // "Colombia"
getCountriesISO("en").col // { code:"col", isoCode:"COL", indicative:"+57", label:"Colombia" }
getIndicativesWithCodes("en") // view-model for a phone dialing-code selector
hasCountryISOFlag("col") // true — whether a flag asset is available- base (i18n): business countries (
col,mex,usa,global, …). - iso (i18n): full ISO catalog (~249 countries) with
isoCodeandindicative.
movements
import {
getMovementTypes, queryMovementTypes, type MovementType, type MovementTypeGroupLabel,
type MovementTypeOption, type MovementTypeQuery,
getMovementStatuses, queryMovementStatuses, type MovementStatus, type MovementStatusType,
type MovementStatusOption, type MovementStatusQuery,
} from "@cobre-npm/library-portal-core/movements"
getMovementTypes("en").ach.label // "ACH"
getMovementTypes("en").on_ramp.groupLabel // "Stablecoin"
getMovementTypes("en").on_ramp.label // "On Ramp" (individual label, unchanged)
queryMovementTypes({ lang: "en", groupLabel: "Stablecoin" }) // global, off_ramp, on_ramp, stable_payout
getMovementStatuses("en").completed // { code: "completed", label: "Completed", type: "success", icon: "icon-check" }
queryMovementStatuses({ lang: "en", type: "error" }) // error, failed, rejected — localizedtypes (i18n): movement types (ACH, SPEI, R2P, …).
on_ramp/off_ramp/stable_payout/globaladditionally carrygroupLabel: "Stablecoin"— a display grouping for views that need to show them under one label.getMovementTypes()keeps returning each rail's own individuallabelunchanged; a consumer that wants the grouped label picksgroupLabel ?? label. There's no separategetXLabelfor this — the value already comes back insidegetMovementTypes(), same as everything else in this catalog.types query (i18n
queryX()):queryMovementTypes({ lang, ...filters })— same rules asqueryTransactionTypes/queryDocumentTypes, no default field.groupLabelis the only filterable field besidescode, and it's optional on the record: a key that is present always filters, sogroupLabel: undefinedmatches the 19 rails that don't carry a group, not "no filter". An optional UI filter has to be spread in conditionally:queryMovementTypes({ lang, ...(group.value ? { groupLabel: group.value } : {}) })status (i18n): movement statuses (
initiated,processing,completed, …). Each record also carriestype: MovementStatusType(success | warning | error | info | neutral | pending) — a Design-System-agnostic classification, not a UI component's prop value. Note it'spending, notstatus-pending: the latter is ads-v3CobreBadgevariant name, and using it would tie this library to that Design System. Mappingtypeto an actual badge variant/CSS class is each consumer's job.iconis present on 9 of the 11 statuses (under_reviewandpending_fundshave none) — unliketype,iconis coupled to the portal's current icon set (its values are that set's class names); a consumer without it should map its own icon fromcode/typeinstead.status query (i18n
queryX()):queryMovementStatuses({ lang, ...filters })—codeandtypeare the filterable fields; there's nolabelfilter, same as every otherqueryX()here.typeis the real use case:queryMovementStatuses({ lang, type: "error" })returnserror,failed,rejected, replacing a hand-maintained list of codes with a query.
subclients
import {
getSubclientStatuses, type SubclientStatus, type SubclientStatusOption,
type Subclient, type SubclientPerson,
} from "@cobre-npm/library-portal-core/subclients"
getSubclientStatuses("en").approved // { code: "approved", label: "Enabled" }- status (i18n):
getSubclientStatuses(lang); subclient statuses (processing,approved,rejected,failed). - interfaces:
Subclient,SubclientPerson(shape shared byubosandlegal_representatives).
documentTypes — three views
It's the only domain with a many-to-many relationship (one document type applies in several countries with different usage), so it exposes three ways to read the same source:
import {
getDocumentTypes, getDocumentTypesByGeo, queryDocumentTypes,
type DocumentType, type DocumentTypeUsage, type DocumentTypeGeo, type DocumentTypeValidationType,
type DocumentTypeOption, type DocumentTypeGeoOption, type DocumentTypeQuery,
} from "@cobre-npm/library-portal-core/documentTypes"
// 1) By code (definition): unique list, validation, label
const docs = getDocumentTypes("en") // Record<DocumentType, DocumentTypeOption>
Object.values(docs) // unique list of documents
docs.nit.minLength // validation by code
docs.nit.label // "NIT"
// 2) Flattened by country (applicability): per-geo/usage dropdowns, unfiltered
getDocumentTypesByGeo("en") // DocumentTypeGeoOption[] → { code, ...validations, label, geo, usage }
// 3) Flattened by country, narrowed by query (i18n queryX()): the picker/table case
queryDocumentTypes({ lang: "en", geo: "col", usage: "business" }) // only col's business-usage documentsWhy three: the by-code view covers "unique list / validation / label" (each code once); the
flattened-by-country view covers the full onboarding dropdown ("which documents does Colombia require"),
where a code repeats per country; queryDocumentTypes is that same flattened view pre-narrowed — the
third domain, after currencies and transactions/types, to get an i18n queryX(). lang goes inside
the query object, same rule as transactions, and there is no default field (unlike currencies' scope):
queryDocumentTypes({ lang: "en" }) returns everything, localized.
As with every queryX(), a key that is present always filters — it's the absence of the key that
doesn't. So queryDocumentTypes({ lang, geo: "col" }) narrows to Colombia, but
queryDocumentTypes({ lang, geo: undefined }) returns [], not "no geo filter". An optional UI filter has to
be spread in conditionally:
queryDocumentTypes({ lang, ...(geo.value ? { geo: geo.value } : {}) })getDocumentTypesByGeo("en").filter(d => d.geo === "col" && d.usage.includes("business")) still works — it's
just what queryDocumentTypes({ lang: "en", geo: "col", usage: "business" }) now does for you.
The per-geo applicability lives inside DOCUMENT_TYPES[code].geo (a Partial<Record<DocumentTypeGeo,
DocumentTypeUsage[]>>), not in a separate catalog — one source, and the same code can carry different usage
per geo (nit is business-only in col but business-or-individual in usa). queryDocumentTypes filters
the geo-flattened, unlabeled rows first and only attaches label to what matched, so DocumentTypeQuery
never accepts label as a filter — unlike the other two XQuery types, this is enforced at the type level,
not just by convention.
currencies
import { getCurrencies, queryCurrencies, type Currency, type CurrencyType, type CurrencyRecord,
type CurrencyScope, type CurrencyQuery } from "@cobre-npm/library-portal-core/currencies"
getCurrencies().cop // { code:"cop", label:"COP", isoCode:"COP", geo:"col", flagAsset:"col", minAmount:1, type:"fiat" }
queryCurrencies({ type: "fiat" }) // [ cop, mxn, usd ] → the list a picker needsNo lang: the label is fixed (a display code, the same in any language).
Which one to use. getCurrencies() is the catalog keyed by code — use it to look a single currency
up (getCurrencies()[code]) or when you genuinely want all of them. queryCurrencies() returns a
list, narrowed by the query, and is what a dropdown, table filter or picker should call.
Query rules
queryCurrencies() // cop, mxn, usd, usd_stable, usdt, usdc, copco
queryCurrencies({ type: "fiat" }) // cop, mxn, usd
queryCurrencies({ type: ["fiat", "stable"] }) // cop, mxn, usd, usd_stable, copco
queryCurrencies({ type: "fiat", geo: ["col", "mex"] }) // cop, mxn
queryCurrencies({ isoCode: "USD" }) // usd, usd_stable, usdt, usdc
queryCurrencies({ scope: "counterparties" }) // cny, eurAny field of the record is queryable, by equality. AND across fields, OR within a field, so
{ type: ["fiat","stable"], geo: "col" }reads as (fiat or stable) and geo col. There are no operators — for a range or a substring,.filter()the result.A key that is present always filters. What does not filter is the absence of the key, so
queryCurrencies({})returns everything whilequeryCurrencies({ code: undefined })returns nothing. An optional UI filter therefore has to be spread in conditionally:queryCurrencies({ type: "fiat", ...(country.value ? { geo: country.value } : {}) })This fails loudly — an empty dropdown — instead of silently returning more rows than intended.
scopeis the one field with a default of its own. Leave the key out and you get only the general-purpose currencies; ask for a scope and you get exactly the ones restricted to it. That is what keeps a new picker from inheritingcny/eur, which exist solely for counterparty settlement.
Watch out for two consequences of rule 3:
queryCurrencies()andqueryCurrencies({ scope: "counterparties" })are disjoint. For every currency a counterparty flow accepts, useObject.values(getCurrencies()).queryCurrencies({ code: "eur" })returns[], because the scope default dropseurbeforecodeis considered. Looking a currency up by code isgetCurrencies().eur.
And two on rule 1: { minAmount: 1 } is equality, not >=, which is rarely what you want; and
flagAsset/label are presentation details that make poor business criteria.
transactions
import {
getTransactionTypes, queryTransactionTypes, type TransactionType, type TransactionTypeQuery,
type Transaction, type EnrichedTransaction, /* ...interfaces */
} from "@cobre-npm/library-portal-core/transactions"
getTransactionTypes("en").spei_debit.label // "Debit via SPEI"
queryTransactionTypes({ lang: "en", code: "spei_debit" }) // [ { code: "spei_debit", label: "Debit via SPEI" } ]- types (i18n):
getTransactionTypes(lang); each record carriescode,creditDebitType("credit" | "debit"— the sameCreditDebitTypeused by theTransactioninterface) andlabel. - types query (i18n
queryX()):queryTransactionTypes({ lang, ...filters })—langis required inside the query object, not a separate argument, so the result is already localized. There is no default field (unlikecurrencies'scope): every transaction type comes back when onlylangis given.queryTransactionTypes({ lang: "en", creditDebitType: "credit" })narrows to only the credits. - interfaces:
Transaction,EnrichedTransaction,TransactionMetadata,CreditDebitType.
search / trustedSessions
Interfaces only (pure types):
import type { SearchRequest, SearchResponse, SearchFilter } from "@cobre-npm/library-portal-core/search"
import type { TrustedSessionStatus } from "@cobre-npm/library-portal-core/trustedSessions"TrustedSessionStatus is the contract of /trusted-sessions: the OTP elevation window that lets a
caller stop attaching an otp_token per request while the window is active.
trustedSessions also exports a plain constant, TRUSTED_SESSION_TTL_SECONDS, from
constants/trustedSessions.constants.ts — the pattern for a domain that needs standalone constants
(no catalog, no i18n): a constants/<domain>.constants.ts file, re-exported from the barrel.
canonicalTransactions
Interfaces only (pure types). This is the single source of truth for the canonical money-movement /
transaction detail — built by core-portal-bff's transaction-canonical BFF, consumed as-is by the
portal MFE (CanonicalMoneyMovement), and narrowed with Pick by the receipts-generation service:
import type {
ICanonicalTransactionDetail, ICanonicalReceiptRequest,
ICanonicalParticipant, ICanonicalSummary, ICanonicalAlert, /* ...all sections */
} from "@cobre-npm/library-portal-core/canonicalTransactions"ICanonicalTransactionDetailis the full canonical shape (summary,classification,participants,references,timeline,forex_quote,errors,allowed_actions,approvals,balance,associated_transactions,split_information,derived, ...).ICanonicalReceiptRequestis the wire contractcore-portal-bffsends to the receipts-generation service ({ canonical, document_type, resource_id, locale?, timezone? }).- Fields reuse existing catalogs where they represent one (
Currency,Country,CounterpartyType,DocumentType,MovementStatus) instead of a loosestring. - This union was assembled by comparing the shape as it existed, slightly diverged, in three
consumers (
portal,core-portal-bff,util-portal-receipts) — seeICanonicalApprovalEvent's doc comment for one case that's a superset of two non-overlapping shapes rather than a clean merge, and revisit it once the real payload is confirmed.
i18n / languages
Translations live in locales/{en,es-mex}.json files inside each domain (never inline in code, except
currencies, whose label is not translatable). The supported locales are typed:
import { type Locale, LOCALES, DEFAULT_ES_LOCALE, DEFAULT_EN_LOCALE }
from "@cobre-npm/library-portal-core/lang"
// LOCALES → ["en", "es-mex"]
// type Locale → "en" | "es-mex"
// DEFAULT_ES_LOCALE → "es-mex"The getX(lang) functions take lang: Locale (strongly typed): passing an unsupported language is a compile-time
error, not a silent fallback.
Legacy code (.) and its deprecation
The package root (@cobre-npm/library-portal-core, resolving to src/constants/*, src/interfaces/* and
src/utils/amount.utils) is the previous version of these catalogs. All of its contents are marked
@deprecated, pointing to their new domain:
/** @deprecated Use the `accounts` domain instead: `@cobre-npm/library-portal-core/accounts`. */
export const AccountProviders = { /* ... */ }Why it was deprecated (not removed)
This migration to screaming architecture was done in a strictly additive way: the new domains were created without touching the old code, so that nothing the root currently exports stops working. The deprecation:
- Breaks no one.
@deprecatedis just a hint (the IDE strikes it through and suggests the replacement); current consumers keep compiling and running the same. - Sets the direction. It signals that the source of truth moved to the new domains and that the old one will be retired.
- Allows gradual migration. Moving each MFE (portal, portal-widgets, portal-checkout, core-portal-bff) to the new API is a separate, incremental task; meanwhile, both versions coexist without conflict.
Key differences of the new model vs the old one
- The catalog moved from an array (or name→code map) to an object keyed by code with derived types.
- The label is always delivered inside the list (
getX(lang)returns records + label); there is no moregetXLabelin i18n domains (the lookup isgetX(lang)[code].label). - Filter helpers were removed (
getXByGeo, etc.): the consumer filters with.filter()over the list, which already carries all the metadata. - Each domain's public API was reduced to the essentials: the code type +
getX()+ the result type. The raw catalogs and intermediate types were made internal.
Out of scope for this migration:
utils/amount.utils(theformatAmountformatter) has no new replacement and is kept as-is at the root, not deprecated.
Development
Requires pnpm.
pnpm install
pnpm test # vitest (tests colocated as *.test.ts next to the code)
pnpm exec tsc --noEmit # type-check
pnpm lint # eslint --fix
pnpm build # tsc → dist/publish:prod runs lint → type-check → build → publish.
Conventions
- Strict TypeScript (
strict+noUncheckedIndexedAccess), CommonJS, relative and extensionless imports. - Style (ESLint, no Prettier): 2 spaces, double quotes, no semicolons; comments with
//(multi-line block/** */only when documenting API); blank line beforereturn/function. - Colocated tests (
*.test.ts), Vitest,describe/it. - A new domain = self-contained folder (
catalog.ts+utils/+locales/+index.ts) + anexportsentry inpackage.jsonpointing todist/<domain>/index.js. Theindex.tsexposes only the public API (code typegetX()+ result type); the raw catalog stays internal.
