Seller Card

The Seller Card lets a supplier spend their Payability balance directly. Two issuers are live at once: the original Marqeta cards and the newer Airwallex cards, presented through a single unified UI.

Eligibility

private isSellerCardAvailable(user: User): boolean {
    const hasPaymentHistory = user?.status?.hasPaymentHistory;
    const pcardAvailable    = user?.pcardAvailable;
    const isFromUS          = this.isFromUS(user);
    return hasPaymentHistory && (pcardAvailable || isFromUS) && !this.isUserDuringOnboarding(user);
}

private isFromUS(user: User): boolean {
    const country            = user?._businessInformation?.address?.country;
    const residentialCountry = user?._personalInformation?.residentialAddress?.country;
    return [country, residentialCountry].every(code => code === CountryCode.US);
}
Condition Note

status.hasPaymentHistory

At least one payment must have been processed.

pcardAvailable or US-based

Explicitly enabled by Payability, or both addresses are US.

Not onboarding

dashboardStatus.status !== Onboarding.

isFromUS() uses .every() across the business and residential addresses. A US business with a non-US owner fails, and must be enabled through pcardAvailable.

Two issuers, one UI

Marqeta Airwallex

Enum value

CardProvider.Marqeta

CardProvider.Airwallex

Modelled as

A payment method (paymentMethods slice)

A card (airwallexCards slice)

Status enum

PCardStatus

AirwallexCardStatus

Form factor

PCardTypeIndicator (from instrument_type)

CardFormFactor

Shipping

Physical cards shipped to a recipient address

Uses the cardholder address on file

The unified card model

src/app/core/pyb/interfaces/unified-supplier-card.interface.ts normalizes both into one shape so the "Your Cards" panel can render them together.

export enum UnifiedCardStatus {
    Active  = 'ACTIVE',
    Blocked = 'BLOCKED',
    Pending = 'PENDING',
    Closed  = 'CLOSED',
}

export interface UnifiedSupplierCard extends CardShipping {
    id: string;
    provider: CardProvider;
    formFactorIndicator: string;
    formFactorLabelKey: string;
    last4: string | null;
    status: UnifiedCardStatus;
    statusLabelKey: string;
    statusColor: string;
    providerLabelKey: string;
    createdAt?: string;
    raw: PaymentMethodModel | AirwallexCard;   (1)
}
1 raw retains the original provider model so the page can route an action to the correct service.

Status mapping:

Unified Marqeta (PCardStatus) Airwallex (AirwallexCardStatus)

Active

Active

ACTIVE

Blocked

Suspended

INACTIVE

Pending

Unactivated

PENDING

Closed

Terminated

CLOSED

Unmapped values fall through to Closed.

Presentation mapping:

Unified status Label key Colour

Active

SELLER_CARD.ACTIVE

success

Blocked

SELLER_CARD.BLOCKED

danger

Pending

SELLER_CARD.PENDING_ACTIVATION

warning

Closed

SELLER_CARD.TERMINATED

danger

Synthesized payment methods

The unified /cards endpoint is authoritative for display data, but Marqeta actions historically route through the payment-methods store. When a Marqeta card in the /cards response has no matching payment method, synthesizeMarqetaPaymentMethod() fabricates a minimal stand-in carrying the card token:

const synthesizeMarqetaPaymentMethod = (dto: UnifiedCardDto): PaymentMethodModel => ({
    paymentConfig: {
        pcard: {
            card_token:           dto.token,
            cardNumber:           dto.last_four ?? '',
            pcard_status:         dto.state,
            pcard_type_indicator: marqetaFormFactor(dto.instrument_type),
        },
    },
} as unknown as PaymentMethodModel);

A synthesized method is enough for block and activate, which only need the card token. The View card flow requires a real paymentConfigKey, so it is only offered when a genuine payment method was matched.

Form factor is derived from Marqeta’s instrument_type string:

const marqetaFormFactor = (instrumentType: string | undefined): string =>
    (instrumentType ?? '').toUpperCase().includes('VIRTUAL')
        ? PCardTypeIndicator.Virtual
        : PCardTypeIndicator.Physical;

AirwallexCardService

src/app/core/pyb/services/airwallex-card.service.ts — the HTTP surface, all supplier-scoped.

Method Endpoint

getCards(supplierKey)

GET /suppliers/{key}/cards — unified list, each entry tagged with provider.

getCardProviders(supplierKey)

GET /suppliers/{key}/card_providers — which issuers and form factors are offered.

getConsentStatus(supplierKey)

GET /suppliers/{key}/airwallex — cardholder_terms_accepted.

saveConsent(supplierKey)

POST /suppliers/{key}/airwallex/consent.

newCard(supplierKey, request)

POST /suppliers/{key}/cards/newcard.

getPanToken(supplierKey, cardId)

POST /suppliers/{key}/airwallex/pan-token — short-lived token for revealing the PAN.

blockCard(supplierKey, cardId)

POST /suppliers/{key}/cards/block/{cardId} with { provider: Airwallex }.

activateCard(supplierKey, cardId)

POST /suppliers/{key}/cards/activate/{cardId} — also used to unblock.

The order request is shaped per provider:

newCard(supplierKey, { provider, formFactor, recipientAddress, expediteCard }) {
    return this.http.post(`${this.env.apiUrl}/suppliers/${supplierKey}/cards/newcard`, {
        provider,
        form_factor: formFactor,
        ...(recipientAddress ? { recipient_address: recipientAddress } : {}),   (1)
        ...(expediteCard != null ? { expedite_card: expediteCard } : {}),
    });
}
1 recipient_address and expedite_card are only sent for issuers that ship a physical card. Airwallex uses the cardholder address already on file.

Airwallex consent must be recorded before a card is ordered. If saveConsent() fails, the order must not proceed.

Card operations

Each operation is a small service in views/pages/seller-card/services/, and each follows the same shape: confirm → 2FA challenge → API call → analytics.

block(card: AirwallexCard): Observable<AirwallexCardDto> {
    return this.openBlockCardConfirmationModal().pipe(
        filter(Boolean),
        switchMap(() => this.check2FaState()),
        filter(Boolean),
        switchMap(() => this.airwallexCardService.blockCard(this.userService.userId, card.cardId)),
        tap(() => this.eventLoggerService.track({ eventMessage: AirwallexCardEventTypes.CARD_BLOCKED })),
    );
}
Service Behaviour

AirwallexCardBlockService

Confirmation modal → 2FA → blockCard() → CARD_BLOCKED.

AirwallexCardUnblockService

Confirmation → 2FA → activateCard() → CARD_UNBLOCKED.

AirwallexCardViewService

2FA → AirwallexCardViewModal (reveals card details via the PAN token).

SellerCardBlockService / SellerCardUnblockService

Marqeta equivalents.

SellerCardViewService

Marqeta card details.

SellerCardActivateService

Marqeta card activation.

Every card operation requires a passed 2FA challenge. filter(Boolean) after checkTwoFactorAuthenticationState() silently ends the stream when the challenge fails, so no API call is made.

Ordering a card — /newcard

NewCardModule (views/pages/newcard/) is the ordering funnel. It is where SellerCardListGuard sends a supplier who has no cards yet.

newcard.page.ts

The ordering page.

newcard-perks/

Benefit presentation.

newcard-wrapper/

Layout shell.

shipping-address.constants.ts / .interface.ts

Physical-card delivery address form.

newcard.constants.ts

Page copy and option constants.

Backed by the cardOrder slice: loadCardProviders resolves the available CardProvider / CardFormFactor combinations, then orderCard submits.

export enum CardProvider   { Marqeta = 'marqeta', Airwallex = 'airwallex' }
export enum CardFormFactor { Physical = 'PHYSICAL', Virtual = 'VIRTUAL' }

The Airwallex transition

Existing Marqeta cardholders must accept Airwallex’s terms before their card is migrated. This is delivered as a dashboard notification, not a page.

airwallex_pcard_terms

Accept the new provider’s terms. Opens AirwallexPcardTermsModalComponent.

identity_document_expired

Identity document expired — opens SellerCardTransitionModalComponent, which folds the terms step in when needed.

GlobalSubscriptionsService subscribes to both, and the identity notification takes precedence:

private subscribeToAirwallexPcardTermsNotification(): Subscription {
    return this.store.pipe(
        select(selectDashboardNotifications),
        filter(notifications =>
            this.canSeeAirwallexPcardTerms(notifications?.airwallexPcardTerms) &&
            !this.canSeeIdentityDocumentExpired(notifications?.identityDocumentExpired)),   (1)
        take(1),
    ).subscribe(() => this.displayAirwallexPcardTermsModal());
}
1 If the identity document has also expired, the standalone terms dialog is suppressed — the transition modal handles both in one pass.

Terms-only dialog

private displayAirwallexPcardTermsModal() {
    this.dialogService.open(AirwallexPcardTermsModalComponent)   // no disableClose → dismissible
        .afterClosed()
        .pipe(take(1))
        .subscribe(result => {
            if (result?.actionType === CtaActionTypesEnum.Accept) {
                this.store.dispatch(acceptDashboardNotifications({
                    payload: { type: DashboardNotificationType.AirwallexPcardTerms },
                }));
            }
        });
}

Only Accept resolves the notification. Dismissing does nothing, so the dialog returns on the next load until the terms are accepted. The dialog itself carries an amber "Important" callout about the cutover date and a mandatory checkbox linking the Terms & Conditions and the Cardholder Agreement; the accept button is disabled until it is ticked. Copy lives under MODALS.AIRWALLEX_PCARD_TERMS in src/assets/i18n/en.ts.

Combined transition dialog

private displayIdentityDocumentExpiredModal(notifications: DashboardNotifications): void {
    const showTermsStep = !notifications?.identityDocumentExpired?.data?.airwallex_terms_accepted;   (1)

    this.dialogService.open(SellerCardTransitionModalComponent, { data: { showTermsStep } })
        .afterClosed().pipe(take(1))
        .subscribe(result => {
            if (result?.actionType !== CtaActionTypesEnum.Accept) { return; }

            if (result.termsAccepted) {
                this.store.dispatch(acceptDashboardNotifications({
                    payload: { type: DashboardNotificationType.AirwallexPcardTerms, silent: true },   (2)
                }));
            }
            this.store.dispatch(acceptDashboardNotifications({
                payload: { type: DashboardNotificationType.IdentityDocumentExpired, silent: true },
            }));

            this.store.pipe(select(selectCurrentUser), take(1)).subscribe(user => {
                if (user?.id) { this.entrustLauncher.launch(user.id).subscribe(); }   (3)
            });
        });
}
1 The terms step is skipped when the notification payload already reports airwallex_terms_accepted.
2 silent: true suppresses the per-notification success modal — the dialog has already given the supplier a single confirmation.
3 Accepting launches Entrust identity re-verification. See Entrust Identity.

UI surface

/seller-card

SellerCardPage → SellerCardListComponent, guarded by SellerCardListGuard.

/newcard

The ordering funnel.

/reports/card-transactions

Card transaction history.

widgets/seller-cards/

Dashboard card summary.

widgets/seller-cards-panel/

The unified "Your Cards" panel.

components/card-visual/, components/pcard/, components/awx-card/

Card artwork per issuer.

modals/seller-card-*

Activate, order, block, unblock, view, transition.

modals/airwallex-card-view-modal/

Reveal Airwallex card details.

modals/airwallex-pcard-terms-modal/

Provider terms acceptance.

Decline codes

NetworkResponseCodeEnum maps issuer decline codes to explanatory copy — expired card, lost/stolen pick-up, insufficient funds, limit exceeded, CVV failure, account blocked, suspected fraud, and around thirty more. See Enum Reference.

Analytics

Constant Segment event

AirwallexCardEventTypes.CARD_DETAILS_VIEWED

AirwallexCardDetailsViewed

AirwallexCardEventTypes.CARD_BLOCKED

AirwallexCardBlocked

AirwallexCardEventTypes.CARD_UNBLOCKED

AirwallexCardUnblocked

AirwallexCardEventTypes.TERMS_ACCEPTED

AirwallexTermsAccepted

CardOrderEventTypes.ORDER_STARTED

CardOrderStarted

CardOrderEventTypes.CARD_ORDERED

CardOrdered

CrossSellEventType.ClickedSellerCardSignUpOnProductOfferings

Clicked Seller Card Sign Up on Product Offerings

The application never handles a raw PAN or PIN. Card details are revealed through a short-lived provider token (getPanToken() for Airwallex) rendered inside the provider’s own secure surface.