Suppliers

What it is

A supplier is a marketplace seller — Payability’s customer. Everything else in the application hangs off a supplierKey (a UUID). Roughly half of the codebase (~200 components) is the supplier module.

The supplier record is not one table but an aggregate: identity and business details, funding particulars, balance, marketplace links, payment configurations, compliance/KYC statuses, contracts and card accounts, each from a different endpoint.

Where it lives

Concern Location

Routes

src/app/suppliers/suppliers-routing.routes.ts — /suppliers/list and /suppliers/:supplierKey/<tab> with ~25 tab routes.

Browse

suppliers/supplier-list/

Workspace shell

suppliers/supplier/supplier.component.* plus suppliers/supplier/supplier-box-header/ (two generations — the current one and supplier-box-header-old/).

Store

suppliers/store/supplier/ — supplier.actions.ts (~70 action types), supplier.reducers.ts, supplier.effects.ts.

Models

suppliers/model/ — ~35 files. SupplierModel is the aggregate; SupplierDetailsModel is the persisted record.

Services

suppliers/services/ — ~35 services.

The supplier record

SupplierDetailsModel (suppliers/model/supplier.model.ts) is the persisted record. The fields that carry business meaning:

Group Fields

Identity

supplierKey, legalName, companyDBA, taxId, businessTypeCode, stateOfIncorporation, incorporatedInTheUS, dateEstablished, businessWebsite, schema.

Contact

contactFirstName, contactLastName, contactEmail, phoneNumber, instantMessenger (typed Skype), timezone, address, homeAddress.

Owners

owners: SupplierOwnerModel[], ownerSSN, dateOfBirth, extOwnerName. Extra owners are managed on their own tab (suppliers/supplier/additional-business-owners/).

Products enabled

instantAccess, achAvailable, wirAvailable, ppmAvailable, efsAvailable, pcardAvailable, loadNetEarnings.

EFS

efsRebatePercent, efsLastCardReqTs.

Marqeta

marqetaStatuses: { businessState, userState }.

Funding

fundFacilityLimit, oldestOptIn, depositAccountKey.

Lifecycle

status: SupplierStatusModel, hide, supplierLocked, cancelationStatus, writeoff, pendApprovNeeded, modified.

Onboarding

onboardingStatus, onboardingTemplateCode, supplierSite.

Marketplaces

marketplaces: SupplierMarketplaceListModel[].

External CRM

extDealId, extStageName, extStatus, extOwnerName — the link to the external sales CRM.

Email

sendAutoEmail.

Derived predicates

Three getters encode marketplace-based product rules:

Getter Rule

isTarget

Any linked marketplace has marketplaceKey === TARGET_MARKETPLACE_KEY (0dcb4780-…, defined in supplier-onboarding.service.ts).

isAmazonVendor

Any linked marketplace is named Amazon Vendor Central or Amazon Vendor Express — matched on display name, not key.

isAmazonSeller

Returns the count of Amazon Seller Central links (truthy number, not a boolean). AppComponent tests it with > 0 when computing EVA menu conditions.

isEFS

Unconditionally true. A stub — do not rely on it.

isAmazonVendor and isAmazonSeller match on marketplace names. A rename in the marketplace catalogue silently changes behaviour. isTarget correctly matches on key. Also remember these are getters: they vanish when the model is spread — see data modelling.

The workspace

Opening a supplier gives a tab strip; each tab is a child route under :supplierKey. Tabs and their permission posture:

Tab Route Access

General

general

Denies REST_UW_ACCESS only — auditors may view.

Additional business owners

additional-business-owners, …/new

No extra restriction.

Summary

summary/categorization

Standard deny.

Onboarding

onboarding

Standard deny. → Onboarding

Underwriting

underwriting/{initial,daily,supplier-information,documentation,background-reports,bank-transactions-report,bank-transactions-categories,access-onboarding-predictions,access-daily-predictions,ucc/…}

Denies ADVANCE_AUDITORS only; background-reports additionally requires FINANCE; documentation is flag-gated. → Underwriting

New underwriting (beta)

new-underwriting

FeatureFlagGuard.

Payment

payment/{list,new,edit/:paymentConfigKey,deposit-account-edit/:depositAccountKey}

Standard deny. → Payments

Fees

fees/{purchase,payment,reserve,reserve/edit/:marketplaceKey}

No extra restriction. → Fees

Marketplaces

marketplaces/{list,edit/:mpSupplierKey}

Standard deny. → Marketplaces

Statements

statements

Standard deny + report metadata (mp_statements / v_mp_statements).

Reports

reports/{list,:reportName}

Standard deny. → Reports

Advances

advances/{'',new,advance/:advanceKey,advance-line/:advanceLineKey,pending-request/:requestKey,ml-offer/:mlOfferKey,progressive-details[/:advanceOfferRequestKey]}

Standard deny. → Advances

Amazon lending

amazon-lending/{list,add-loan,loan/:loanKey}

Standard deny; add/edit additionally need MP_LOANS_MODIFY / MP_LOANS_DELETE via SubPermissionsGuard.

Health

health

Standard deny. Amazon account health; flag SUPPLIER_ACCOUNT_HEALTH.

Transaction summary / details

transaction-summary, transaction-details

Standard deny.

Receivables

receivables

Standard deny.

Card transactions

card-transactions/efs, card-transactions/marqeta, card-transactions/marqeta/:marqetaKey

Standard deny (the Marqeta detail route has none). → Cards

Contracts

contracts

Standard deny. → Contracts

"Standard deny" = except: [REST_UW_ACCESS, ADVANCE_AUDITORS]. Full detail in the route catalogue.

Surrounding the tabs: the supplier box header (identity, status badges, quick actions), the supplier info panel and the CRM panel — the last two driven by the layout slice, which pins the CRM panel open on the General tab specifically. See UI composition.

Loading a supplier

Nothing in the tab components triggers the load. The chain is:

URL /#/suppliers/<key>/general
  └─ SupplierKeyResolver              dispatches SetSupplierKey
  └─ ROUTER_NAVIGATION
       └─ SupplierEffects.navigateToSupplier
            walks the route tree for a `supplierKey` param, then dispatches:
              SET_SUPPLIER_KEY        point the supplier slice
              SET_TARGET_UUID         point the CRM slice at the same key
              FETCH_SUPPLIER_DETAILS
                 └─ SupplierEffects.getSupplierDetails
                      cache hit  → UPDATE_SUPPLIER_MODEL from state
                      cache miss → forkJoin(
                          SupplierService.fetchSupplierDetails(key, refresh),
                          FundingParticularsService.fetchFundingParticulars(...),
                          SupplierService.getSupplierBalance(...),
                          forkJoin(LongTermContractsService.getContractsForSupplier(...),
                                   …getHistoricalContractsForSupplier(...)))
                        → assemble SupplierModel → UPDATE_SUPPLIER_MODEL

Two behaviours to know:

Permission-shaped loading

For an ADVANCE_AUDITORS user, funding particulars, balance and both contract calls are replaced with of(null). The tuple shape is preserved, so SupplierModel.currentFundingParticularsModel and .balance are null rather than absent. Components must tolerate that.

Caching by supplier key

supplier.suppliers is a map of every supplier visited this session. Revisiting is instant and stale — a refresh requires FetchSupplierDetails({ key, refresh: true }), which also appends refreshTimestamp=Date.now() to bust any HTTP-level cache.

Secondary aggregates load on demand from their tabs: FETCH_SUPPLIER_MARKETPLACES, FETCH_SUPPLIER_MARKETPLACE, FETCH_SUPPLIER_MARKETPLACE_DETAILS_PART, FETCH_SUPPLIER_PAYMENT_CONFIGS, FETCH_COMPLIANCE_ACTIONS_WITH_FILES, FETCH_CARD_PROVIDERS, FETCH_SUPPLIER_CARDS, FETCH_AIRWALLEX_STATUS — each with the same already-in-state check.

Partial editing — the mechanism to understand

The supplier workspace does not submit whole forms. Each editable card owns a formGroupKey and saves independently. This is the single most important pattern in the module.

State

SupplierState keeps three parallel maps keyed by formGroupKey:

supplierModelPartInEdit?:  { [subformId: string]: any };   // dirty, saving
supplierModelPartSaved?:   { [subformId: string]: any };   // last successful save
supplierModelPartErrors?:  { [subformId: string]: any };   // last failure

Flow

Card "Contact data" (formGroupKey: 'contactData') → Save
  dispatch UPDATE_MODEL_PART { formGroupKey, newValues, service, itemKey }
     reducer: supplierModelPartInEdit['contactData'] = newValues
     effect:  calls service.saveItemPart(itemKey, newValues, …)
                (any ServiceUpdatePartialInterface implementation)
       success → UPDATE_MODEL_PART_SUCCESS
                   delete from …InEdit
                   set    …Saved['contactData'] = newValues
                   merge  newValues into a NEW SupplierDetailsModel
                   merge  newValues into currentFundingParticularsModel too
       failure → UPDATE_MODEL_PART_ERROR
                   delete from …InEdit
                   set    …Errors['contactData'] = error
  card shows a success or error message, then dispatches
  UPDATE_MODEL_PART_MAPS { formGroupKey, mapType: 'saved' | 'errors' } to clear it

The UI half is shared/card-formgroup/ plus card-formgroup-message.service.ts. The service half is ServiceUpdatePartialInterface.saveItemPart(itemKey, itemPart, …​params), which is why the effect can call any supplier service uniformly.

Consequences

  • A card’s success/error state must be cleared explicitly (UPDATE_MODEL_PART_MAPS with mapType: 'saved' or 'errors'). An invalid mapType throws Error('Invalid map type').

  • UPDATE_MODEL_PART_SUCCESS merges the saved values into both SupplierDetailsModel and FundingParticularsModel, because some cards edit fields that live on the funding record. A key present on both is written to both.

  • Two cards editing overlapping fields will fight. Keep formGroupKey sets disjoint.

  • The merge constructs new SupplierDetailsModel(…​), so the derived getters survive this path — unlike the plain spreads elsewhere in the reducer.

Lifecycle toggles in the store

Three toggles are modelled with the request/success/error triplet and an in-progress flag, so the button can disable itself:

Action group Effect on state

TOGGLE_HIDE_SUPPLIER[_SUCCESS/_ERROR]

toggleHideInEdit, toggleHideError. On success the reducer flips supplierDetailsModel.hide locally rather than re-reading it.

TOGGLE_SUSPEND_SUPPLIER[_SUCCESS/_ERROR]

toggleSuspendInEdit, toggleSuspendError. No local model mutation.

TOGGLE_LOCK_SUPPLIER[_SUCCESS/_ERROR]

Actions and effects exist; the reducer has no cases for them, so the lock state is only refreshed by re-fetching the supplier.

Opt-in rows (ADD_OPT_IN_ROW, DELETE_OPT_IN_ROW, with _SUCCESS, _ERROR and ADD_OPT_IN_ROW_CLEAR) follow the same shape.

Payment-config operations use a different idiom — a paymentConfigsPending map keyed by a caller-supplied actionId, with PaymentConfigStatus.SUCCESS | ERROR, cleared by PC_CLEAR_STATUS. See payments.

The lifecycle business meaning is in lifecycle actions.

Supplier list

suppliers/supplier-list/ is the browse surface. Two behaviours are worth knowing:

Query persistence

supplier.lastQueryString is persisted to localStorage['suppliers_last_query'] and restored on load. If nothing is stored, the default is created_since:<today − 1 day> — which is why a fresh session shows only recently-created suppliers, not an empty or unbounded list.

Search debounce

SEARCH_DEBOUNCE_TIME = 800 ms, exported from SupplierService.

Global search (the sidebar overlay) is separate — it hits the internal API’s search3 parameter. See UI composition.

Gotchas

Issue Detail

Reducer mutation

supplier.reducers.ts mutates nested models in several cases (supplierDetailsModel.hide = !…, supplierModel.marketplaces[index] = …, and a delete state.supplierModel.marketplaces inside FETCH_SUPPLIER_MARKETPLACE). Do not depend on reference changes for change detection here.

supplierKey outside payload

UpdateSupplierModel takes supplierKey as a second constructor argument, and the reducer reads action.supplierKey. Effects that emit plain object literals must include it at the top level, not inside payload.

ADD_PAYMENT_GATEWAY resets state

It returns { …​initialState, supplierModel: changed }, discarding the suppliers cache, the part maps and lastQueryString. Adding a gateway therefore drops other suppliers from the session cache.

FETCH_SUPPLIER_DETAILS also resets

It returns { …​initialState, suppliers, lastQueryString } — the part-edit maps and all progress flags are cleared whenever a load starts.

Auditor nulls

currentFundingParticularsModel and balance are null, not undefined, for ADVANCE_AUDITORS. Guard with truthiness, not isUndefined.

Two box headers

supplier-box-header/ and supplier-box-header-old/ both exist with their own services. Check which the template uses before editing.

EVA writes into this slice

UPDATE_SUPPLIER_DETAILS_MODEL_BY_EVA lets the embedded EVA front end patch supplier details, and AppComponent pushes details back out on every change. A field change has to be reflected on both sides. See iframe messaging.