Skip to content

Adapters

components.payment_gateway.subcomponents.authorizations.adapters.adyen

authorisation_relay

AdyenAuthorisationRelay

Bases: Subscriber

This class implements the main Adyen Authorisation Relay connector between their webhooks and our business logic.

It provides both an entry point to respond to authorisation webhooks, and a TransferNotificationRequest topic subscriber to process card transfer events on previously handled authorisation requests.

Meta

We keep the British English spelling of "authorisation" everywhere it relates to the Adyen API for consistency, however the rest of the code is PSP-agnostic and hence we use US English spelling everywhere else.

authorization_request_processing_policy instance-attribute
authorization_request_processing_policy
on_authorisation_request
on_authorisation_request(session, message)

Process an incoming Adyen authorisation request event.

This method is called when an authorization request webhook is received from Adyen. It converts the webhook payload to a PendingTransaction before calling the core policy.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/adyen/authorisation_relay.py
@obs.event_subscriber()
def on_authorisation_request(
    self,
    session: Session,
    message: AdyenAuthorisationRequest,
) -> AuthorizationResult:
    """Process an incoming Adyen authorisation request event.

    This method is called when an authorization request webhook is received
    from Adyen. It converts the webhook payload to a PendingTransaction
    before calling the core policy.
    """
    set_tag("workspace", self.authorization_request_processing_policy.workspace_key)
    logger = current_logger.bind(
        account_holder_external_id=message.accountHolder.id,
        account_external_id=message.balanceAccount.id,
        card_external_id=message.paymentInstrument.id,
        transaction_id=message.schemeUniqueTransactionId,
        merchant_name=message.merchantData.nameLocation.name,
        amount=message.amount.value,
        currency=message.amount.currency,
    )
    try:
        pending_transaction = to_pending_transaction(
            session,
            self.authorization_request_processing_policy.workspace_key,
            message,
        )
        return self.authorization_request_processing_policy.authorize_pending_transaction(
            session,
            pending_transaction,
        )

    except Exception as e:
        logger.exception(
            "Error processing authorisation request",
            exception=e,
        )
        alert_on_error_processing_adyen_authorisation_request(
            request=message,
            message="An unexpected error occurred while processing the authorisation request",
        )
        raise
receive
receive(message)

Receive card transfer events and forward them to the core policy for lifecycle management.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/adyen/authorisation_relay.py
@override
@obs.event_subscriber()
def receive(self, message: AdyenTransferNotificationRequest) -> None:
    """Receive card transfer events and forward them to the core policy
    for lifecycle management."""
    from shared.helpers.db import current_session

    logger = current_logger.bind(
        type=message.type,
        transfer_type=message.data.type,
        transfer_id=message.data.id,
        transfer_sequence_number=message.data.sequenceNumber,
    )
    try:
        match message.data.type:
            case "payment":
                self.authorization_request_processing_policy.on_payment_event(
                    current_session,
                    message.data,
                )
                current_session.commit()
            case _:
                # Ignore other types of transfers
                pass
    except Exception as e:
        current_session.rollback()
        logger.exception(
            "Error processing transfer",
            exception=e,
        )
        alert_on_error_processing_adyen_transfer_notification_request(
            notification=message,
            message="An unexpected error occurred while processing the transfer notification",
        )
register_policy
register_policy(authorization_request_processing_policy)

Register the policy to be used for processing authorisation requests.

Any authorisation request will be denied until this method is called.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/adyen/authorisation_relay.py
@obs.api_call()
def register_policy(
    self,
    authorization_request_processing_policy: AuthorizationRequestProcessingPolicy,
) -> None:
    """Register the policy to be used for processing authorisation requests.

    Any authorisation request will be denied until this method is called.
    """
    self.authorization_request_processing_policy = (
        authorization_request_processing_policy
    )

helpers

authorization_request_external_id_from_transfer_data

authorization_request_external_id_from_transfer_data(data)
Source code in components/payment_gateway/subcomponents/authorizations/adapters/adyen/helpers.py
def authorization_request_external_id_from_transfer_data(
    data: AdyenTransferData,
) -> str | None:
    if data.category != "issuedCard" or not data.categoryData:
        return None
    if not isinstance(data.categoryData, AdyenIssuedCard):
        # This can happen when deserializing from a JSON string
        data.categoryData = AdyenIssuedCard.from_dict(data.categoryData)  # type: ignore[arg-type]
    return data.categoryData.schemeUniqueTransactionId

to_pending_transaction

to_pending_transaction(session, workspace_key, data)

Convert an Adyen authorisation request to a pending transaction.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/adyen/helpers.py
def to_pending_transaction(
    session: Session,
    workspace_key: str,
    data: AdyenAuthorisationRequest,
) -> PendingTransaction:
    """Convert an Adyen authorisation request to a pending transaction."""
    with raise_if_card_not_found_for_external_id(data.paymentInstrument.id):
        card_id = CardModelBroker.get_card_id_by_external_id(
            session,
            workspace_key=workspace_key,
            external_id=data.paymentInstrument.id,
        )
    return PendingTransaction(
        workspace_key=workspace_key,
        external_id=data.schemeUniqueTransactionId,
        amount=-data.amount.value,  # Adyen sends negative amounts
        currency=CurrencyCode(data.amount.currency),
        card_id=CardId(card_id),
        merchant_info=PendingTransactionMerchantInfo(
            merchant_id=data.merchantData.merchantId.strip(),
            acquirer_id=data.merchantData.acquirerId.strip(),
            mcc=data.merchantData.mcc,
            name=data.merchantData.nameLocation.name.strip(),
            postal_code=data.merchantData.postalCode.strip()
            if data.merchantData.postalCode
            else None,
            city=data.merchantData.nameLocation.city.strip()
            if data.merchantData.nameLocation.city
            else None,
            country=data.merchantData.nameLocation.country,
        ),
    )

components.payment_gateway.subcomponents.authorizations.adapters.swan

helpers

Map a Swan Payment Control request to the engine's generic PendingTransaction.

Single boundary file — Swan vocabulary stops here. Everything downstream sees only the engine's generic types.

to_pending_transaction

to_pending_transaction(session, workspace_key, request)

Translate a Swan Payment Control payload to the engine's generic PendingTransaction.

Asserts the identity / credit-essential fields Swan marks optional but we strictly need (payment id, card id, amount, currency). The caller catches and returns AuthorizationResult.declined (fail-CLOSED).

Source code in components/payment_gateway/subcomponents/authorizations/adapters/swan/helpers.py
def to_pending_transaction(
    session: Session,
    workspace_key: str,
    request: SwanPaymentControlRequest,
) -> PendingTransaction:
    """Translate a Swan Payment Control payload to the engine's generic PendingTransaction.

    Asserts the identity / credit-essential fields Swan marks optional but we strictly
    need (payment id, card id, amount, currency). The caller catches and returns
    `AuthorizationResult.declined` (fail-CLOSED).
    """
    # Identity / credit-essential fields — refuse if any is missing.
    assert request.paymentId is not None, "missing paymentId"
    assert request.cardId is not None, "missing cardId"
    assert request.amountValue is not None, "missing amountValue"
    assert request.amountCurrency is not None, "missing amountCurrency"

    # Card lookup: Swan's cardId string → our internal CardId UUID.
    # alan_pay persists Swan virtual cards with a `virtual:1:` prefix
    # (see components/alan_pay/.../actions/card.py); Swan's webhook sends the bare id.
    external_card_id = f"virtual:1:{request.cardId}"
    with raise_if_card_not_found_for_external_id(external_card_id):
        card_id = CardModelBroker.get_card_id_by_external_id(
            session,
            workspace_key=workspace_key,
            external_id=external_card_id,
        )

    return PendingTransaction(
        workspace_key=workspace_key,
        # Use paymentId, not transactionId — Swan's later card-transfer events (captured /
        # released / rejected) carry the same paymentId, so the lifecycle release path in
        # `alan_pay/.../card_payment.py` can match this audit row by it.
        external_id=request.paymentId,
        # Swan sends amounts in major units (e.g. 50.00 = 50€); the engine works in minor units.
        amount=float_amount_to_minor_units(request.amountValue, 100),
        currency=CurrencyCode(request.amountCurrency),
        card_id=CardId(card_id),
        merchant_info=PendingTransactionMerchantInfo(
            # Decision-affecting fields use empty-string fallback rather than raising —
            # an audit row with an empty mcc and a `no_expense_category_match` decline is
            # more useful for forensics than a silent crash.
            merchant_id=(request.merchantId or "").strip(),
            acquirer_id=(request.merchantAcquirerId or "").strip(),
            mcc=request.merchantCategoryCode or "",
            name=(request.merchantName or "").strip(),
            country=request.merchantCountry or "",
            postal_code=request.merchantPostalCode.strip()
            if request.merchantPostalCode
            else None,
            city=request.merchantCity.strip() if request.merchantCity else None,
        ),
    )

payment_control

Orchestrator for Swan Payment Control — wraps the auth-relay engine with fail-CLOSED webhook semantics.

See https://docs.swan.io/developers/using-api/payment-control/ ⧉

The synchronous decision must reach Swan within 1.5 s in Live. Any exception is caught and translated into AuthorizationResult.declined (fail-CLOSED), so Swan always receives a concrete answer within the budget.

SwanPaymentControl

SwanPaymentControl()

Swan Payment Control connector — synchronous accept/decline on every card authorization.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/swan/payment_control.py
def __init__(self) -> None:
    self._policy: AuthorizationRequestProcessingPolicy | None = None
on_payment_control_request
on_payment_control_request(session, request)

Synchronously approve or decline an incoming Swan Payment Control request.

On unexpected error: logs + alerts, then re-raises. The caller (the webhook controller) is responsible for catching, rolling back the session, and returning AuthorizationResult.declined to Swan. Same fail-CLOSED outcome, cleaner separation: this method does the work and signals failure with an exception; transaction/response lifecycle is the controller's concern.

Source code in components/payment_gateway/subcomponents/authorizations/adapters/swan/payment_control.py
@obs.api_call()
def on_payment_control_request(
    self,
    session: Session,
    request: SwanPaymentControlRequest,
) -> AuthorizationResult:
    """Synchronously approve or decline an incoming Swan Payment Control request.

    On unexpected error: logs + alerts, then re-raises. The caller (the webhook
    controller) is responsible for catching, rolling back the session, and
    returning `AuthorizationResult.declined` to Swan. Same fail-CLOSED outcome,
    cleaner separation: this method does the work and signals failure with an
    exception; transaction/response lifecycle is the controller's concern.
    """
    if self._policy is None:
        current_logger.error(
            "Swan payment control received before policy registration",
            transaction_id=request.transactionId,
            payment_id=request.paymentId,
        )
        return AuthorizationResult.declined

    set_tag("workspace", self._policy.workspace_key)
    logger = current_logger.bind(
        account_external_id=request.accountId,
        card_external_id=request.cardId,
        transaction_id=request.transactionId,
        payment_id=request.paymentId,
        merchant_name=request.merchantName,
        mcc=request.merchantCategoryCode,
        amount=request.amountValue,
        currency=request.amountCurrency,
    )
    try:
        pending_transaction = to_pending_transaction(
            session,
            self._policy.workspace_key,
            request,
        )
        return self._policy.authorize_pending_transaction(
            session, pending_transaction
        )
    except Exception as e:
        logger.exception(
            "Error processing Swan payment control request",
            exception=e,
        )
        alert_on_error_processing_swan_payment_control_request(
            request=request,
            message="An unexpected error occurred while processing the Swan payment control request",
        )
        raise
register_policy
register_policy(authorization_request_processing_policy)

Register the policy that will process payment control requests.

Called once at boot from the application-specific register_payment_gateway_connectors(). Until this is called, the connector replies declined to every request (fail-CLOSED).

Source code in components/payment_gateway/subcomponents/authorizations/adapters/swan/payment_control.py
@obs.api_call()
def register_policy(
    self,
    authorization_request_processing_policy: AuthorizationRequestProcessingPolicy,
) -> None:
    """Register the policy that will process payment control requests.

    Called once at boot from the application-specific
    `register_payment_gateway_connectors()`. Until this is called, the connector
    replies `declined` to every request (fail-CLOSED).
    """
    self._policy = authorization_request_processing_policy
release_pending_transaction
release_pending_transaction(
    session, workspace_key, external_id
)

Release a previously-reserved authorization once Swan confirms the transfer.

Called from alan_pay/internal/business_logic/actions/card_payment.py when a card transaction transitions into a terminal Swan state (captured / released / rejected). No-op when no prior auth request exists (handled inside the policy).

Source code in components/payment_gateway/subcomponents/authorizations/adapters/swan/payment_control.py
@obs.api_call()
def release_pending_transaction(
    self,
    session: Session,
    workspace_key: str,
    external_id: str,
) -> None:
    """Release a previously-reserved authorization once Swan confirms the transfer.

    Called from `alan_pay/internal/business_logic/actions/card_payment.py` when a
    card transaction transitions into a terminal Swan state (captured / released /
    rejected). No-op when no prior auth request exists (handled inside the policy).
    """
    if self._policy is None:
        current_logger.error(
            "Swan release_pending_transaction called before policy registration",
            external_id=external_id,
        )
        return
    self._policy.release_pending_transaction(
        session,
        workspace_key=workspace_key,
        external_id=external_id,
    )