Data modelling and serialisation

The casing problem

The backends speak snake_case; the application speaks camelCase. Sometimes. Not always. This is the single most error-prone area of the codebase, and it is worth understanding precisely.

cerialize provides the translation, but the casing setting is global and imperative, not per-model:

// SupplierService.fetchSupplierDetails
return this.httpClient.get<SupplierDetailsModel>(url, { params }).pipe(
  map((result) => {
    DeserializeKeysFrom(CamelCase);        // ← global switch, set per call
    return Deserialize(result, SupplierDetailsModel);
  }),
);

// CrmService.listActions
map((res) => {
  DeserializeKeysFrom(UnderscoreCase);    // ← the other direction
  return { data: res['data'].map(/* … */), meta: Deserialize(res['meta'], CrmMetaModel) };
})

// PendingOfferRiskRatingService.prepareDataToSave
SerializeKeysTo(UnderscoreCase);
const serialized = Serialize(dataToSave, ProgressivePricingOfferModel);

DeserializeKeysFrom / SerializeKeysTo mutate module-global state in cerialize. Whichever call ran most recently wins. Two consequences:

  • Always set the casing immediately before the Deserialize/Serialize call, in the same map — never in a constructor or ngOnInit.

  • Interleaved async work can, in principle, observe another feature’s casing setting. In practice the set-then-use-synchronously pattern avoids it. Preserve that pattern.

Where a payload key genuinely does not follow either convention, the model keeps the API’s own spelling — for example AirwallexCardholderStatus uses connected_account_status, kyc_status, cardholder_terms_accepted verbatim, and ProgressivePricingOfferDTO’s sibling `IAdvanceOfferRequest uses advance_offer_request_key, weekly_fee_percent, and so on. That is intentional: those interfaces describe the wire format, not the domain.

A small helper, shared/utils/to-lowercase-underscore.util.ts, converts display labels to snake_case column names for report queries — unrelated to cerialize but easy to confuse with it.

Three model styles

The codebase has accreted three generations of model. All three are live; match the style of the file you are editing, and prefer style 3 for new code.

1. Decorated cerialize classes

The dominant style in suppliers/model/. Serialisation is declared with decorators, and derived business predicates live as getters.

export class SupplierDetailsModel {
  @autoserialize public supplierKey: string;
  @autoserialize public companyDBA: string;
  @autoserializeAs(SupplierStatusModel) public status?: SupplierStatusModel;
  @autoserializeIndexable(SupplierMarketplaceListModel)
  public marketplaces?: SupplierMarketplaceListModel[];

  constructor(dto?: ISupplierDetailsDTO) { Object.assign(this, dto); }

  get isTarget(): boolean { /* any marketplace is the Target marketplace */ }
  get isAmazonVendor(): boolean { /* Amazon Vendor Central or Express */ }
  get isAmazonSeller() { /* count of Amazon Seller Central links */ }
}

Decorators in use:

Decorator Meaning

@autoserialize

Primitive or plain object; participates in both directions.

@autoserializeAs(Type)

Nested model instance.

@autoserializeIndexable(Type)

Array/indexable collection of a model type.

@inheritSerialization(Base)

Class-level; inherits the base class’s serialisation metadata (used by supplier-marketplace.model.ts).

Note the paired DTO interface convention: ISupplierDetailsDTO mirrors the class’s fields as all-optional, and the constructor takes it and Object.assign`s. That gives callers a cheap way to build a partial model without the decorators getting in the way — which is exactly what the `UPDATE_MODEL_PART_SUCCESS reducer does:

const updatedDetailsModel = new SupplierDetailsModel(supplierModel.supplierDetailsModel);
Object.assign(updatedDetailsModel, action.payload.newValues);

Business getters on models (isTarget, isAmazonSeller, isEFS) are lost when the object is spread. { …​supplierDetailsModel } produces a plain object with no getters. Several reducers and effects spread these models — if a downstream consumer reads isAmazonSeller on a spread copy it silently gets undefined. Reconstruct with new SupplierDetailsModel(…​) when the getters matter.

isEFS on SupplierDetailsModel unconditionally returns true. It is a stub, not a real predicate.

2. Plain classes with a DTO constructor

Used across reports/, marketplaces/ and processes/. No decorators; the constructor maps an incoming DTO.

export class ExternalReport implements IExternalReport {
  constructor(dto: IExternalReportDTO) { /* explicit field mapping */ }
}

Services then do response.map(item ⇒ new ExternalReport(item)). Slightly more verbose than decorators but with no global-state hazard, and easy to read.

3. Interfaces plus pure mapper functions

The newest style, best exemplified by src/app/suppliers/model/card.model.ts. Types describe the wire format and the domain separately; free functions map and derive.

export type CardProvider = 'marqeta' | 'airwallex';

export interface CardBase { provider: CardProvider; cardId: string; status: string; lastFour?: string; }
export interface MarqetaCard  extends CardBase { provider: 'marqeta';  /* … */ }
export interface AirwallexCard extends CardBase { provider: 'airwallex'; formFactor: 'VIRTUAL' | 'PHYSICAL'; /* … */ }
export type SupplierCard = MarqetaCard | AirwallexCard;   // discriminated union

export function mapMarqetaCard(raw: any): MarqetaCard { /* … */ }
export function mapSupplierCard(raw: any): SupplierCard {
  return raw.provider === 'airwallex' ? mapAirwallexCard(raw) : mapMarqetaCard(raw);
}

export function isCardBlocked(card: SupplierCard): boolean {
  return card.provider === 'marqeta' ? card.status === 'SUSPENDED' : card.status === 'INACTIVE';
}

export function deriveAirwallexState(status): AirwallexOnboardingState { /* state machine */ }

This style is preferred for new work because:

  • Provider-specific status vocabularies are normalised in one place (STATUS_MAP, normalizeCardStatus, normalizeCardId).

  • The predicates are pure and trivially unit-testable (card.model.spec.ts).

  • A discriminated union makes the compiler enforce provider handling.

See card programmes for the business meaning of this particular model.

Model, DTO, interface — which to write

Kind Use for

interface I…DTO / …Dto

The exact wire shape, with the backend’s own key spelling. No behaviour.

interface

The domain shape consumed by components. Keys in camelCase.

class …Model

When you need cerialize decorators (because you serialise back), a constructor-from-DTO, or derived getters.

type union

When a payload is polymorphic and a discriminant field exists.

enum

Closed vocabularies. Placed in <module>/enums/ or shared/enums/. Examples: USER_PERMISSION, FEATURE_FLAGS, ExternalReports, UwStatus, RoutingNumberValidationStatus, CrmItemStatus, PaymentConfigStatus, MESSAGE_TYPE, CalculatorInputFields, OfferStatuses.

One concept per file, named <concept>.model.ts, in the owning module’s model/ folder. There are ~80 model files.

Polymorphic deserialisation

CrmService.listActions demonstrates the app’s one hand-rolled polymorphic mapper: a single activity feed returns mixed types and is dispatched on item.type.

switch (item.type) {
  case 'note':            return Deserialize(item, NoteModel);
  case 'task':            return Deserialize(item, TaskModel);
  case 'event':           return Deserialize(item, EventModel);
  case 'file':            return Deserialize(item, FileModel);
  case 'email':
  case 'email_supplier':  return Deserialize(item, EmailModel);
  case 'batch':           return Deserialize(item, EmailModel);
  default:                return Deserialize(item, BaseCrmModel);
}

BaseCrmModel is the fallback, so an unknown activity type degrades to a generic timeline entry rather than throwing. Adding a CRM item type means touching this switch, the CrmStateModel streams and the CrmItem union type. See CRM.

Response envelopes

Backends are not consistent. Know the four shapes you will meet:

Envelope Where

Bare array/object

Internal API lookups (countries, timezones, xactTypes).

{ results: T[], total: number }

Internal API list endpoints (suppliers via logins, payment batches, pending_review).

{ data: T, meta: {} } / { data: T[], meta: {} }

Actions Tools, event rules, global settings. Typed as Response<T> / ListResponse<T> in manage/event-rules/events.service.ts and unwrapped by GlobalSettingsService.castEntity.

Elasticsearch hit envelope

search3 on the suppliers endpoint and the event-logger changelog — { hits: { hits: […​] } } with highlight fragments, unwrapped by SearchDataModel and ElasticHighlightsService.

Services are responsible for unwrapping the envelope so that components and reducers only ever see models.

Aggregate models

Two classes are aggregates rather than payload mirrors — they exist only in the store and are assembled from several calls:

SupplierModel

Holds supplierDetailsModel, currentFundingParticularsModel, marketplaces, marketplaceFundingModels, marketplaceOptInPeriodModels, marketplaceMinFeeOverridesModels, paymentConfigsModels, balance, kycDocumentsStatuses, complianceAmazonAccessStatus, efsCardRequested. The maps are keyed by mpSupKey / paymentConfigKey. Assembled by SupplierEffects.getSupplierDetails and friends.

SupplierMarketplaceModel

The same idea one level down: the marketplace link plus its funding, min-fee overrides and opt-in periods.

Neither has cerialize decorators, because neither is ever sent to a backend.

Validators as domain logic

A few validators encode business rules and are worth knowing about because they live outside the models:

  • suppliers/supplier/supplier-payment/routing-num.validator.ts — a ValidatorFn factory keyed on the routing-number verification status.

  • suppliers/enums/routing-number-validation-status.enum.ts
    routing-number-validation-status.config.model.ts — status → message/severity mapping (success / danger / warning), including INSUFFICIENT_FOR_NATIONAL_PAYMENT and INSUFFICIENT_FOR_INTERNATIONAL_PAYMENT.

  • manage/event-rules/rules-add-edit/action-form/send-to-event-bridge/validators/event-bridge-action-name.validator.ts.

  • manage/event-rules/event-rule.models.ts — the Filter class, which parses and renders filter expressions (see events).