Card programmes
What it is
Payability can give a supplier a card to spend against their balance. Three issuing programmes exist, added over time and all still live:
| Programme | Status |
|---|---|
EFS |
The original prepaid card programme. Supplier flags |
Marqeta |
Card issuing platform. Supplier field |
Airwallex |
The newest programme, behind the |
The application never calls an issuer directly — everything goes through the internal API’s card endpoints.
Where it lives
| Concern | Location |
|---|---|
Routes |
|
Components |
|
Unified service |
|
Programme services |
|
Model |
|
Store |
|
card.model.ts is the best-designed model file in the repository — a discriminated
union plus pure mapper and predicate functions. Use it as the template for new
models. See data modelling.
The unified card model
export type CardProvider = 'marqeta' | 'airwallex';
export interface CardBase {
provider: CardProvider; cardId: string; status: string; lastFour?: string;
}
export interface MarqetaCard extends CardBase {
provider: 'marqeta';
cardProductToken?; expiration?; pcardTypeIndicator?; shippingStatus?;
}
export interface AirwallexCard extends CardBase {
provider: 'airwallex';
formFactor: 'VIRTUAL' | 'PHYSICAL'; cardNumber?: string | null; createdAt?: string;
// delivery data, populated for physical cards — see <<awx-shipping>>
shippingApplicable: boolean; deliveryAddress: string | null;
deliveryStatus: string | null; trackingCode: string | null; trackingLink: string | null;
// spend limits, only when the cards payload carries them — see <<awx-limits>>
maxXactAmountAtOnce?: number | null; maxXactAmountDaily?: number | null;
}
export type SupplierCard = MarqetaCard | AirwallexCard;
Note that EFS is not in this union — it predates the unified model and is handled by its own service and tab.
Status normalisation
The two providers use different status vocabularies for the same concepts. The model normalises both.
Display mapping (STATUS_MAP / normalizeCardStatus):
| Raw status | Display |
|---|---|
|
Active |
|
Blocked |
|
Blocked |
|
Pending Activation |
|
Pending Activation |
|
Closed |
|
Closed |
Anything unmapped passes through unchanged.
Semantic predicates, which branch on the provider:
| Predicate | Marqeta | Airwallex |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Always use the predicates, never a raw string comparison. |
Card management endpoints
CardManagementService on internalApiUrl:
| Endpoint | Purpose |
|---|---|
|
Which providers are available for this supplier. Returns
|
|
List cards. Optionally filtered by provider — passed as a request body on a
GET, using |
|
Issue a card ( |
|
Block. |
|
Activate. |
|
Remove/close. |
|
Reissue. |
|
Airwallex cardholder status. |
|
Register the cardholder ( |
|
Issue an Airwallex card ( |
|
Fetch the card from Airwallex once and persist the latest status and delivery data, so the next cardholder-status read carries it. |
|
One card’s spend limits. See Airwallex per-card spend limits. |
|
Replace one card’s spend limits. Both amounts every time — see Airwallex per-card spend limits. |
|
|
Every method logs to console.error with context on failure and rethrows via
throwError(() ⇒ err) — the modern factory form, unlike the ErrorObservable.create
used in older services.
Airwallex onboarding state machine
Airwallex is the only programme with a modelled onboarding progression.
deriveAirwallexState(status) reduces the raw cardholder record to one of six states,
in this precedence order:
1. no status, or neither connected_account_id nor cardholder_id → 'not_started'
2. cards present (length > 0) → 'active'
3. cardholder_id present → 'ready_for_card'
4. connected_account_status === 'ACTIVE' → 'ready_for_cardholder'
5. connected_account_status === 'REJECTED' → 'kyc_rejected'
6. otherwise → 'kyc_pending'
not_started ──► kyc_pending ──► ready_for_cardholder ──► ready_for_card ──► active
│
└────────► kyc_rejected
AirwallexCardholderStatus fields:
| Field | Meaning |
|---|---|
|
Identifiers on both sides. |
|
The Airwallex connected account.
Status: |
|
|
|
The cardholder once registered. |
|
Terms acceptance and when. Defaults to not accepted in the UI when unknown. |
|
An Airwallex request for information: |
|
Money plumbing. |
|
Onboarding-shaped card records ( |
Response normalisation
normalizeAirwallexStatus handles three backend shapes, and this is where "is this
supplier registered?" is actually decided:
if (!response) return null;
const record = response.db_record ?? response; // may be wrapped in db_record
if (!record || response.status === 'not_registered') return null;
if (!record.connected_account_id && !record.cardholder_id && !record.airwallex_supplier_key)
return null; // flat record, nothing set
return {
...record,
connected_account_status: record.connected_account_status
?? response.connected_account_status ?? null,
rfi: record.rfi ?? response.rfi ?? null,
cards: record.cards ?? response.cards ?? [],
};
So airwallexStatus === null in the store means not registered, not not loaded —
loadingAirwallexStatus is the loading flag.
connected_account_status, rfi and cards are named explicitly rather than left to
the spread because the backend puts them either on db_record or beside it, depending
on the shape. A record written before the backend added rfi normalises to null, which
is how the panel knows to omit the row entirely.
Requests for information
Airwallex can raise an RFI against a connected account, and the endpoint reports it as
{ rfi_status, rfi_id, rfi_type }. The panel shows it as a row under the cardholder
terms: a badge with the status, plus the type and the RFI id when one is outstanding.
NO_RFI still renders, so an operator can tell "nothing outstanding" apart from "this
record is too old to say".
The status vocabulary belongs to the backend — NO_RFI and ANSWERED are the confirmed
values — so AirwallexRfiStatus stays an open union and both display helpers degrade
rather than break:
-
airwallexRfiStatusLabelmaps the known codes to prose and otherwise strips the underscores, so a newACTION_REQUIREDreads asACTION REQUIREDinstead ofNo_rfi-style mangling from thetitlecasepipe. -
rfiStatusClasscoloursANSWEREDamber and everything else, known or not, neutral grey. Guessing a colour for an unrecognised code risks telling the operator the opposite of what it means.
An outstanding RFI does not change the onboarding state — deriveAirwallexState still
keys off the connected account, cardholder and cards alone.
Airwallex physical cards and shipping
A virtual Airwallex card is active the moment it is created. A physical card ships
to an address and is created PENDING, to be activated on delivery.
Ordering a physical card
Issuing from the Airwallex panel on the supplier payment list branches on the selected
form factor. VIRTUAL dispatches straight away; PHYSICAL first opens
ChooseCardTypeComponent in shipping-only mode (data.shippingOnly === true), which
skips provider and card-type selection and opens directly on the address form and the
Expedite Shipping checkbox. Cancelling the dialog issues nothing.
The address is pre-filled from the supplier’s main owner, the same source Marqeta has always used, and the operator can edit it before ordering.
CardManagementService.issueAirwallexCard owns the payload rules:
| Rule | Why |
|---|---|
|
A virtual card ships nowhere; the backend ignores an address on one. |
Empty |
The backend expects the field absent rather than blank. |
|
The backend rejects the string |
Backend behaviour worth knowing:
-
The address is request-driven. A submitted address ships and is written back to the
loginrow. Omitting it falls back to the supplier’s business address, with no write-back. -
Physical issuance is fail-closed: an incomplete resolved address returns
400naming the missing fields and creates no card. There is no fallback to a stale cardholder address. -
Express shipping requires a phone number on the login or supplier record. Without one the order is rejected with
400before Airwallex is called. -
The duplicate guard is per form factor — an
ACTIVEorPENDINGcard of the same form factor blocks a second one, and the other form factor is unaffected.
Reading delivery and tracking
Delivery data rides on the card record itself — every card in the cards array of
GET suppliers/:key/airwallex carries shipping_applicable, delivery_address,
delivery_status, tracking_code and tracking_link. There is no separate shipping
read, so the expanded card row needs no extra request.
mapAirwallexOnboardingCard and mapAirwallexCard both fold these onto
AirwallexCard, flattening delivery_address into a single display string. Note that
the returned address uses Airwallex’s own shape (line1 / postcode), which is not
the shape sent on create (address1 / zip).
AirwallexCardListComponent renders delivery status, the delivery address, the
tracking code and a tracking link in the expanded row of a physical card. Values the
backend has not filled in yet render as an em dash. Virtual rows show none of this and
have no refresh action.
The stored values only move when something asks Airwallex, so Refresh status calls
the refresh endpoint and then emits changed, which re-runs FetchAirwallexStatus and
brings back both the refreshed card status and its delivery fields.
Store integration
Actions in the supplier slice:
| Action | State effect |
|---|---|
|
|
|
|
|
|
|
|
|
|
null vs [] matters: null means "never loaded", [] means "loaded, none".
Surfacing Airwallex failures
Every Airwallex operation fails closed and the backend message names the cause, so no
AWX call is allowed to fail silently. extractAirwallexError(error, fallback) in
supplier.reducers.ts is the one place that digs the message out, preferring
error.error.msg, then error.error.message, then a plain-string body, then
error.message, and only then the caller’s fallback.
| Failure | How the operator sees it |
|---|---|
Cardholder registration ( |
|
Card issuance ( |
|
Card operations (activate, block/unblock, replace, remove, refresh) |
|
Submitting with the minimum documents
Before it creates a Connected Account the backend checks the supplier’s compliance
documents and refuses the submit if any required one is missing. Some of those refusals
can be forced: the same PUT is repeated with overwrite_missing_documents: true, and
the backend skips the missing files and submits what it has.
Whether a refusal can be forced is the backend’s decision, and it states it outright. The 400 body carries the verdict alongside the prose:
{
"message": "Cannot create Airwallex Connected Account for supplier <key>: …",
"can_overwrite": true,
"missing_documents": ["ein_letter", "proof_of_business"]
}
Both extra fields are optional. can_overwrite is the gate — the resubmit is offered
if and only if it is the literal boolean true. Absent, false, or a truthy-but-not-true
value such as 'true' or 1 all mean no resubmit, because an unrecognised shape must fail
closed rather than send the operator on a round trip the backend has already ruled out.
missing_documents names what the supplier still owes; airwallexDocumentLabel in
card.model.ts maps the known codes to readable labels and tidies up an unknown one rather
than dropping it, since the operator has to chase the file either way.
resolveCardholderBlock turns that into an AirwallexSubmitBlock of the verbatim
message, an overridable boolean and a missingDocuments list.
SupplierPaymentListComponent then opens ConfirmDialogComponent for every refusal,
with the message in its alertMessage box and the documents in its details list:
-
Overridable — Resubmit anyways plus Close. Confirming re-dispatches
RegisterAirwallexCardholderwithoverwriteMissingDocuments: true. -
Not overridable —
isCloseOnly, so a single Close button. The operator has to fix the cause at the source.
Either way no toast is raised: it would stack on top of the modal, and the inline
awx-cardholder-error alert already keeps the reason on screen after the dialog closes.
Two guards matter:
-
The flag is only ever sent as a real boolean. The backend reads it as
… is True, so'true'or1would silently mean false. -
airwallexOverrideRequestedrecords that the in-flight request already carried the override, andresolveCardholderBlockforcesoverridabletofalsewhen it did, even if the backend still sayscan_overwrite: true. A failed override never offers another one, so the operator pays at most one wasted round trip per refusal. Re-reading the status clears the block and resets the guard.
Airwallex rebate expiry
The Airwallex transactions page lets an operator make the supplier’s
efsRebatePercent temporary. For a positive rebate, the form asks whether the
rate has an expiration date. Selecting Yes reveals a required whole-number
duration from 1 through 365 days; saving No hides and clears it. A zero, empty,
or null rebate hides the expiration controls and clears any pending duration
because there is no positive rebate to time-box.
On initial load, the rebate percentage comes from efsRebatePercent in supplier
details. When that response also contains monthlyRebatePromo, the form selects
Yes, initializes the duration from its daysLeft, and displays the
promotion’s startTs and expiryTs.
Both variants use the existing partial supplier update:
| Selection | PUT suppliers/:key payload |
|---|---|
No |
|
Yes |
|
The client sends zero only when the operator explicitly saves No.
An ordinary rebate save without an expiration change omits
monthlyRebatePromoDays, leaving any running promotion untouched.
|
The form blocks empty, fractional, negative, zero, and greater-than-365 durations before calling the API. The backend owns the expiry calculation and enforcement; no browser timer is involved. Resending an unchanged duration keeps the promotion’s original start date rather than restarting it from today.
EFS
EfsService on internalApiUrl, with two URL families:
GET/POST suppliers/:key/efs/:resource
GET/POST suppliers/:key/pcard/:resource
GET suppliers/:key/efs/get_company_report
GET suppliers/:key/pcard/get_general_report_v2
efs/ is the EFS programme; pcard/ is the Marqeta/prepaid-card programme —
the same service covers both, which is easy to misread. supplier-efs-account/
renders the EFS tab.
EFS card requests are tracked on the supplier: REQUEST_EFS_CARD action,
SupplierModel.efsCardRequested, and efsLastCardReqTs on the details model.
efsRebatePercent is the supplier’s rebate share.
selectPaymentMethods treats a payment config with paymentType === 'EFS' (or
pcardAvailable) as "has a seller card" when reporting menu conditions to EVA — see
iframe messaging.
Marqeta
SupplierMarqetaService on internalApiUrl:
GET suppliers/:key/pcard/get_general_report_v2 transactions
GET suppliers/:key/pcard/get_general_report_v2/statuses status vocabulary
GET suppliers/:key/pcard/:transactionId/transaction_details
Screens: supplier-marqeta-account/ (the list, at
card-transactions/marqeta) and card-transactions-details/ (one transaction, at
card-transactions/marqeta/:marqetaKey).
Marqeta KYC lives with underwriting:
suppliers/:key/marqeta_kyc_compliances and …/owners (GET and PUT) in
SupplierUnderwritingService. Both the business and every owner must pass before a
card can be issued. See underwriting.
marqetaStatuses.businessState and .userState on the supplier record are the
issuer-side states.
Choosing a card type
suppliers/supplier/choose-card-type/ is the dialog shown when more than one
provider is available for a supplier — driven by card_providers. With a single
provider it is skipped.
It has two opt-in modes for callers that already know what they are ordering, both set
through MAT_DIALOG_DATA: replacementMode for replacing a physical Marqeta card, and
shippingOnly for ordering a physical Airwallex card (see Airwallex physical cards and shipping). Both open
directly on the shipping form.
JIT simulation
Card spend uses just-in-time funding. PaymentConfigService exposes five simulation
endpoints (jit/simulate/{pending,rejected,charge,cleared,refund}) for exercising
that flow without real transactions. See
supplier payments.
Gotchas
| Issue | Detail |
|---|---|
|
The endpoint prefix does not say Marqeta anywhere. |
EFS is outside the unified model |
|
Airwallex is flag-gated |
|
Terms default to not accepted |
The cardholder-terms view treats unknown as not accepted, deliberately. |
GET with a body |
|
Two shapes for one address |
Physical issuance sends |
|
The string |
Express needs a phone number |
|
Delivery data is stored, not live |
The card record serves persisted values and never calls Airwallex. Use |
Limit writes replace both amounts |
A limits |
No AWX limit is not "unlimited" |
Both amounts unset means the backend pushes its own per-transaction default to Airwallex, so the UI names that default instead of showing an em dash. |
The disputes route must stay above |
|
The disputes list is supplier-wide |
There is no card filter — the backend list endpoint is supplier-scoped. The Transaction column is what ties a row back to card activity. |
The Airwallex tab is not in the supplier tab bar |
|
Airwallex per-card spend limits
Airwallex enforces card spend limits itself, and configures them per card. Two amounts
exist — max_xact_amount_at_once (per transaction) and max_xact_amount_daily — and
either may be null. There is no hourly transaction-count limit for Airwallex, and no
supplier-level Airwallex limits endpoint: both were removed with GAR-2347. Marqeta and EFS
keep their own supplier-level velocity forms, hourly count and foreign-fraud override
included.
| Rule | Behaviour |
|---|---|
Full replace |
|
|
With both amounts unset the backend pushes a single per-transaction fallback to
Airwallex ( |
`. |
|
Closed cards are read-only |
The backend rejects a limits write on a |
Effective immediately, spend is not |
New limits apply in Airwallex at once, but the amount already spent today is not reset. A daily limit below it blocks the card until the next day — the confirmation dialog says so before the write. |
AirwallexCardListComponent reads limits when a row is expanded, so cards nobody opens
cost no requests. If the cards payload already carries the two amounts they are used
directly; otherwise one GET per opened card fills them in. The values are cached in the
component by cardId and not written onto the card objects, because
SupplierPaymentListComponent.airwallexCards is a getter that re-maps the array on every
change-detection cycle.
Airwallex transaction disputes
Airwallex offers no screen for card disputes, so Internal Admin owns the whole workflow: raise a dispute against a card transaction event, attach evidence to it, submit it to the card scheme, cancel it while that is still allowed, and follow the outcome. Added by GAR-2368 on top of the internal-admin endpoints GAR-2329 shipped.
A dispute record is UI and reporting only. Winning one credits the supplier through a
separate DISPUTE_CREDIT clearing event booked by the backend, so nothing on this page
moves a balance — the create dialog says so on screen.
Where it lives
| Concern | Location |
|---|---|
Route |
|
Components |
|
Service |
|
Model |
|
Entry points |
A Disputes button beside Export Current / Export All on the Airwallex card transactions tab, and a per-row Dispute column in that tab’s grid |
There is no NgRx slice. The page holds its state in the components, as the rest of the Airwallex card transactions tab does.
AwxDisputesPageComponent resolves the supplier key from selectSupplierDetails and
passes it down as an @Input, so it arrives after first render.
AwxDisputeListComponent therefore loads on ngOnChanges as well as ngOnInit, and
never fetches on a falsy key — a fetch in ngOnInit alone would put undefined in the
URL.
Lifecycle and affordances
DRAFT ──► SUBMITTED ──► IN_PROGRESS ──► WON | LOST | REJECTED | EXPIRED
│ │
└────────────┴──────► CANCELED
AwxDisputeStatus is a string-literal union over exactly those eight values.
Airwallex enforces the rules server-side and rejects a disallowed action with a 400;
the UI derives the affordances from status so an operator is never offered a button that
cannot succeed.
| Predicate | True for | Drives |
|---|---|---|
|
|
The Add evidence row action. |
|
|
The Submit row action. |
|
|
The Cancel row action. Airwallex blocks cancellation once the scheme has the case. |
|
|
Nothing actionable. |
|
As with card status, always call the predicates rather than comparing the status string in a template. The affordance rules live in the model so they are tested in one place. |
The list renders detailed_status beside the status pill when it differs from status,
so the Airwallex sub-state is visible without a column of its own. Submit and Cancel each
run confirm → call → toast → reload, with a per-row busyDisputeId disabling that row
while a call is in flight. Filtering by status is client-side: the list is small enough to
fetch whole, and there is no pagination.
Endpoints
AwxDisputesService on internalApiUrl, which already ends in internal/:
| Endpoint | Purpose |
|---|---|
|
List. The backend has already followed Airwallex cursor pagination and returns a flat
array; the service coerces a non-array body to |
|
Create. Requires |
|
Attach evidence. |
|
Submit to the scheme. |
|
Cancel. |
Every one of them returns a dispute object. The fields are a snake_case pass-through of
the Airwallex shape — the backend does not reshape it, so the model does not camel-case
it either, matching AwxTransaction on the same tab. Treat the field set as open.
|
The event id has two names. Create takes it as |
|
Unlike |
Evidence rules
validateEvidenceFile mirrors the limits the backend enforces so the operator hears about
a bad file before committing to a 20 MB upload. It returns an operator-facing message, or
null when the file is acceptable. The backend remains the source of truth.
| Rule | Detail |
|---|---|
Allowed extensions |
|
|
Airwallex rejects it specifically, and "not an allowed type" reads as a mistake the operator cannot act on. It gets its own message. |
Maximum size |
20 MB ( |
Maximum file name |
50 characters ( |
The dialog validates on selection, not on upload, and a failure disables the upload button
— the request is never sent. It binds app-input-file, which writes a FileInputModel
rather than a raw File, so the dialog reads control.value?.files?.[0].
Creating from a transaction row
A dispute is keyed on the transaction event, not the transaction, so
AwxTransaction and AwxTransactionMapper.map carry transaction_event_id, sitting
beside transaction_id where it belongs rather than at the end of the constructor’s
twenty positional parameters. That is safe only because AwxTransactionMapper.map is the
single construction site in src/; add another and the argument order becomes a trap.
The grid’s Dispute column sits after the Transaction ID column — anywhere after the
pinned ROW_COUNT_COLUMN_DEF, which has to stay leftmost. It renders a link through
cellRenderer and handles the click in onCellClicked, destructuring ({ data })
because an action column needs the row rather than the cell value. It
navigates to the disputes page with createFor (the event id), transactionId and
amount on the query string; the list component opens the create dialog from those
parameters and then clears them with replaceUrl, so reloading or navigating back does
not reopen it.
The create dialog prefills and disables the event-id control — the operator can confirm
which event they are disputing but cannot retype it — and reads the value from the
injected dialog data, since Angular omits disabled controls from form.value. Optional
fields are omitted from the payload when empty rather than sent blank: an absent amount
disputes the full transaction, which is not the same request as 0. reference is capped
at 20 characters (AWX_DISPUTE_REFERENCE_MAX_LENGTH) with a counter and a field-level
error. On a backend rejection the dialog stays open with the typed input intact.
|
The Dispute cell renders no link, and the click handler no-ops, when the row has no
|
Scope and reach
| Decision | Why |
|---|---|
Supplier-wide, not per card |
GAR-2368 asked for supplier and card scoping, but the backend list endpoint is
supplier-scoped and exposes no |
No feature flag, no new permission |
The page is additive, its actions are guarded by dispute state on the backend, and it is
reachable only from the Airwallex card transactions tab. Access is inherited from the
route’s |
No new environment variable |
The service reads the existing |
Discoverability is inherited too, and it is thinner than it looks: the supplier tab bar
has no Airwallex entry at all — supplier-box-header-old only adds
card-transactions/marqeta as "Seller Card". Operators reach the Airwallex tab through
the EVA supplier menu, which app.component.ts enables via isSellerCardAwx when the
supplier has a PCARD-AWX payment type, or by typing the URL. The disputes page is
exactly as discoverable as the tab it hangs off, no more. This predates GAR-2368. See
iframe messaging for the menu-conditions message.