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 |
|
Browse |
|
Workspace shell |
|
Store |
|
Models |
|
Services |
|
The supplier record
SupplierDetailsModel (suppliers/model/supplier.model.ts) is the persisted
record. The fields that carry business meaning:
| Group | Fields |
|---|---|
Identity |
|
Contact |
|
Owners |
|
Products enabled |
|
EFS |
|
Marqeta |
|
Funding |
|
Lifecycle |
|
Onboarding |
|
Marketplaces |
|
External CRM |
|
|
Derived predicates
Three getters encode marketplace-based product rules:
| Getter | Rule |
|---|---|
|
Any linked marketplace has |
|
Any linked marketplace is named |
|
Returns the count of |
|
Unconditionally |
|
|
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 |
|
Denies |
Additional business owners |
|
No extra restriction. |
Summary |
|
Standard deny. |
Onboarding |
|
Standard deny. → Onboarding |
Underwriting |
|
Denies |
New underwriting (beta) |
|
|
Payment |
|
Standard deny. → Payments |
Fees |
|
No extra restriction. → Fees |
Marketplaces |
|
Standard deny. → Marketplaces |
Statements |
|
Standard deny + report metadata ( |
Reports |
|
Standard deny. → Reports |
Advances |
|
Standard deny. → Advances |
Amazon lending |
|
Standard deny; add/edit additionally need |
Health |
|
Standard deny. Amazon account health; flag |
Transaction summary / details |
|
Standard deny. |
Receivables |
|
Standard deny. |
Card transactions |
|
Standard deny (the Marqeta detail route has none). → Cards |
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_AUDITORSuser, funding particulars, balance and both contract calls are replaced withof(null). The tuple shape is preserved, soSupplierModel.currentFundingParticularsModeland.balancearenullrather than absent. Components must tolerate that. - Caching by supplier key
-
supplier.suppliersis a map of every supplier visited this session. Revisiting is instant and stale — a refresh requiresFetchSupplierDetails({ key, refresh: true }), which also appendsrefreshTimestamp=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_MAPSwithmapType: 'saved'or'errors'). An invalidmapTypethrowsError('Invalid map type'). -
UPDATE_MODEL_PART_SUCCESSmerges the saved values into bothSupplierDetailsModelandFundingParticularsModel, 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
formGroupKeysets 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 |
|---|---|
|
|
|
|
|
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.lastQueryStringis persisted tolocalStorage['suppliers_last_query']and restored on load. If nothing is stored, the default iscreated_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 = 800ms, exported fromSupplierService.
Global search (the sidebar overlay) is separate — it hits the internal API’s
search3 parameter. See
UI composition.
Gotchas
| Issue | Detail |
|---|---|
Reducer mutation |
|
|
|
|
It returns |
|
It returns |
Auditor nulls |
|
Two box headers |
|
EVA writes into this slice |
|