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 efsAvailable, efsRebatePercent, efsLastCardReqTs. Its own supplier tab.

Marqeta

Card issuing platform. Supplier field marqetaStatuses { businessState, userState }; requires Marqeta’s own KYC pass. Referred to in endpoints as pcard.

Airwallex

The newest programme, behind the CARD_MANAGEMENT_AIRWALLEX feature flag. Has a full cardholder-onboarding state machine and terms acceptance.

The application never calls an issuer directly — everything goes through the internal API’s card endpoints.

Where it lives

Concern Location

Routes

/suppliers/:supplierKey/card-transactions/efs, …/card-transactions/marqeta, …/card-transactions/marqeta/:marqetaKey, …/card-transactions/airwallex, …/card-transactions/airwallex/disputes, …/card-transactions/airwallex/:awxKey

Components

suppliers/supplier/supplier-efs-account/, supplier-marqeta-account/, supplier-awx-account/ and its awx-disputes/ (see Airwallex transaction disputes), card-transactions-details/, choose-card-type/, supplier-payment/supplier-payment-list/ (the Airwallex panel) and its airwallex-card-list/

Unified service

suppliers/services/card-management/card-management.service.ts (providedIn: 'root')

Programme services

suppliers/services/efs.service.ts (+ mock), supplier-marqeta-account/supplier-marqeta.service.ts, supplier-awx-account/supplier-awx.service.ts, suppliers/services/awx-disputes/awx-disputes.service.ts (providedIn: 'root')

Model

suppliers/model/card.model.ts (+ card.model.spec.ts), suppliers/model/awx-dispute.model.ts (+ spec), supplier-awx-account/awx-transaction.model.ts

Store

availableCardProviders, supplierCards, airwallexStatus, loadingAirwallexStatus, registeringAirwallex, issuingAirwallexCard, airwallexCardError, airwallexCardholderBlock, airwallexOverrideRequested in the supplier slice

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

Active

SUSPENDED

Blocked

INACTIVE

Blocked

UNACTIVATED

Pending Activation

PENDING

Pending Activation

TERMINATED

Closed

CLOSED

Closed

Anything unmapped passes through unchanged.

Semantic predicates, which branch on the provider:

Predicate Marqeta Airwallex

isCardActive

ACTIVE

ACTIVE

isCardBlocked

SUSPENDED

INACTIVE

isCardClosed

TERMINATED

CLOSED

isCardPendingActivation

UNACTIVATED

PENDING

Always use the predicates, never a raw string comparison. status === 'INACTIVE' means "blocked" for Airwallex and nothing for Marqeta.

Id normalisation

Marqeta identifies a card by token; Airwallex by card_id. normalizeCardId(raw) prefers token, falls back to card_id, then ''. mapMarqetaCard / mapAirwallexCard / mapSupplierCard handle the rest, including deriving lastFour from card_number or pan when the backend does not send it.

Card management endpoints

CardManagementService on internalApiUrl:

Endpoint Purpose

GET suppliers/:key/card_providers

Which providers are available for this supplier. Returns { providers: CardProvider[] }.

GET suppliers/:key/cards

List cards. Optionally filtered by provider — passed as a request body on a GET, using http.request('GET', url, { body }).

POST suppliers/:key/cards/newcard

Issue a card (NewCardRequest: provider, form_factor, pcard_type_to_issue, recipient_address, expedite_card, created_by).

POST suppliers/:key/cards/block/:cardId

Block.

POST suppliers/:key/cards/activate/:cardId

Activate.

POST suppliers/:key/cards/remove/:cardId

Remove/close.

POST suppliers/:key/cards/reissue/:cardId

Reissue.

GET suppliers/:key/airwallex

Airwallex cardholder status.

PUT suppliers/:key/airwallex/cardholder

Register the cardholder ({ login_key }).

POST suppliers/:key/airwallex/newcard

Issue an Airwallex card (AirwallexIssueCardRequest: form_factor, recipient_address, expedite_card). See Airwallex physical cards and shipping.

POST suppliers/:key/airwallex/cards/:cardId/refresh

Fetch the card from Airwallex once and persist the latest status and delivery data, so the next cardholder-status read carries it.

GET suppliers/:key/cards/:cardId/velocity/limits/awx

One card’s spend limits. See Airwallex per-card spend limits.

PUT suppliers/:key/cards/:cardId/velocity/limits/awx

Replace one card’s spend limits. Both amounts every time — see Airwallex per-card spend limits.

getCards sends a body on a GET request. That is unusual, and some proxies drop GET bodies. If provider filtering appears to be ignored in a deployed environment, this is the first thing to check.

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

airwallex_supplier_key, supplier_key

Identifiers on both sides.

connected_account_id, connected_account_status

The Airwallex connected account. Status: ACTIVE / SUBMITTED / CREATED / REJECTED / SUSPENDED / null.

kyc_status

READY / PENDING / other strings / null.

cardholder_id, cardholder_type

The cardholder once registered.

cardholder_terms_accepted, cardholder_terms_response_ts

Terms acceptance and when. Defaults to not accepted in the UI when unknown.

rfi

An Airwallex request for information: { rfi_status, rfi_id, rfi_type }, all nullable. Reported on its own row in the panel and deliberately not an input to deriveAirwallexState — see Requests for information.

spending_account_id, funding_source_id

Money plumbing.

cards

Onboarding-shaped card records (AirwallexOnboardingCard), mapped by mapAirwallexOnboardingCard.

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:

  • airwallexRfiStatusLabel maps the known codes to prose and otherwise strips the underscores, so a new ACTION_REQUIRED reads as ACTION REQUIRED instead of No_rfi-style mangling from the titlecase pipe.

  • rfiStatusClass colours ANSWERED amber 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

recipient_address only for PHYSICAL

A virtual card ships nowhere; the backend ignores an address on one.

Empty address2 is stripped

The backend expects the field absent rather than blank.

expedite_card coerced with === true

The backend rejects the string "false" and would not treat it as standard mail.

Backend behaviour worth knowing:

  • The address is request-driven. A submitted address ships and is written back to the login row. Omitting it falls back to the supplier’s business address, with no write-back.

  • Physical issuance is fail-closed: an incomplete resolved address returns 400 naming 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 400 before Airwallex is called.

  • The duplicate guard is per form factor — an ACTIVE or PENDING card 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

FETCH_CARD_PROVIDERS / _SUCCESS / _ERROR

availableCardProviders (initially null).

FETCH_SUPPLIER_CARDS / _SUCCESS / _ERROR

supplierCards (initially null).

FETCH_AIRWALLEX_STATUS / _SUCCESS / _ERROR

airwallexStatus + loadingAirwallexStatus.

REGISTER_AIRWALLEX_CARDHOLDER / _SUCCESS / _ERROR

registeringAirwallex and airwallexCardholderBlock; success also replaces airwallexStatus. The payload carries { supplierKey, loginKey, overwriteMissingDocuments? } — see submitting with the minimum documents.

ISSUE_AIRWALLEX_CARD / _SUCCESS / _ERROR

issuingAirwallexCard and airwallexCardError. The payload carries { supplierKey, formFactor, recipientAddress?, expediteCard? }. The effect chases success with FetchAirwallexStatus, so the new card appears without the caller re-fetching.

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 (PUT …/airwallex/cardholder)

REGISTER_AIRWALLEX_CARDHOLDER_ERROR stores the message in airwallexCardholderBlock, which is what selectAirwallexCardholderError reads. The panel renders it in a persistent awx-cardholder-error alert, because a KYC failure enumerates every missing field (supplier.tax_id (EIN), login[…].birth_date, and so on) and the operator needs it on screen long enough to act on. It also opens a dialog — always, whether or not a resubmit is on offer — so this is the one AWX failure that does not toast; see Submitting with the minimum documents. Retrying, or re-reading the status, clears it.

Card issuance (POST …/airwallex/newcard)

ISSUE_AIRWALLEX_CARD_ERROR stores the message in airwallexCardError; the panel subscribes to selectAirwallexCardError and toasts. ISSUE_AIRWALLEX_CARD clears it.

Card operations (activate, block/unblock, replace, remove, refresh)

AirwallexCardListComponent calls the service directly and toasts the backend message from the subscribe error handler.

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 RegisterAirwallexCardholder with overwriteMissingDocuments: 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' or 1 would silently mean false.

  • airwallexOverrideRequested records that the in-flight request already carried the override, and resolveCardholderBlock forces overridable to false when it did, even if the backend still says can_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

efsRebatePercent plus monthlyRebatePromoDays: 0, which ends the promotion.

Yes

efsRebatePercent plus numeric monthlyRebatePromoDays.

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

pcard means Marqeta

The endpoint prefix does not say Marqeta anywhere.

EFS is outside the unified model

SupplierCard covers only Marqeta and Airwallex. Do not assume a card list includes EFS.

Airwallex is flag-gated

CARD_MANAGEMENT_AIRWALLEX. With the flag off the provider is absent from the UI even if the backend returns it.

Terms default to not accepted

The cardholder-terms view treats unknown as not accepted, deliberately.

GET with a body

getCards filters by provider using a GET body.

Two shapes for one address

Physical issuance sends address1/zip; the card record returns line1/postcode. The card mappers bridge them.

expedite_card must be a real boolean

The string "false" is rejected rather than read as standard mail, which is why the service coerces with === true.

Express needs a phone number

expedite_card: true without a phone on the login or supplier record is a 400 before Airwallex is called.

Delivery data is stored, not live

The card record serves persisted values and never calls Airwallex. Use …/refresh when the data looks stale.

Limit writes replace both amounts

A limits PUT that omits one amount clears it. The client always sends the pair.

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 :awxKey

card-transactions/airwallex/disputes is registered before card-transactions/airwallex/:awxKey, which would otherwise match disputes as a transaction key and render the transaction-details screen. See Airwallex transaction disputes.

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

supplier-box-header-old only adds card-transactions/marqeta ("Seller Card"). Operators reach Airwallex card transactions — and therefore disputes — through the EVA supplier menu or by URL. See Airwallex transaction disputes.

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

PUT writes both amounts. The backend cannot tell an omitted field from an explicit null and clears the column either way, so updateAirwallexCardLimits always sends the pair. An emptied input is sent as null.

null means "backend default"

With both amounts unset the backend pushes a single per-transaction fallback to Airwallex (AIRWALLEX_DEFAULT_PER_TRANSACTION_LIMIT mirrors it for display). 0 is a legal limit and is not the same as unset, which is why the mappers normalise with ?? and never `

`.

Closed cards are read-only

The backend rejects a limits write on a CLOSED card with a 400; every other status accepts one. canEditLimits delegates to the existing canModify predicate rather than restating the rule.

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

card-transactions/airwallex/disputes → AwxDisputesPageComponent, a child of :supplierKey in suppliers/suppliers-routing.routes.ts, spreading RESTRICTED_UW_ACCESS_CONFIG

Components

supplier-awx-account/awx-disputes/: awx-disputes-page.component (routed shell), awx-dispute-list.component (table and every action), awx-dispute-create-dialog.component, awx-dispute-evidence-dialog.component, and a shared awx-disputes.component.scss

Service

suppliers/services/awx-disputes/awx-disputes.service.ts

Model

suppliers/model/awx-dispute.model.ts — AwxDispute, AwxDisputeStatus, CreateAwxDisputeRequest, AwxDisputeFilters, the reason and status lists, the state predicates and validateEvidenceFile

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

canAddEvidence

DRAFT

The Add evidence row action.

canSubmit

DRAFT

The Submit row action.

canCancel

DRAFT, SUBMITTED

The Cancel row action. Airwallex blocks cancellation once the scheme has the case.

isTerminal

WON, LOST, REJECTED, EXPIRED, CANCELED

Nothing actionable. IN_PROGRESS also offers no actions, and is deliberately not terminal.

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

GET suppliers/:key/airwallex/disputes

List. The backend has already followed Airwallex cursor pagination and returns a flat array; the service coerces a non-array body to []. Takes an optional AwxDisputeFilters object, with empty values dropped from the HttpParams.

POST suppliers/:key/airwallex/disputes

Create. Requires card_transaction_event_id and reason; amount, notes, reference and additional_info are optional.

POST suppliers/:key/airwallex/disputes/:disputeId/evidence

Attach evidence. multipart/form-data, file under file, one file per request. The service sets no Content-Type so the browser writes the multipart boundary.

POST suppliers/:key/airwallex/disputes/:disputeId/submit

Submit to the scheme.

POST suppliers/:key/airwallex/disputes/:disputeId/cancel

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 card_transaction_event_id; the dispute object that comes back — and the transaction report the id is read from — both call the same value transaction_event_id. CreateAwxDisputeRequest carries the request name and AwxDispute the response name. Normalising them in the UI would hide a backend fact, so the mismatch is deliberate; copying a field name from the wrong one produces a 400.

Unlike CardManagementService, this service neither logs nor wraps errors. The backend forwards the Airwallex message prefixed Airwallex error: …, and every caller renders that message verbatim — inline in a dialog, or as a toast for a row action. It names the cause better than anything this layer could substitute.

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

pdf, jpg, jpeg, png, doc, docx, xls, xlsx, csv, mp4, heic (AWX_EVIDENCE_ALLOWED_EXTENSIONS), read off the last . of the name and lowercased.

.txt is called out separately

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 (AWX_EVIDENCE_MAX_BYTES).

Maximum file name

50 characters (AWX_EVIDENCE_MAX_FILENAME_LENGTH).

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 transaction_event_id. That field comes from POST suppliers/:key/pcard/get_general_report_awx, which did not always return it — so a partial backend rollout leaves the column empty rather than producing an invalid create. An empty Dispute column across every row is the symptom to check first.

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 card_id filter. Accepted on 2026-09-15: dispute volumes are low, and the Transaction column already identifies the card activity behind each row. The service passes a filter object through, so narrowing later costs only a parameter.

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 RESTRICTED_UW_ACCESS_CONFIG.

No new environment variable

The service reads the existing internalApiUrl.

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.