diff --git a/.typos.toml b/.typos.toml index 016a1f3f56..8d7880815a 100644 --- a/.typos.toml +++ b/.typos.toml @@ -24,7 +24,9 @@ caf = "caf" # part of café (URL-encoding / Unicode examples) Automatico = "Automatico" # Brazilian Portuguese — Globalgetnet's PIX_AUTOMATICO API method AUTOMATICO = "AUTOMATICO" # Brazilian Portuguese — Globalgetnet's PIX_AUTOMATICO API method UE = "UE" # Hyperswitch internal error code prefix (e.g. UE_9000) -Payed = "Payed" # Santander API returns "PAYED" as a status +Payed = "Payed" # Santander API returns "PAYED" as a status +Threed = "Threed" # Ilixium spells its 3DS-completion operation type "THREED_SECURE_COMPLETE" +THREED = "THREED" # Ilixium spells its 3DS-completion operation type "THREED_SECURE_COMPLETE" [default.extend-identifiers] ACI = "ACI" # Name of a connector diff --git a/config/development.toml b/config/development.toml index 33e7606756..37c2d7a2f5 100644 --- a/config/development.toml +++ b/config/development.toml @@ -58,6 +58,7 @@ psync = "GW_TXN_SYNC" connectors_with_webhook_source_verification_call = "paypal, truelayer" [connectors] +ilixium.base_url = "https://prprocessing.ilixium.com/platform/ili" grabpay.base_url = "https://partner-api.grab.com/grabpay/partner/v2" maya.base_url = "https://pg-sandbox.paymaya.com" boost.base_url = "https://stage-api.boostconnect.biz/gateway" diff --git a/config/production.toml b/config/production.toml index 0bbd88d995..d01a4beeb6 100644 --- a/config/production.toml +++ b/config/production.toml @@ -23,6 +23,7 @@ connector_request_timeout = 30 bypass_urls = ["localhost", "local"] [connectors] +ilixium.base_url = "https://prprocessing.ilixium.com/platform/ili" grabpay.base_url = "https://partner-api.grab.com/grabpay/partner/v2" maya.base_url = "https://pg.maya.ph" boost.base_url = "https://api.boostconnect.biz/gateway" diff --git a/config/sandbox.toml b/config/sandbox.toml index cb000a3a7b..c59bfffac5 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -23,6 +23,7 @@ connector_request_timeout = 30 bypass_urls = ["localhost", "local"] [connectors] +ilixium.base_url = "https://prprocessing.ilixium.com/platform/ili" grabpay.base_url = "https://partner-api.stg-myteksi.com/grabpay/partner/v2" maya.base_url = "https://pg-sandbox.paymaya.com" boost.base_url = "https://stage-api.boostconnect.biz/gateway" diff --git a/config/superposition.toml b/config/superposition.toml index a217782571..96c864ec30 100644 --- a/config/superposition.toml +++ b/config/superposition.toml @@ -85,7 +85,7 @@ api_tags_tags_psync = { value = "", schema = { type = "string" } } environment = { position = 1, schema = { type = "string", enum = ["sandbox", "development", "production"] } } # Connector dimension - determines which payment processor URLs to use -connector = { position = 2, schema = { type = "string", enum = ["glomopay", "stripe", "adyen", "paypal", "razorpay", "checkout", "braintree", "worldpay", "cybersource", "payme", "bluesnap", "fiserv", "trustpay", "volt", "hipay", "xendit", "phonepe", "cashfree", "payu", "paytm", "dlocal", "rapyd", "aci", "nuvei", "forte", "authorizedotnet", "elavon", "bamboraapac", "billwerk", "paysafe", "worldpayvantiv", "worldpayxml", "tsys", "tsys_transit", "bankofamerica", "powertranz", "revolut", "airwallex", "bambora", "shift4", "multisafepay", "iatapay", "jpmorgan", "nmi", "nexixpay", "authipay", "stax", "trustpayments", "fiservemea", "datatrans", "silverflow", "celero", "globalpay", "razorpayv2", "fiuu", "payload", "cashtocode", "novalnet", "nexinets", "noon", "mifinity", "calida", "cryptopay", "helcim", "placetopay", "barclaycard", "gigadat", "loonio", "mollie", "absa_sanlam", "truelayer", "trustly", "twoc_twop_paco", "zift", "revolv3", "fiservcommercehub", "finix", "wellsfargo", "imerchantsolutions", "qwikcilver", "flywire", "boost", "plaid", "grabpay", "maya"] } } +connector = { position = 2, schema = { type = "string", enum = ["glomopay", "stripe", "adyen", "paypal", "razorpay", "checkout", "braintree", "worldpay", "cybersource", "payme", "bluesnap", "fiserv", "trustpay", "volt", "hipay", "xendit", "phonepe", "cashfree", "payu", "paytm", "dlocal", "rapyd", "aci", "nuvei", "forte", "authorizedotnet", "elavon", "bamboraapac", "billwerk", "paysafe", "worldpayvantiv", "worldpayxml", "tsys", "tsys_transit", "bankofamerica", "powertranz", "revolut", "airwallex", "bambora", "shift4", "multisafepay", "iatapay", "jpmorgan", "nmi", "nexixpay", "authipay", "stax", "trustpayments", "fiservemea", "datatrans", "silverflow", "celero", "globalpay", "razorpayv2", "fiuu", "payload", "cashtocode", "novalnet", "nexinets", "noon", "mifinity", "calida", "cryptopay", "helcim", "placetopay", "barclaycard", "gigadat", "loonio", "mollie", "absa_sanlam", "truelayer", "trustly", "twoc_twop_paco", "zift", "revolv3", "fiservcommercehub", "finix", "wellsfargo", "imerchantsolutions", "qwikcilver", "flywire", "boost", "plaid", "grabpay", "maya", "ilixium"] } } # ============================================================================ # Development Environment Overrides @@ -580,6 +580,13 @@ connector_base_url = "https://api.zift.io/" _context_ = { connector = "boost" } connector_base_url = "https://stage-api.boostconnect.biz/gateway" +# Ilixium Direct API — sandbox host. Ilixium does not publish a production +# host; it is supplied per merchant at onboarding, so there is deliberately no +# production override here (see tech spec UNDECIDED #4). +[[overrides]] +_context_ = { connector = "ilixium" } +connector_base_url = "https://prprocessing.ilixium.com/platform/ili" + # ============================================================================ # Production Environment Overrides # ============================================================================ diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index 2ba867575a..6fd83e2502 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -659,7 +659,7 @@ where } (common_enums::CallConnectorAction::Trigger, TransportType::Http) => { let mut connector_request = connector - .build_request_v2(&router_data.clone()) + .build_request_v2(&router_data) .map_err(report_connector_request_to_flow)?; let mut updated_router_data = router_data.clone(); @@ -991,7 +991,7 @@ where } (common_enums::CallConnectorAction::Trigger, TransportType::Kafka) => { let kafka_record = connector - .build_kafka_record(&router_data.clone()) + .build_kafka_record(&router_data) .map_err(report_connector_request_to_flow)?; match kafka_record { diff --git a/crates/grpc-server/grpc-server/src/server/payments.rs b/crates/grpc-server/grpc-server/src/server/payments.rs index aad5e44271..058968780f 100644 --- a/crates/grpc-server/grpc-server/src/server/payments.rs +++ b/crates/grpc-server/grpc-server/src/server/payments.rs @@ -889,14 +889,25 @@ impl PaymentService for Payments { // Convert proto request to intermediate type let payload: AuthorizationRequest = proto_payload.clone().into(); - let payment_method_data_action = PaymentMethodDataAction::get_payment_method_data_action(proto_payload.payment_method.clone().ok_or(ucs_env::error::GrpcError::from(IntegrationError::MissingRequiredField { field_name: "payment_method", context: domain_types::errors::IntegrationErrorContext::default() }))?) - .map_err(|err| { - tracing::error!("PAYMENT_AUTHORIZE_FLOW: failed to get payment method data action - error: {:?}", err); - ucs_env::error::GrpcError::from(IntegrationError::InvalidDataFormat { field_name: "payment_method", context: domain_types::errors::IntegrationErrorContext::default() }) - })?; + // `payment_method` is required for an ordinary authorization, but not for the + // second leg of a redirect-based one: connectors that finalise 3DS through + // Authorize are re-invoked after the ACS posts back to `TermUrl`, and the + // caller no longer holds the raw instrument at that point. That leg is + // identified by `redirection_response`, exactly as the connectors themselves + // identify it (`request.redirect_response.is_some()`), and it is the only case + // in which an absent `payment_method` is accepted. + let payment_method_data_action = match proto_payload.payment_method.clone() { + Some(payment_method) => Some(PaymentMethodDataAction::get_payment_method_data_action(payment_method) + .map_err(|err| { + tracing::error!("PAYMENT_AUTHORIZE_FLOW: failed to get payment method data action - error: {:?}", err); + ucs_env::error::GrpcError::from(IntegrationError::InvalidDataFormat { field_name: "payment_method", context: domain_types::errors::IntegrationErrorContext::default() }) + })?), + None if proto_payload.redirection_response.is_some() => None, + None => Err(ucs_env::error::GrpcError::from(IntegrationError::MissingRequiredField { field_name: "payment_method", context: domain_types::errors::IntegrationErrorContext::default() }))?, + }; let authorize_response = match payment_method_data_action { - PaymentMethodDataAction::CardProxy(proxy_card_details) => { + Some(PaymentMethodDataAction::CardProxy(proxy_card_details)) => { let token_data = proxy_card_details.to_token_data(); let payment_method_data = payment_method_data::PaymentMethodData::Card(payment_method_data::Card::::foreign_try_from(proxy_card_details).map_err(|err| { tracing::error!("PAYMENT_AUTHORIZE_FLOW: failed to get payment method data action - error: {:?}", err); @@ -917,7 +928,7 @@ impl PaymentService for Payments { )) .await? } - PaymentMethodDataAction::Card(card_details) => { + Some(PaymentMethodDataAction::Card(card_details)) => { tracing::info!("REGULAR: Processing regular payment authorization (no injector)"); let payment_method_data = payment_method_data::PaymentMethodData::Card(payment_method_data::Card::::foreign_try_from(card_details).map_err(|err| { tracing::error!("PAYMENT_AUTHORIZE_FLOW: failed to get payment method data action - error: {:?}", err); @@ -938,7 +949,7 @@ impl PaymentService for Payments { )) .await? } - PaymentMethodDataAction::Default => { + Some(PaymentMethodDataAction::Default) => { let payment_method_data = payment_method_data::PaymentMethodData::convert_to_domain_model_for_non_card_payment_methods(proto_payload.payment_method.clone().ok_or(ucs_env::error::GrpcError::from(IntegrationError::MissingRequiredField { field_name: "payment_method", context: domain_types::errors::IntegrationErrorContext::default() }))?) .map_err(|err| { tracing::error!("Failed to convert payment method data: {:?}", err); @@ -959,7 +970,7 @@ impl PaymentService for Payments { )) .await? } - PaymentMethodDataAction::CardWithNoCvc(card_details) => { + Some(PaymentMethodDataAction::CardWithNoCvc(card_details)) => { tracing::info!("REGULAR: Processing payment authorization with CardWithNoCvc"); let payment_method_data = payment_method_data::PaymentMethodData::CardWithNoCvc( payment_method_data::CardWithNoCvc::foreign_try_from(card_details).map_err(|err| { @@ -981,6 +992,27 @@ impl PaymentService for Payments { )) .await? } + // Redirect-return leg: no instrument was sent (guarded above by + // `redirection_response` being present). The connector finalises from the + // redirect payload and its own transaction reference; connectors that + // cannot do so reject `NoInstrumentAfterRedirect` rather than mistaking it + // for a payable instrument. + None => { + tracing::info!("REDIRECT_COMPLETION: authorization re-entered after redirect without payment method data"); + Box::pin(self.process_authorization_internal::( + &config, + payload, + metadata_payload.connector.clone(), + metadata_payload.connector_config.clone(), + metadata, + &metadata_payload, + &service_name, + &metadata_payload.request_id, + None, + payment_method_data::PaymentMethodData::NoInstrumentAfterRedirect, + )) + .await? + } }; Ok(tonic::Response::new(authorize_response)) diff --git a/crates/integrations/connector-integration/src/connectors.rs b/crates/integrations/connector-integration/src/connectors.rs index a9d41561d4..9d6ccf57da 100644 --- a/crates/integrations/connector-integration/src/connectors.rs +++ b/crates/integrations/connector-integration/src/connectors.rs @@ -302,3 +302,6 @@ pub use self::tesouro::Tesouro; pub mod boost; pub use self::boost::Boost; + +pub mod ilixium; +pub use self::ilixium::Ilixium; diff --git a/crates/integrations/connector-integration/src/connectors/aci/transformers.rs b/crates/integrations/connector-integration/src/connectors/aci/transformers.rs index 89558df8e7..30750bd032 100644 --- a/crates/integrations/connector-integration/src/connectors/aci/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/aci/transformers.rs @@ -762,7 +762,8 @@ impl | PaymentMethodData::OpenBanking(_) | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Aci"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/adyen/transformers.rs b/crates/integrations/connector-integration/src/connectors/adyen/transformers.rs index 645a3442fd..d85eb95ed4 100644 --- a/crates/integrations/connector-integration/src/connectors/adyen/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/adyen/transformers.rs @@ -3905,11 +3905,11 @@ impl Err(IntegrationError::NotImplemented( - ("payment method").into(), - Default::default(), - ) - .into()), + | PaymentMethodData::MobilePayment(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err( + IntegrationError::NotImplemented(("payment method").into(), Default::default()) + .into(), + ), }, } } @@ -6525,7 +6525,8 @@ impl Err( + | PaymentMethodData::PaymentMethodToken(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err( IntegrationError::NotImplemented(("payment method").into(), Default::default()) .into(), ), diff --git a/crates/integrations/connector-integration/src/connectors/bambora/transformers.rs b/crates/integrations/connector-integration/src/connectors/bambora/transformers.rs index 26e085b176..c8cb8d7a27 100644 --- a/crates/integrations/connector-integration/src/connectors/bambora/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/bambora/transformers.rs @@ -298,7 +298,8 @@ impl | PaymentMethodData::MobilePayment(_) | PaymentMethodData::OpenBanking(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { return Err(IntegrationError::NotSupported { message: "Selected payment method".to_string(), connector: "bambora", diff --git a/crates/integrations/connector-integration/src/connectors/bankofamerica/transformers.rs b/crates/integrations/connector-integration/src/connectors/bankofamerica/transformers.rs index 8a2770a97f..57bdb672f4 100644 --- a/crates/integrations/connector-integration/src/connectors/bankofamerica/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/bankofamerica/transformers.rs @@ -767,7 +767,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( domain_types::utils::get_unimplemented_payment_method_error_message( "Bank of America", @@ -2061,7 +2062,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("BankOfAmerica"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/barclaycard/transformers.rs b/crates/integrations/connector-integration/src/connectors/barclaycard/transformers.rs index b62f0e93fd..996172acce 100644 --- a/crates/integrations/connector-integration/src/connectors/barclaycard/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/barclaycard/transformers.rs @@ -157,6 +157,7 @@ fn payment_method_data_label( PaymentMethodData::NetworkToken(_) => "network_token", PaymentMethodData::CardWithNoCvc(_) => "card_with_no_cvc", PaymentMethodData::MobilePayment(_) => "mobile_payment", + PaymentMethodData::NoInstrumentAfterRedirect => "no_instrument_after_redirect", } } diff --git a/crates/integrations/connector-integration/src/connectors/billwerk/transformers.rs b/crates/integrations/connector-integration/src/connectors/billwerk/transformers.rs index a38bad30fc..81e71e14a0 100644 --- a/crates/integrations/connector-integration/src/connectors/billwerk/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/billwerk/transformers.rs @@ -240,7 +240,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("billwerk"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/braintree/transformers.rs b/crates/integrations/connector-integration/src/connectors/braintree/transformers.rs index 9725d6c726..3d426e7c5e 100644 --- a/crates/integrations/connector-integration/src/connectors/braintree/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/braintree/transformers.rs @@ -633,7 +633,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("braintree"), connector: "Braintree", @@ -1647,7 +1648,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("braintree"), connector: "Braintree", @@ -2684,7 +2686,8 @@ fn get_braintree_redirect_form< | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { return Err(ConnectorError::unexpected_response_error_http_status_unknown().into()); } }, @@ -3014,7 +3017,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("braintree"), connector: "Braintree", @@ -3375,7 +3379,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("braintree"), connector: "Braintree", diff --git a/crates/integrations/connector-integration/src/connectors/cryptopay/transformers.rs b/crates/integrations/connector-integration/src/connectors/cryptopay/transformers.rs index a675f1f1f3..4a3e2c177a 100644 --- a/crates/integrations/connector-integration/src/connectors/cryptopay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/cryptopay/transformers.rs @@ -111,7 +111,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: get_unimplemented_payment_method_error_message("CryptoPay"), connector: "Cryptopay", diff --git a/crates/integrations/connector-integration/src/connectors/cybersource/transformers.rs b/crates/integrations/connector-integration/src/connectors/cybersource/transformers.rs index 1fb4ae5e85..d4f99fc76c 100644 --- a/crates/integrations/connector-integration/src/connectors/cybersource/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/cybersource/transformers.rs @@ -347,7 +347,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: domain_types::utils::get_unimplemented_payment_method_error_message( @@ -2604,7 +2605,8 @@ impl { + | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: domain_types::utils::get_unimplemented_payment_method_error_message( "Cybersource", @@ -2710,7 +2712,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Cybersource"), connector: "Cybersource", @@ -3747,7 +3750,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Cybersource"), Default::default(), @@ -4036,7 +4040,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Cybersource"), Default::default(), @@ -5138,7 +5143,8 @@ impl { + | PaymentMethodData::PaymentMethodToken(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Cybersource"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/datatrans/transformers.rs b/crates/integrations/connector-integration/src/connectors/datatrans/transformers.rs index 17b9884d94..6e99b96ff8 100644 --- a/crates/integrations/connector-integration/src/connectors/datatrans/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/datatrans/transformers.rs @@ -789,7 +789,8 @@ impl { + | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( UNSUPPORTED_PAYMENT_METHOD_ERROR.to_string(), datatrans_context( diff --git a/crates/integrations/connector-integration/src/connectors/dlocal/transformers.rs b/crates/integrations/connector-integration/src/connectors/dlocal/transformers.rs index c1dde111c4..3561f341ad 100644 --- a/crates/integrations/connector-integration/src/connectors/dlocal/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/dlocal/transformers.rs @@ -454,7 +454,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( crate::utils::get_unimplemented_payment_method_error_message("Dlocal"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/fiserv/transformers.rs b/crates/integrations/connector-integration/src/connectors/fiserv/transformers.rs index ed495e3e37..70f9c9c043 100644 --- a/crates/integrations/connector-integration/src/connectors/fiserv/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/fiserv/transformers.rs @@ -549,7 +549,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("fiserv"), Default::default() diff --git a/crates/integrations/connector-integration/src/connectors/fiuu/transformers.rs b/crates/integrations/connector-integration/src/connectors/fiuu/transformers.rs index 920c91e24a..c5113688c3 100644 --- a/crates/integrations/connector-integration/src/connectors/fiuu/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/fiuu/transformers.rs @@ -691,7 +691,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("fiuu"), Default::default(), @@ -1062,7 +1063,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("fiuu"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/forte/transformers.rs b/crates/integrations/connector-integration/src/connectors/forte/transformers.rs index 809fadda20..b39f3d645e 100644 --- a/crates/integrations/connector-integration/src/connectors/forte/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/forte/transformers.rs @@ -352,7 +352,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented(utils::get_unimplemented_payment_method_error_message("Forte") , Default::default()))? } } diff --git a/crates/integrations/connector-integration/src/connectors/givepayments/transformers.rs b/crates/integrations/connector-integration/src/connectors/givepayments/transformers.rs index 7763055f40..e085dabb21 100644 --- a/crates/integrations/connector-integration/src/connectors/givepayments/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/givepayments/transformers.rs @@ -447,7 +447,8 @@ impl { + | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(errors::IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Givepayments"), errors::IntegrationErrorContext { diff --git a/crates/integrations/connector-integration/src/connectors/hipay/transformers.rs b/crates/integrations/connector-integration/src/connectors/hipay/transformers.rs index 2f48a29afb..0fa280dd8c 100644 --- a/crates/integrations/connector-integration/src/connectors/hipay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/hipay/transformers.rs @@ -598,7 +598,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( "Payment method not supported for tokenization".to_string(), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/ilixium.rs b/crates/integrations/connector-integration/src/connectors/ilixium.rs new file mode 100644 index 0000000000..844e36b96a --- /dev/null +++ b/crates/integrations/connector-integration/src/connectors/ilixium.rs @@ -0,0 +1,858 @@ +//! Ilixium — Direct API (card, one-time payments). +//! +//! Scope: the **PreAuthenticate**, **Authorize**, **Capture**, **Void**, **PSync**, **Refund** +//! and **RSync** flows, for **Card** payment methods. +//! +//! Ilixium has exactly **one** authorisation endpoint, and it performs 3-D Secure inside it +//! rather than through standalone authentication endpoints. A card payment is therefore one or +//! two round trips depending on whether the issuer challenges: +//! +//! * `POST /direct/auth` — the authorisation. Runs as **PreAuthenticate** when the payment is +//! 3DS (see the flow block below) and as **Authorize** when it is not. On a 3DS-enabled +//! account it *may* answer `status.code = PENDING` with the ACS redirect data instead of a +//! final result — but it may equally answer `SUCCESS`, because Ilixium decides at response +//! time whether a challenge is needed. A not-enrolled or frictionless card is charged by this +//! single call and has no second leg. +//! * `POST /direct/threedcomplete` — the finalisation, sent as **Authorize** when UCS re-invokes +//! the flow after the ACS posts `md`/`paRes` back to `TermUrl`. Selected by +//! [`transformers::is_three_ds_completion`], which is also what the request-body builder +//! uses, so the URL and the body cannot disagree about which leg is being sent. +//! +//! This is why `/direct/threedcomplete` is modelled as `Authorize` and not as +//! `PostAuthenticate`: it is what moves the money (the vendor is explicit that "no customer +//! funds have been ring-fenced prior to this stage"), and the authentication flows are defined +//! to produce authentication data for a *following* Authorize to spend. Modelling it as +//! `PostAuthenticate` would leave an Authorize still to run, re-sending `/direct/auth` and +//! earning response code 102, "Duplicate Merchant Ref". +//! +//! Capture and Void are each a single round trip: +//! +//! * `POST /direct/capture` — completes a deferred-capture authorisation. It is bound to the +//! original payment by `transaction.merchantRef` (the authorisation's own reference), not by +//! a gateway id, so the reference is recomputed from the same UCS value both legs used. +//! * `POST /direct/reversal` — Ilixium's name for a void. A separate endpoint, *not* a capture +//! for zero, with a body field-for-field identical to the capture body and the same +//! `merchantRef` binding. Partial reversals are not supported, so the amount must be the +//! original transaction's in full (see `transformers::IlixiumVoidRequest`). +//! +//! Refund is likewise a single round trip, and the first flow here built on `RefundFlowData`: +//! +//! * `POST /direct/refund` — variant A only (a refund that references a previous transaction), +//! bound to the payment by the same `transaction.merchantRef`. Partial refunds are supported +//! and accumulate; the payment must already be captured. `/direct/credit` is a payout, not a +//! refund, and is never called. Ilixium returns no refund identifier of any kind — see +//! `transformers::refund_identifier`. +//! +//! PSync and RSync are the outliers, and they are the *same* call: +//! +//! * `POST /history/operations` — note the path is **not** under `/direct/`. Ilixium publishes no +//! per-payment status endpoint and no refund-status endpoint at all, so a sync is a **bulk, +//! time-windowed reconciliation report** (max 24 hours, no server-side filter of any kind) that +//! the connector then filters client-side. It is a POST with a body, unlike the GET-based +//! PSync/RSync most connectors have. See `transformers::IlixiumHistoryRequest`. +//! +//! PSync keeps the entries whose `type` is a payment-lifecycle operation; RSync keeps those +//! whose `type` is `REFUND` — never `CREDIT`, which is a `/direct/credit` payout. Both filter on +//! the **original payment's** `transaction.merchantRef`, which on the refund side has to be +//! resolved out of `RefundSyncData` rather than out of the flow's common data. Refund entries +//! echo the payment's `merchantRef` *and* `gatewayRef`, so distinguishing one partial refund +//! from another is a known, documented limitation — see +//! `transformers::IlixiumHistoryResponse::match_refund_operation`. +//! +//! Two things about this API drive the whole design: +//! +//! 1. **Every** business failure — validation rejection, decline, 3DS-required, internal +//! error — comes back as **HTTP 200** with the outcome in `status.code`. Nothing here +//! branches on the HTTP status. +//! 2. Authentication is a body-derived digest (`X-MERCHANT-DIGEST`), not a static key, so it +//! is computed in `build_headers` from the exact serialised body rather than in +//! `ConnectorCommon::get_auth_header`. +//! +//! Reference: `grace/rulesbook/codegen/references/ilixium/technical_specification.md` + +pub mod transformers; + +use std::fmt::Debug; + +use common_enums::CurrencyUnit; +use common_utils::{ + errors::CustomResult, events, ext_traits::ByteSliceExt, request::Method, types::StringMinorUnit, +}; +use domain_types::{ + connector_flow::{Authorize, Capture, PSync, PreAuthenticate, RSync, Refund, Void}, + connector_types::*, + errors, + payment_method_data::PaymentMethodDataTypes, + router_data::{ConnectorSpecificConfig, ErrorResponse}, + router_data_v2::RouterDataV2, + router_response_types::Response, + types::Connectors, +}; +use error_stack::ResultExt; +use hyperswitch_masking::{Mask, Maskable, PeekInterface}; +use interfaces::{ + api::ConnectorCommon, connector_integration_v2::ConnectorIntegrationV2, connector_types, + decode::BodyDecoding, +}; +use serde::Serialize; +use transformers::{ + self as ilixium, IlixiumAuthorizeRequest, IlixiumCaptureRequest, IlixiumCaptureResponse, + IlixiumHistoryRequest, IlixiumHistoryResponse, IlixiumPaymentResponse, + IlixiumPreAuthenticateRequest, IlixiumPreAuthenticateResponse, IlixiumRefundHistoryRequest, + IlixiumRefundHistoryResponse, IlixiumRefundRequest, IlixiumRefundResponse, IlixiumVoidRequest, + IlixiumVoidResponse, +}; + +use super::macros; +use crate::{types::ResponseRouterData, with_error_response_body}; + +pub(crate) mod headers { + pub(crate) const X_MERCHANT_DIGEST: &str = "X-MERCHANT-DIGEST"; + pub(crate) const CONTENT_TYPE: &str = "Content-Type"; + pub(crate) const ACCEPT: &str = "Accept"; +} + +/// The Direct API defaults to `text/xml`, so both `Content-Type` and `Accept` must be sent +/// explicitly for a JSON integration. +const ILIXIUM_JSON_MEDIA_TYPE: &str = "application/json"; + +const AUTH_ENDPOINT: &str = "/direct/auth"; +const THREE_DS_COMPLETE_ENDPOINT: &str = "/direct/threedcomplete"; +const CAPTURE_ENDPOINT: &str = "/direct/capture"; +/// Ilixium calls a void a *reversal*; there is no `void`/`cancel` endpoint on the Direct API. +const VOID_ENDPOINT: &str = "/direct/reversal"; +/// The refund endpoint. **Not** `/direct/credit`: that is a money-out *payout* to a payment +/// method (`creditRequest`, response `type: CREDIT`, its own error codes 126 and 205–208) which +/// happens to have an almost identical body, and it must never be used for a refund. +const REFUND_ENDPOINT: &str = "/direct/refund"; +/// The operation-history report, and the **only** query the Direct API offers — there is no +/// per-payment status endpoint (see the module docs). Note the path is `/history/…`, *not* under +/// `/direct/` like every other endpoint above. +const HISTORY_OPERATIONS_ENDPOINT: &str = "/history/operations"; + +// ============================================================================= +// CONNECTOR COMMON IMPLEMENTATION +// ============================================================================= +impl ConnectorCommon + for Ilixium +{ + fn id(&self) -> &'static str { + "ilixium" + } + + fn get_currency_unit(&self) -> CurrencyUnit { + CurrencyUnit::Minor + } + + fn common_get_content_type(&self) -> &'static str { + ILIXIUM_JSON_MEDIA_TYPE + } + + fn base_url<'a>(&self, connectors: &'a Connectors) -> &'a str { + connectors.ilixium.base_url.as_ref() + } + + // NOTE: Ilixium has no static auth header. Every request is authenticated with + // `X-MERCHANT-DIGEST`, a two-round SHA-512/Base64 digest over the *exact* request body + // salted with the merchant's Digest Calculation Password — a value this trait method + // cannot compute, because it never sees the body. The real header is built in + // `build_headers` below, which every flow's `get_headers` delegates to. Merchant identity + // travels in the body (`merchant.merchantId` / `merchant.accountId`), not in a header. + // + // This impl exists only to satisfy the trait. It still resolves the auth type so a + // misconfigured merchant account fails loudly here rather than silently sending an + // unauthenticated request, and it deliberately returns no headers. + fn get_auth_header( + &self, + auth_type: &ConnectorSpecificConfig, + ) -> CustomResult)>, errors::IntegrationError> { + ilixium::IlixiumAuthType::try_from(auth_type)?; + Ok(Vec::new()) + } + + // Reached only for genuine transport/infrastructure failures: Ilixium answers HTTP 200 + // for every documented business outcome, so declines and validation rejections are + // handled by the Authorize response mapping, not here. A non-2xx body is therefore most + // likely an intermediary (proxy/WAF/gateway) response, which may not be JSON at all. + fn build_error_response( + &self, + res: Response, + event_builder: Option<&mut events::Event>, + _connector_config: &ConnectorSpecificConfig, + ) -> CustomResult { + match res + .response + .parse_struct::("IlixiumPaymentResponse") + { + Ok(response) => { + with_error_response_body!(event_builder, response); + + let reason_codes = response.reason_codes(); + Ok(ErrorResponse { + status_code: res.status_code, + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", response.status.code).to_uppercase()), + message: response + .status + .message + .clone() + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()), + reason: if reason_codes.is_empty() { + response.status.message.clone() + } else { + Some(reason_codes.join(", ")) + }, + attempt_status: None, + connector_transaction_id: response.gateway_ref(), + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + }) + } + Err(_) => { + let raw_body = String::from_utf8_lossy(&res.response).to_string(); + tracing::warn!( + status_code = res.status_code, + body = %raw_body, + "Ilixium returned a body that is not a paymentResponse envelope — the \ + Direct API answers HTTP 200 with a paymentResponse for every documented \ + outcome, so this is most likely an intermediary proxy/WAF/gateway response" + ); + Ok(ErrorResponse { + status_code: res.status_code, + code: common_utils::consts::NO_ERROR_CODE.to_string(), + message: common_utils::consts::NO_ERROR_MESSAGE.to_string(), + reason: Some(raw_body.chars().take(500).collect()), + attempt_status: None, + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + }) + } + } + } +} + +// ============================================================================= +// BODY DECODING IMPLEMENTATION +// ============================================================================= +impl BodyDecoding + for Ilixium +{ +} + +// ============================================================================= +// AMOUNT CONVERTER +// ============================================================================= +// `transaction.amount` is the minor-unit value, digits only, and the schema types it as a +// JSON string (`^[\d]{1,12}$`). +macros::create_amount_converter_wrapper!(connector_name: Ilixium, amount_type: StringMinorUnit); + +// ============================================================================= +// PREREQUISITES: struct, flow bridges, shared digest/header helpers +// ============================================================================= +macros::create_all_prerequisites!( + connector_name: Ilixium, + generic_type: T, + api: [ + ( + flow: Authorize, + request_body: IlixiumAuthorizeRequest, + response_body: IlixiumPaymentResponse, + router_data: RouterDataV2, PaymentsResponseData>, + ), + ( + + flow: PreAuthenticate, + request_body: IlixiumPreAuthenticateRequest, + response_body: IlixiumPreAuthenticateResponse, + router_data: RouterDataV2, PaymentsResponseData>, + ), + ( + flow: Capture, + request_body: IlixiumCaptureRequest, + response_body: IlixiumCaptureResponse, + router_data: RouterDataV2, + ), + ( + flow: Void, + request_body: IlixiumVoidRequest, + response_body: IlixiumVoidResponse, + router_data: RouterDataV2, + ), + ( + flow: PSync, + request_body: IlixiumHistoryRequest, + response_body: IlixiumHistoryResponse, + router_data: RouterDataV2, + ), + ( + flow: Refund, + request_body: IlixiumRefundRequest, + response_body: IlixiumRefundResponse, + router_data: RouterDataV2, + ), + ( + flow: RSync, + // The same `/history/operations` message PSync sends and parses, under its own idents: + // `create_all_prerequisites!` mints one `Templating` marker struct per named + // request/response ident, so naming IlixiumHistoryRequest/Response on a second flow + // would define those markers twice. See `transformers::IlixiumRefundHistoryRequest`. + request_body: IlixiumRefundHistoryRequest, + response_body: IlixiumRefundHistoryResponse, + router_data: RouterDataV2, + ) + ], + amount_converters: [ + amount_converter: StringMinorUnit + ], + member_functions: { + /// Builds the three Ilixium request headers. + /// + /// `X-MERCHANT-DIGEST` must be computed over the **exact** bytes that will be + /// transmitted, so the body is taken from `get_request_body` and digested verbatim. + /// That is the same value the framework puts on the wire: `RequestContent:: + /// get_body_bytes` serialises through `get_inner_value`, which is what + /// `content.content.get_inner_value()` returns here, so the digest can never drift + /// from the payload. + /// + /// Note the `.content` hop: `ConnectorRequestData` carries both the wire body + /// (`content`) and a *masked* typed copy kept for observability (`typed_request`). + /// The digest must be taken over the former — digesting the masked copy would sign + /// redacted JSON and every request would be rejected by the gateway. + /// + /// Generic over the flow-common-data type (`FCD`) rather than pinned to + /// `PaymentFlowData`: the digest is computed from the serialised body alone, so the + /// same code serves the payment flows and the refund flows (`RefundFlowData`) without + /// duplication. + pub fn build_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> + where + Self: ConnectorIntegrationV2, + { + let method = self.get_http_method(); + let body = match method { + // Every in-scope Ilixium endpoint is a POST; the digest covers the body only + // (no headers, no URL, no timestamp, no nonce), so a bodyless method digests + // the empty string. + Method::Get => String::default(), + Method::Post | Method::Put | Method::Delete | Method::Patch => self + .get_request_body(req)? + .map(|content| content.content.get_inner_value().peek().to_owned()) + .unwrap_or_default(), + }; + + let auth = ilixium::IlixiumAuthType::try_from(&req.connector_config)?; + let digest = auth.compute_merchant_digest(&body).change_context( + errors::IntegrationError::RequestEncodingFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: Some( + "https://docs.ilixium.com/docs/direct/digest".to_string(), + ), + additional_context: Some( + "Failed to compute the X-MERCHANT-DIGEST authentication header \ + for an Ilixium payment request. An incorrect digest is rejected \ + by the platform with response code 101, 'Invalid Merchant \ + Credentials'." + .to_string(), + ), + }, + }, + )?; + + Ok(vec![ + ( + headers::CONTENT_TYPE.to_string(), + self.get_content_type().to_string().into(), + ), + ( + headers::ACCEPT.to_string(), + ILIXIUM_JSON_MEDIA_TYPE.to_string().into(), + ), + (headers::X_MERCHANT_DIGEST.to_string(), digest.into_masked()), + ]) + } + + pub fn connector_base_url_payments<'a, F, Req, Res>( + &self, + req: &'a RouterDataV2, + ) -> &'a str { + &req.resource_common_data.connectors.ilixium.base_url + } + + /// Refund-side counterpart of [`Self::connector_base_url_payments`]. Ilixium serves + /// every Direct API operation from one host, so both resolve to the same value; the + /// split exists only because `RefundFlowData` is a different common-data type. + pub fn connector_base_url_refunds<'a, F, Req, Res>( + &self, + req: &'a RouterDataV2, + ) -> &'a str { + &req.resource_common_data.connectors.ilixium.base_url + } + } +); + +// ============================================================================= +// AUTHORIZE — POST /direct/auth, and POST /direct/threedcomplete on the 3DS return leg +// ============================================================================= +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumAuthorizeRequest), + curl_response: IlixiumPaymentResponse, + flow_name: Authorize, + resource_common_data: PaymentFlowData, + flow_request: PaymentsAuthorizeData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult { + // Same gate as the request-body builder in transformers.rs, so the endpoint and + // the payload always describe the same leg of the 3DS flow. + let endpoint = if ilixium::is_three_ds_completion(&req.request) { + THREE_DS_COMPLETE_ENDPOINT + } else { + AUTH_ENDPOINT + }; + Ok(format!("{}{}", self.connector_base_url_payments(req), endpoint)) + } + } +); + +// ============================================================================= +// PRE-AUTHENTICATE — POST /direct/auth (the 3DS first leg) +// ============================================================================= +// Same endpoint and same body as the Authorize first leg — Ilixium has exactly one authorisation +// call. The split exists so the flow name matches what the call does: this leg initiates the +// payment and may come back with an ACS challenge, and the follow-up `/direct/threedcomplete` +// then runs as `Authorize`. +// +// The thing to understand here is that this leg is **terminal in two of its three outcomes**. +// Ilixium decides at response time whether a challenge is needed, so `/direct/auth` answers: +// +// * `SUCCESS` when the account is not 3DS-enabled or the card is not enrolled / authenticates +// frictionlessly — the payment is charged and there is no second leg; +// * `PENDING` + ACS data when a challenge is required — the only case with a second leg; +// * `DECLINED` / `REJECTED` on failure. +// +// A second `/direct/auth` after a terminal outcome would be rejected with response code 102, +// "Duplicate Merchant Ref" (or double-charge if the reference differed). Nothing here guards +// against that because nothing has to: HS's `should_continue` after PreAuthenticate defaults to +// `false`, so it finalises the attempt from this response and never runs the follow-up Authorize. +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumPreAuthenticateRequest), + curl_response: IlixiumPreAuthenticateResponse, + flow_name: PreAuthenticate, + resource_common_data: PaymentFlowData, + flow_request: PaymentsPreAuthenticateData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_payments(req), AUTH_ENDPOINT)) + } + } +); + +// ============================================================================= +// CAPTURE — POST /direct/capture +// ============================================================================= +// Completes a deferred-capture authorisation (one sent with `deferredCapture: true`, whose +// response `type` was `AUTH`). The capture is bound to the original payment by +// `transaction.merchantRef` — the *authorisation's* reference, not a gateway id — which is +// why the body carries no transaction identifier of its own and the URL is a bare endpoint. +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumCaptureRequest), + curl_response: IlixiumCaptureResponse, + flow_name: Capture, + resource_common_data: PaymentFlowData, + flow_request: PaymentsCaptureData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_payments(req), CAPTURE_ENDPOINT)) + } + } +); + +// ============================================================================= +// VOID (REVERSAL) — POST /direct/reversal +// ============================================================================= +// Ilixium's void. A *separate* endpoint — not a capture for zero — whose body is +// field-for-field identical to the capture body, and which is likewise bound to the payment by +// `transaction.merchantRef` (the original authorisation's reference) rather than a gateway id, +// so the URL is a bare endpoint. Partial reversals are rejected with response code 142, which +// is why `IlixiumVoidRequest` refuses to build a body without a caller-supplied amount. +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumVoidRequest), + curl_response: IlixiumVoidResponse, + flow_name: Void, + resource_common_data: PaymentFlowData, + flow_request: PaymentVoidData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_payments(req), VOID_ENDPOINT)) + } + } +); + +// ============================================================================= +// PSYNC — POST /history/operations +// ============================================================================= +// Not a status lookup: Ilixium publishes none. This posts a `<= 24h` window and receives every +// operation the merchant account recorded in it, which `IlixiumHistoryResponse:: +// latest_payment_operation` then filters down to this payment by `transaction.merchantRef` — +// the request itself has nowhere to put a payment identifier (its four properties are the two +// period bounds, `merchant` and `reportFormat`). +// +// Three consequences worth flagging at the call site: +// +// * it is a **POST with a body**, unlike the GET-based PSync most connectors have, so +// `curl_request` is present and `build_headers` digests the body as usual; +// * the URL is *not* under `/direct/`; +// * the response envelope is a genuinely different schema from `paymentResponse`, which is why +// `IlixiumHistoryResponse` is a new type rather than an alias. +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumHistoryRequest), + curl_response: IlixiumHistoryResponse, + flow_name: PSync, + resource_common_data: PaymentFlowData, + flow_request: PaymentsSyncData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_payments(req), HISTORY_OPERATIONS_ENDPOINT)) + } + } +); + +// ============================================================================= +// REFUND — POST /direct/refund +// ============================================================================= +// The **first** flow on this connector built on `RefundFlowData` rather than `PaymentFlowData`, +// which is what forces the two differences from Capture/Void that are not visible in the body +// shape (identical to theirs — `version`, `transaction`, `merchant`): +// +// * the URL is built with `connector_base_url_refunds`, the `RefundFlowData` counterpart of the +// payments helper — one host serves everything, but the common-data type differs; +// * the original payment's `merchantRef` cannot come from +// `resource_common_data.connector_request_reference_id` the way Capture and Void source +// theirs, because on this flow UCS populates that field from `merchant_refund_id` — the +// *refund's* reference. See `transformers::resolve_original_merchant_ref`. +// +// Only variant A (a refund referencing a previous transaction) is implemented; the standalone +// variant is account-gated. The payment must already be captured — an uncaptured authorisation +// yields response code 111 and must be reversed via Void instead. Partial refunds are supported +// and accumulate up to the original amount (code 125 once the *total* would exceed it, 124 once +// it is fully refunded). +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumRefundRequest), + curl_response: IlixiumRefundResponse, + flow_name: Refund, + resource_common_data: RefundFlowData, + flow_request: RefundsData, + flow_response: RefundsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> { + // `build_headers` is generic over the flow-common-data type precisely so the refund + // flows can reuse it: the X-MERCHANT-DIGEST is computed from the serialised body + // alone and knows nothing about which flow produced it. + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_refunds(req), REFUND_ENDPOINT)) + } + } +); + +// ============================================================================= +// RSYNC — POST /history/operations +// ============================================================================= +// **The same endpoint, the same request body and the same response envelope as PSync**, because +// Ilixium publishes no refund-status endpoint at all and no lookup by refund id: the bulk, +// time-windowed report is the only query the Direct API offers. Only the client-side filter over +// `operation[]` differs — RSync selects entries whose `transaction.merchantRef` is the **original +// payment's** and whose `type` is `REFUND`, never `CREDIT` (a /direct/credit payout is not a +// refund). See `transformers::IlixiumHistoryResponse::match_refund_operation`. +// +// Two things distinguish this block from the PSync one: +// +// * it is built on `RefundFlowData`, so the URL uses `connector_base_url_refunds`; +// * the original payment's `merchantRef` is resolved from `RefundSyncData` — never from +// `RefundFlowData::connector_request_reference_id`, which UCS populates from +// `merchant_refund_id` and which therefore identifies the *refund*. Exactly the trap the Refund +// flow had to avoid; both go through `transformers::resolve_original_merchant_ref`. +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Ilixium, + curl_request: Json(IlixiumRefundHistoryRequest), + curl_response: IlixiumRefundHistoryResponse, + flow_name: RSync, + resource_common_data: RefundFlowData, + flow_request: RefundSyncData, + flow_response: RefundsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::IntegrationError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}{}", self.connector_base_url_refunds(req), HISTORY_OPERATIONS_ENDPOINT)) + } + } +); + +// ============================================================================= +// TRAIT REGISTRATION +// ============================================================================= +// ===== CONNECTOR SERVICE TRAIT IMPLEMENTATION ===== +// Aggregate trait - composes all other connector traits. +impl + connector_types::ConnectorServiceTrait for Ilixium +{ +} + +// ===== FLOW MARKER TRAIT IMPLEMENTATIONS ===== +// Required by ConnectorServiceTrait's supertrait bounds; not auto-generated by +// create_all_prerequisites! itself. +impl + connector_types::PaymentAuthorizeV2 for Ilixium +{ +} + +// `PaymentPreAuthenticateV2` is generic over the payment-method data type, like +// `PaymentAuthorizeV2`. Written out here now that the 3DS first leg is implemented for real +// against `POST /direct/auth` and has left `macro_connector_flow_status_impls!`'s +// `not_supported` list. +impl + connector_types::PaymentPreAuthenticateV2 for Ilixium +{ +} + +// `PaymentCapture` — unlike `PaymentAuthorizeV2` — is not generic over the payment-method +// data type. It was previously emitted by `macro_connector_flow_status_impls!`; now that +// Capture is implemented for real it has left that macro's `not_implemented` list, so the +// marker impl is written out here. +impl + connector_types::PaymentCapture for Ilixium +{ +} + +// `PaymentVoidV2` is likewise non-generic over the payment-method data type. Now that Void is +// implemented for real it has left `macro_connector_flow_status_impls!`'s `not_implemented` +// list, so the marker impl is written out here. +impl + connector_types::PaymentVoidV2 for Ilixium +{ +} + +// `PaymentSyncV2` — non-generic over the payment-method data type, like the two above. Written +// out here now that PSync is implemented against `POST /history/operations` and has left +// `macro_connector_flow_status_impls!`'s `not_implemented` list. +impl + connector_types::PaymentSyncV2 for Ilixium +{ +} + +// `RefundV2` — non-generic over the payment-method data type, like the three above. Written out +// here now that Refund is implemented against `POST /direct/refund` and has left +// `macro_connector_flow_status_impls!`'s `not_implemented` list. +impl + connector_types::RefundV2 for Ilixium +{ +} + +// `RefundSyncV2` — non-generic over the payment-method data type, like the four above. Written out +// here now that RSync is implemented against `POST /history/operations` (the same query PSync +// uses, filtered to `type == REFUND`) and has left `macro_connector_flow_status_impls!`'s +// `not_implemented` list. +impl + connector_types::RefundSyncV2 for Ilixium +{ +} + +// ===== BASE (NON-FLOW) TRAIT IMPLEMENTATIONS ===== +// These are simple marker traits that are NOT flows and therefore have no arm +// in expand_flow_status_impl!. They must be impl'd manually. +impl + connector_types::ValidationTrait for Ilixium +{ +} + +// ===== INCOMING WEBHOOK IMPLEMENTATION ===== +// The Ilixium Direct API is fully synchronous for card payments: the outcome of +// /direct/auth (or /direct/threedcomplete for 3DS) is returned inline in the HTTP +// response, and the vendor documents no notification callback for this surface. The +// default no-op implementation is therefore the correct one. +impl + connector_types::IncomingWebhook for Ilixium +{ +} + +impl + connector_types::VerifyRedirectResponse for Ilixium +{ +} + +// ===== SOURCE VERIFICATION IMPLEMENTATION ===== +// Non-generic marker trait required by VerifyRedirectResponse for webhook +// signature verification. +impl + interfaces::verification::SourceVerification for Ilixium +{ +} + +// ===== PAYOUT TRAIT IMPLEMENTATIONS ===== +// Emits payout marker-trait impls and default no-op ConnectorIntegrationV2 +// impls for all PayoutXxxV2 flows. +crate::connectors::macros::macro_connector_payout_implementation!( + connector: Ilixium, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize] +); + +// ===== FLOW STATUS IMPLEMENTATIONS ===== +// `not_implemented` = Ilixium documents an API for it, this pass just doesn't cover it. +// `not_supported` = the documented Direct API has no equivalent concept at all. +// +// All six core flows — Authorize, Capture, Void, PSync, Refund and RSync — are implemented for +// real above and appear in neither list. +crate::connectors::macros::macro_connector_flow_status_impls!( + connector: Ilixium, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + not_implemented: [ + // Card tokens are returned on every payment attempt + // (paymentHistory.paymentAttempt[].token) and can drive stored-card payments; + // tokenization-as-a-flow and mandates/MIT are out of scope here. + PaymentMethodToken, + SetupMandate, + RepeatPayment + ], + not_supported: [ + VoidPC, + VoidPostRefund, + IncrementalAuthorization, + // Ilixium has no order/intent object to create before authorising, and no + // customer-object endpoint: customer.customerId is merchant-supplied and travels + // inline in the authorisation body. + CreateOrder, + CreateConnectorCustomer, + GetConnectorCustomer, + MandateRevoke, + // 3-D Secure is performed by the platform inside /direct/auth + + // /direct/threedcomplete, not through standalone authentication endpoints. The first + // of those two runs as `PreAuthenticate` (implemented above); there is no separate + // authentication call for `Authenticate`/`PostAuthenticate` to model, because + // /direct/threedcomplete settles the payment and therefore runs as `Authorize`. + Authenticate, + PostAuthenticate, + ServerAuthenticationToken, + ServerSessionAuthenticationToken, + ClientAuthenticationToken, + // The Direct API publishes no dispute/chargeback surface. + Accept, + SubmitEvidence, + DefendDispute + ], +); diff --git a/crates/integrations/connector-integration/src/connectors/ilixium/transformers.rs b/crates/integrations/connector-integration/src/connectors/ilixium/transformers.rs new file mode 100644 index 0000000000..7b0783e514 --- /dev/null +++ b/crates/integrations/connector-integration/src/connectors/ilixium/transformers.rs @@ -0,0 +1,4045 @@ +use base64::Engine; +use common_enums::{AttemptStatus, AuthenticationType, Currency, RefundStatus}; +use common_utils::{ + crypto::{self, GenerateDigest}, + pii::Email, + types::StringMinorUnit, +}; +use domain_types::{ + connector_flow::{Authorize, Capture, PSync, PreAuthenticate, RSync, Refund, Void}, + connector_types::{ + PaymentFlowData, PaymentVoidData, PaymentsAuthorizeData, PaymentsCaptureData, + PaymentsPreAuthenticateData, PaymentsResponseData, PaymentsSyncData, RawConnectorStatus, + RefundFlowData, RefundSyncData, RefundsData, RefundsResponseData, ResponseId, + }, + errors, + payment_method_data::{PaymentMethodData, PaymentMethodDataTypes, RawCardNumber}, + router_data::{ConnectorSpecificConfig, ErrorResponse}, + router_data_v2::RouterDataV2, + router_request_types::BrowserInformation, + router_response_types::RedirectForm, +}; +use error_stack::ResultExt; +use hyperswitch_masking::{ExposeInterface, PeekInterface, Secret}; +use serde::{Deserialize, Serialize}; +use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime, UtcOffset}; + +use super::IlixiumRouterData; + +pub const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +/// Message-level API version. The platform's `version` field is `2` for every +/// in-scope Direct API message (`/direct/auth`, `/direct/threedcomplete`). +const ILIXIUM_MESSAGE_VERSION: u8 = 2; + +/// `transaction.merchantRef` is constrained to `^(?!.*£)[\w]{4,20}$`. +const MERCHANT_REF_MIN_LEN: usize = 4; +const MERCHANT_REF_MAX_LEN: usize = 20; + +const CHALLENGE_WINDOW_SIZE_FULL_SCREEN: &str = "05"; + +/// The colour depths Ilixium accepts (`^1$|^4$|^8$|^15$|^16$|^24$|^32$|^48$`). +const ACCEPTED_COLOR_DEPTHS: [u8; 8] = [1, 4, 8, 15, 16, 24, 32, 48]; + +// ============================================================================= +// AUTH +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct IlixiumAuthType { + pub merchant_password: Secret, + pub merchant_id: Secret, + pub account_id: Secret, +} + +impl TryFrom<&ConnectorSpecificConfig> for IlixiumAuthType { + type Error = error_stack::Report; + + fn try_from(auth_type: &ConnectorSpecificConfig) -> Result { + match auth_type { + ConnectorSpecificConfig::Ilixium { + api_key, + key1, + api_secret, + .. + } => Ok(Self { + merchant_password: api_key.to_owned(), + merchant_id: key1.to_owned(), + account_id: api_secret.to_owned(), + }), + _ => Err(error_stack::report!( + errors::IntegrationError::FailedToObtainAuthType { + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Configure this merchant account's Ilixium connector with a \ + SignatureKey auth type: api_key = Digest Calculation Password, \ + key1 = MerchantId, api_secret = AccountId." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/digest".to_string()), + additional_context: Some( + "The connector_config passed to IlixiumAuthType::try_from was not \ + the ConnectorSpecificConfig::Ilixium variant — either a different \ + connector's config was routed to Ilixium, or Ilixium's three \ + credentials were never configured for this merchant account." + .to_string(), + ), + } + } + )), + } + } +} + +impl IlixiumAuthType { + pub fn compute_merchant_digest( + &self, + body: &str, + ) -> Result> { + let digest_error = |round: &str| errors::IntegrationError::RequestEncodingFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: Some("https://docs.ilixium.com/docs/direct/digest".to_string()), + additional_context: Some(format!( + "SHA-512 hashing failed while computing {round} of Ilixium's \ + X-MERCHANT-DIGEST (two rounds of Base64(SHA-512(...)) over the request \ + body, with the Digest Calculation Password appended in round two)." + )), + }, + }; + + let round_one = BASE64_ENGINE.encode( + crypto::Sha512 + .generate_digest(body.as_bytes()) + .change_context(digest_error("round one"))?, + ); + let salted = format!("{}{}", round_one, self.merchant_password.peek()); + let round_two = BASE64_ENGINE.encode( + crypto::Sha512 + .generate_digest(salted.as_bytes()) + .change_context(digest_error("round two"))?, + ); + Ok(round_two) + } +} + +// ============================================================================= +// merchantRef derivation +// ============================================================================= + +/// Whether a value already satisfies Ilixium's `transaction.merchantRef` pattern +/// (`^(?!.*£)[\w]{4,20}$`) and can therefore be sent verbatim. +/// +/// This is what makes [`derive_merchant_ref`] **idempotent**: a value it has already produced — +/// either an untouched caller reference or the 20-character hex digest — passes this test, so +/// re-deriving from it returns it unchanged. The Refund flow relies on that, because the caller +/// may legitimately supply *either* the original UCS payment reference *or* the `merchantRef` +/// Ilixium echoed back for it. +fn is_merchant_ref_verbatim_safe(reference: &str) -> bool { + (MERCHANT_REF_MIN_LEN..=MERCHANT_REF_MAX_LEN).contains(&reference.len()) + && reference + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Ilixium's `transaction.merchantRef` is 4–20 characters of `[\w]` (`[A-Za-z0-9_]`) and must +/// be unique per payment request (a repeat yields response code 102, Duplicate Merchant Ref). +/// A UCS `connector_request_reference_id` is routinely longer than 20 characters and usually +/// contains `-`, which is not in `[\w]`. +/// +/// Resolution (tech spec UNDECIDED #3, option (a) made collision-safe): +/// * If the reference already satisfies the pattern verbatim, send it unchanged so the value +/// stays human-traceable in Ilixium's back office. +/// * Otherwise derive `hex(SHA-512(reference))[..20]`. Hex is a subset of `[\w]`, the length +/// is exactly 20, and the derivation is **deterministic**, which is what makes the 3DS +/// second leg work: `/direct/threedcomplete` must quote the *same* `merchantRef` as the +/// original `/direct/auth`, and both legs recompute it from the same reference rather than +/// depending on any persisted mapping. +pub fn derive_merchant_ref( + reference: &str, +) -> Result> { + if is_merchant_ref_verbatim_safe(reference) { + return Ok(reference.to_string()); + } + + if reference.is_empty() { + return Err(error_stack::report!( + errors::IntegrationError::MissingRequiredField { + field_name: "connector_request_reference_id", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium requires a non-empty transaction.merchantRef on every \ + /direct/auth request." + .to_string(), + ), + doc_url: None, + additional_context: None, + }, + } + )); + } + + let digest = crypto::Sha512 + .generate_digest(reference.as_bytes()) + .change_context(errors::IntegrationError::RequestEncodingFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some( + "SHA-512 hashing failed while deriving Ilixium's 20-character \ + transaction.merchantRef from the UCS payment reference." + .to_string(), + ), + }, + })?; + Ok(hex::encode(digest) + .chars() + .take(MERCHANT_REF_MAX_LEN) + .collect()) +} + +// ============================================================================= +// SHARED REQUEST TYPES +// ============================================================================= + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumPaymentMethodType { + Card, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumTransactionType { + Ecommerce, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumMerchant { + #[serde(rename = "merchantId")] + pub merchant_id: Secret, + #[serde(rename = "accountId")] + pub account_id: Secret, +} + +impl From<&IlixiumAuthType> for IlixiumMerchant { + fn from(auth: &IlixiumAuthType) -> Self { + Self { + merchant_id: auth.merchant_id.clone(), + account_id: auth.account_id.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumTransaction { + #[serde(rename = "transactionType")] + pub transaction_type: IlixiumTransactionType, + #[serde(rename = "merchantRef")] + pub merchant_ref: String, + /// Minor units, digits only, serialised as a JSON **string** to match the schema's + /// `^[\d]{1,12}$` (the published examples emit a bare number, but the schema types it + /// as a string and both are accepted). + pub amount: StringMinorUnit, + /// ISO 4217 three-letter code (`GBP`); the schema also accepts the three-digit form. + pub currency: Currency, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumCard { + #[serde(rename = "cardNumber")] + pub card_number: RawCardNumber, + /// `MMyyyy`, no separator (e.g. `012030`). + #[serde(rename = "expiryDate")] + pub expiry_date: Secret, + #[serde(rename = "securityCode")] + pub security_code: Secret, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumAddress { + #[serde(rename = "addressLine1", skip_serializing_if = "Option::is_none")] + pub address_line1: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub province: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub postcode: Option>, + /// Always mandatory, even when the account is configured for Optional Address. + pub country: common_enums::CountryAlpha2, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumCustomer { + #[serde(rename = "customerId")] + pub customer_id: String, + pub email: Email, + #[serde(rename = "firstName")] + pub first_name: Secret, + pub surname: Secret, + /// `ddmmyyyy`. Schema-mandatory but absent from the UCS payment model — see + /// [`extract_date_of_birth`] for how it is sourced and why it may be omitted. + #[serde(rename = "dateOfBirth", skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option>, + pub address: IlixiumAddress, + #[serde(rename = "mobileNumber", skip_serializing_if = "Option::is_none")] + pub mobile_number: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumPaymentInfo { + /// ISO 3166 country of origin of the transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + /// Cardholder browser IP — recommended by Ilixium whenever 3-D Secure is in play. + #[serde(rename = "ipAddress", skip_serializing_if = "Option::is_none")] + pub ip_address: Option>, +} + +/// Mandatory when the merchant's account performs 3-D Secure; ignored (but harmless) when the +/// account is not configured for 3DS. `browserDetails` is the platform-performed-3DS branch of +/// the schema's `oneOf`; the `externalReferences` branch (merchant/third-party +/// pre-authentication) is out of scope here. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumEmvco3ds { + #[serde(rename = "browserDetails")] + pub browser_details: IlixiumBrowserDetails, +} + +/// All ten fields are mandatory. The schema types the numeric ones as strings. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumBrowserDetails { + #[serde(rename = "acceptHeader")] + pub accept_header: String, + #[serde(rename = "javaScriptEnabled")] + pub java_script_enabled: bool, + #[serde(rename = "javaEnabled")] + pub java_enabled: bool, + pub language: String, + #[serde(rename = "screenHeight")] + pub screen_height: String, + #[serde(rename = "screenWidth")] + pub screen_width: String, + /// `getTimezoneOffset()` in minutes. + #[serde(rename = "timeDifference")] + pub time_difference: String, + #[serde(rename = "userAgent")] + pub user_agent: String, + #[serde(rename = "colorDepth")] + pub color_depth: String, + #[serde(rename = "challengeWindowSize")] + pub challenge_window_size: String, +} + +// ============================================================================= +// AUTHORIZE REQUEST (both legs) +// ============================================================================= + +/// Untagged request body for the Authorize flow. +/// +/// Leg 1 (`Auth`) is `POST /direct/auth`; leg 2 (`ThreeDsComplete`) is +/// `POST /direct/threedcomplete`. The leg is selected by [`is_three_ds_completion`], which is +/// also what `get_url` in `ilixium.rs` keys off, so the URL and the body can never disagree. +/// `untagged` so each variant serialises as its own bare body. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum IlixiumAuthorizeRequest { + Auth(Box>), + ThreeDsComplete(Box), +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumPaymentsRequest { + pub version: u8, + /// `false` (the default) captures the authorisation immediately — the response `type` + /// is then `AUTH_CAP`. `true` authorises only and requires a later Capture or Reversal. + #[serde(rename = "deferredCapture")] + pub deferred_capture: bool, + pub transaction: IlixiumTransaction, + #[serde(rename = "paymentMethodType")] + pub payment_method_type: IlixiumPaymentMethodType, + pub merchant: IlixiumMerchant, + pub card: IlixiumCard, + pub customer: IlixiumCustomer, + #[serde(rename = "paymentInfo", skip_serializing_if = "Option::is_none")] + pub payment_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub emvco3ds: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumThreeDsTransactionRef { + /// Must be byte-identical to the `merchantRef` sent on the original `/direct/auth`. + #[serde(rename = "merchantRef")] + pub merchant_ref: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumThreeDsData { + pub md: Secret, + #[serde(rename = "paRes")] + pub pa_res: Secret, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumThreeDsCompleteRequest { + pub version: u8, + pub merchant: IlixiumMerchant, + pub transaction: IlixiumThreeDsTransactionRef, + #[serde(rename = "threeDSecure")] + pub three_d_secure: IlixiumThreeDsData, +} + +/// Whether this Authorize invocation is the 3DS return leg. +/// +/// UCS re-invokes `Authorize` after the ACS posts back to `TermUrl`, and it is the presence of +/// `redirect_response` that distinguishes that call from the initial authorisation. Used by +/// both the request builder below and `get_url` in `ilixium.rs`. +pub fn is_three_ds_completion( + request: &PaymentsAuthorizeData, +) -> bool { + request.redirect_response.is_some() +} + +// ============================================================================= +// REQUEST BUILDERS +// ============================================================================= + +/// `customer.dateOfBirth` is schema-mandatory (`ddmmyyyy`) but has no home in the UCS payment +/// model (tech spec UNDECIDED #1). Rather than fabricate a placeholder date — which would be +/// sent to the issuer and could distort Ilixium's own fraud checks — it is read from the +/// merchant-supplied `metadata` object under `ilixium_date_of_birth` (or `date_of_birth`) and +/// simply omitted when absent. Accounts that enforce the field will answer `VA8`, which +/// surfaces as a normal `REJECTED` error rather than a silently wrong value. +fn extract_date_of_birth( + metadata: Option<&common_utils::pii::SecretSerdeValue>, +) -> Option> { + let value = metadata?.clone().expose(); + ["ilixium_date_of_birth", "date_of_birth"] + .iter() + .find_map(|key| value.get(key).and_then(|v| v.as_str()).map(String::from)) + .map(Secret::new) +} + +/// Ilixium accepts only the colour depths in [`ACCEPTED_COLOR_DEPTHS`], while browsers report +/// whatever `screen.colorDepth` returns (30 on some X11 configurations, for instance). Snap +/// down to the nearest accepted value so a legitimate browser reading never turns into a +/// `VB88` rejection. +fn normalize_color_depth(reported: u8) -> u8 { + ACCEPTED_COLOR_DEPTHS + .iter() + .rev() + .find(|accepted| **accepted <= reported) + .copied() + .unwrap_or(ACCEPTED_COLOR_DEPTHS[0]) +} + +fn build_browser_details( + browser_info: &BrowserInformation, +) -> Result> { + let missing = |field: &'static str| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: field, + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium requires all ten emvco3ds.browserDetails fields on a 3-D Secure \ + authorisation. Collect the full browser profile client-side before \ + confirming a three_ds card payment." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/3dsecure".to_string()), + additional_context: None, + }, + }) + }; + + Ok(IlixiumBrowserDetails { + accept_header: browser_info + .accept_header + .clone() + .ok_or_else(|| missing("browser_info.accept_header"))?, + java_script_enabled: browser_info + .java_script_enabled + .ok_or_else(|| missing("browser_info.java_script_enabled"))?, + java_enabled: browser_info + .java_enabled + .ok_or_else(|| missing("browser_info.java_enabled"))?, + language: browser_info + .language + .clone() + .ok_or_else(|| missing("browser_info.language"))?, + screen_height: browser_info + .screen_height + .ok_or_else(|| missing("browser_info.screen_height"))? + .to_string(), + screen_width: browser_info + .screen_width + .ok_or_else(|| missing("browser_info.screen_width"))? + .to_string(), + time_difference: browser_info + .time_zone + .ok_or_else(|| missing("browser_info.time_zone"))? + .to_string(), + user_agent: browser_info + .user_agent + .clone() + .ok_or_else(|| missing("browser_info.user_agent"))?, + color_depth: normalize_color_depth( + browser_info + .color_depth + .ok_or_else(|| missing("browser_info.color_depth"))?, + ) + .to_string(), + challenge_window_size: CHALLENGE_WINDOW_SIZE_FULL_SCREEN.to_string(), + }) +} + +impl + TryFrom< + IlixiumRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + > for IlixiumAuthorizeRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + if is_three_ds_completion(&item.router_data.request) { + Ok(Self::ThreeDsComplete(Box::new( + IlixiumThreeDsCompleteRequest::try_from(&item.router_data)?, + ))) + } else { + Ok(Self::Auth(Box::new(IlixiumPaymentsRequest::try_from( + &item, + )?))) + } + } +} + +/// Everything the `/direct/auth` body needs, normalised out of whichever flow request is driving +/// it. +/// +/// `/direct/auth` is reachable from two flows: `Authorize` (non-3DS payments, and the 3DS first +/// leg when the connector is not routed through PreAuthenticate) and `PreAuthenticate` (the 3DS +/// first leg). The two carry the same information under different types — and +/// [`PaymentsPreAuthenticateData`] carries strictly less of it — so both funnel through here +/// rather than duplicating a 130-line body builder. +/// +/// Fields the PreAuthenticate request cannot supply are `Option` and documented at the call site. +pub(super) struct IlixiumAuthBodyInputs<'a, T: PaymentMethodDataTypes> { + pub payment_method_data: &'a PaymentMethodData, + /// Already converted to Ilixium's minor-unit digit string by the caller, which owns the + /// flow-specific amount field (`minor_amount` vs `amount`). + pub amount: StringMinorUnit, + pub currency: Currency, + pub email: Option, + /// `None` on the PreAuthenticate leg — `PaymentsPreAuthenticateData` has no `customer_name`, + /// so the billing address is the only name source there. + pub customer_name: Option<&'a str>, + /// `None` on the PreAuthenticate leg — see [`extract_date_of_birth`]. + pub metadata: Option<&'a common_utils::pii::SecretSerdeValue>, + pub browser_info: Option<&'a BrowserInformation>, + pub is_auto_capture: bool, + pub is_three_ds: bool, +} + +/// Builds the `POST /direct/auth` body shared by the Authorize and PreAuthenticate flows. +pub(super) fn build_ilixium_payments_request( + inputs: IlixiumAuthBodyInputs<'_, T>, + common: &PaymentFlowData, + connector_config: &ConnectorSpecificConfig, +) -> Result, error_stack::Report> { + let card = match inputs.payment_method_data { + PaymentMethodData::Card(card_data) => IlixiumCard { + card_number: card_data.card_number.clone(), + // Ilixium wants MMyyyy with no separator, e.g. `012030`. + expiry_date: Secret::new(format!( + "{}{}", + card_data.get_card_expiry_month_2_digit()?.peek(), + card_data.get_expiry_year_4_digit().peek() + )), + security_code: card_data.card_cvc.clone(), + }, + other => { + return Err(error_stack::report!( + errors::IntegrationError::NotImplemented( + "Ilixium supports only raw card payments on POST /direct/auth; wallets, \ + APMs, bank transfers and stored-card tokens are out of scope for this \ + connector implementation" + .to_string(), + errors::IntegrationErrorContext { + suggested_action: Some( + "Route this payment with PaymentMethodData::Card.".to_string(), + ), + doc_url: Some( + "https://docs.ilixium.com/docs/api/authorisation".to_string() + ), + additional_context: Some(format!( + "Unsupported payment_method_data variant for Ilixium \ + /direct/auth: {other:?}" + )), + }, + ) + )); + } + }; + + let auth = IlixiumAuthType::try_from(connector_config)?; + + let amount = inputs.amount; + + let country = common.get_optional_billing_country().ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: "payment_method_data.billing.address.country", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium always requires customer.address.country, even on accounts \ + configured for Optional Address." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/optional-address".to_string()), + additional_context: None, + }, + }) + })?; + + let email = inputs + .email + .clone() + .or_else(|| common.get_optional_billing_email()) + .ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: "email", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium requires customer.email on every authorisation.".to_string(), + ), + doc_url: None, + additional_context: None, + }, + }) + })?; + + let (first_name, surname) = split_customer_name(common, inputs.customer_name)?; + + // `customer.customerId` is mandatory (pattern allows `-`, up to 255 chars), so the UCS + // customer id maps across verbatim when present. Ilixium ties a card to a single + // customer id (fraud code 121, "Duplicate Card"), which is why the real customer id is + // preferred over a per-payment synthetic; the payment reference is only a fallback for + // guest checkouts that carry no customer at all (tech spec UNDECIDED #2, options a+b). + let customer_id = common + .customer_id + .as_ref() + .map(|id| id.get_string_repr().to_string()) + .unwrap_or_else(|| common.connector_request_reference_id.clone()); + + let ip_address = inputs + .browser_info + .and_then(|info| info.ip_address) + .map(|ip| Secret::new(ip.to_string())); + + // `emvco3ds` is only meaningful on a 3-D Secure authorisation. Ilixium ignores it on + // non-3DS accounts, but sending it there would be noise, so it is gated strictly on the + // request's authentication type. + let emvco3ds = if inputs.is_three_ds { + let browser_info = inputs.browser_info.ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: "browser_info", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "A 3-D Secure card authorisation must carry the browser profile \ + that populates emvco3ds.browserDetails." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/3dsecure".to_string()), + additional_context: None, + }, + }) + })?; + Some(IlixiumEmvco3ds { + browser_details: build_browser_details(browser_info)?, + }) + } else { + None + }; + + // `paymentInfo.country` is the country of origin of the transaction and + // `paymentInfo.ipAddress` is the cardholder's browser IP, which Ilixium recommends + // whenever 3-D Secure is in play. Both are optional, so the block is emitted whenever + // either value is known. + let payment_info = Some(IlixiumPaymentInfo { + country: Some(country), + ip_address, + }); + + Ok(IlixiumPaymentsRequest { + version: ILIXIUM_MESSAGE_VERSION, + deferred_capture: !inputs.is_auto_capture, + transaction: IlixiumTransaction { + transaction_type: IlixiumTransactionType::Ecommerce, + merchant_ref: derive_merchant_ref(&common.connector_request_reference_id)?, + amount, + currency: inputs.currency, + }, + payment_method_type: IlixiumPaymentMethodType::Card, + merchant: IlixiumMerchant::from(&auth), + card, + customer: IlixiumCustomer { + customer_id, + email, + first_name, + surname, + date_of_birth: extract_date_of_birth(inputs.metadata), + address: IlixiumAddress { + address_line1: common.get_optional_billing_line1(), + city: common.get_optional_billing_city(), + province: common.get_optional_billing_state(), + postcode: common.get_optional_billing_zip(), + country, + }, + mobile_number: common.get_billing_phone_number().ok(), + }, + payment_info, + emvco3ds, + }) +} + +impl + TryFrom< + &IlixiumRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + > for IlixiumPaymentsRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: &IlixiumRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let common = &router_data.resource_common_data; + + let amount = item + .connector + .amount_converter + .convert(request.minor_amount, request.currency) + .change_context(errors::IntegrationError::AmountConversionFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some(format!( + "Failed to convert minor_amount {} {} into Ilixium's \ + transaction.amount (minor units, digits only, sent as a JSON string).", + request.minor_amount.get_amount_as_i64(), + request.currency + )), + }, + })?; + + build_ilixium_payments_request( + IlixiumAuthBodyInputs { + payment_method_data: &request.payment_method_data, + amount, + currency: request.currency, + email: request.email.clone(), + customer_name: request.customer_name.as_deref(), + metadata: request.metadata.as_ref(), + browser_info: request.browser_info.as_ref(), + is_auto_capture: request.is_auto_capture(), + is_three_ds: common.auth_type == AuthenticationType::ThreeDs, + }, + common, + &router_data.connector_config, + ) + } +} + +/// `POST /direct/auth` built from the **PreAuthenticate** leg. +/// +/// Same body as the Authorize path — Ilixium has one authorisation endpoint — but +/// [`PaymentsPreAuthenticateData`] is a narrower struct, so three things differ: +/// +/// * `payment_method_data` and `currency` are `Option` here and must be unwrapped; +/// * `amount` is a single `MinorUnit` (there is no separate `minor_amount`); +/// * `is_auto_capture()` returns a `Result` (it rejects `ManualMultiple`/`Scheduled`) rather than +/// a bare `bool`. +/// +/// One input is unavailable on this leg: `customer_name`, so the billing address is the only +/// name source (which [`split_customer_name`] already handles). `metadata` *is* available — +/// `PaymentsPreAuthenticateData` carries it so `customer.dateOfBirth` resolves here exactly as +/// it does on Authorize. +impl + TryFrom< + IlixiumRouterData< + RouterDataV2< + PreAuthenticate, + PaymentFlowData, + PaymentsPreAuthenticateData, + PaymentsResponseData, + >, + T, + >, + > for IlixiumPreAuthenticateRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2< + PreAuthenticate, + PaymentFlowData, + PaymentsPreAuthenticateData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let common = &router_data.resource_common_data; + + let missing = |field: &'static str| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: field, + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium's PreAuthenticate leg sends the full POST /direct/auth \ + authorisation, so it needs the same card, amount and currency an \ + Authorize would carry." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/api/authorisation".to_string()), + additional_context: None, + }, + }) + }; + + let payment_method_data = request + .payment_method_data + .as_ref() + .ok_or_else(|| missing("payment_method_data"))?; + let currency = request.currency.ok_or_else(|| missing("currency"))?; + + let amount = item + .connector + .amount_converter + .convert(request.amount, currency) + .change_context(errors::IntegrationError::AmountConversionFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some(format!( + "Failed to convert amount {} {} into Ilixium's transaction.amount \ + (minor units, digits only, sent as a JSON string).", + request.amount.get_amount_as_i64(), + currency + )), + }, + })?; + + build_ilixium_payments_request( + IlixiumAuthBodyInputs { + payment_method_data, + amount, + currency, + email: request.email.clone(), + // `PaymentsPreAuthenticateData` has no `customer_name`; the billing address is + // the only name source on this leg, which `split_customer_name` handles. + customer_name: None, + metadata: request.metadata.as_ref(), + browser_info: request.browser_info.as_ref(), + is_auto_capture: request.is_auto_capture()?, + // UCS forces `auth_type` to ThreeDs on the PreAuthenticate common data, and this + // leg only runs for 3DS payments, so `emvco3ds` is always required here. + is_three_ds: true, + }, + common, + &router_data.connector_config, + ) + } +} + +/// Ilixium requires `firstName` and `surname` separately. Prefer the structured billing name, +/// then fall back to splitting the single `customer_name` on the first space. +fn split_customer_name( + common: &PaymentFlowData, + customer_name: Option<&str>, +) -> Result<(Secret, Secret), error_stack::Report> { + let missing = |field: &'static str| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: field, + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Ilixium requires customer.firstName and customer.surname. Supply a \ + billing address with first_name/last_name, or a customer name containing \ + both parts." + .to_string(), + ), + doc_url: None, + additional_context: None, + }, + }) + }; + + let billing_first = common.get_optional_billing_first_name(); + let billing_last = common.get_optional_billing_last_name(); + if let (Some(first), Some(last)) = (billing_first.clone(), billing_last.clone()) { + return Ok((first, last)); + } + + let (split_first, split_last) = customer_name + .and_then(|name| { + name.trim() + .split_once(char::is_whitespace) + .map(|(first, last)| (first.to_string(), last.trim().to_string())) + }) + .map_or((None, None), |(first, last)| { + (Some(Secret::new(first)), Some(Secret::new(last))) + }); + + let first_name = billing_first + .or(split_first) + .ok_or_else(|| missing("payment_method_data.billing.address.first_name"))?; + let surname = billing_last + .or(split_last) + .ok_or_else(|| missing("payment_method_data.billing.address.last_name"))?; + Ok((first_name, surname)) +} + +impl + TryFrom< + &RouterDataV2, PaymentsResponseData>, + > for IlixiumThreeDsCompleteRequest +{ + type Error = error_stack::Report; + + fn try_from( + router_data: &RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + ) -> Result { + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + let payload = router_data + .request + .redirect_response + .as_ref() + .and_then(|response| response.payload.clone()) + .ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: "request.redirect_response.payload", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "The ACS posts `md` and `paRes` back to TermUrl; both must be \ + forwarded into the Authorize completion call." + .to_string(), + ), + doc_url: Some( + "https://docs.ilixium.com/docs/api/threedcomplete".to_string() + ), + additional_context: None, + }, + }) + })? + .expose(); + + // The ACS form field names are case-inconsistent across issuers: the spec documents + // lowercase `md`/`paRes` on the TermUrl post-back, while the outbound form fields are + // `MD`/`PaReq`. Accept the documented casing plus the common variants. + let pick = |candidates: &[&str], field_name: &'static str| { + candidates + .iter() + .find_map(|key| payload.get(*key).and_then(|value| value.as_str())) + .map(|value| Secret::new(value.to_string())) + .ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name, + context: errors::IntegrationErrorContext { + suggested_action: Some( + "POST /direct/threedcomplete requires both threeDSecure.md and \ + threeDSecure.paRes exactly as returned by the ACS." + .to_string(), + ), + doc_url: Some( + "https://docs.ilixium.com/docs/api/threedcomplete".to_string() + ), + additional_context: None, + }, + }) + }) + }; + + Ok(Self { + version: ILIXIUM_MESSAGE_VERSION, + merchant: IlixiumMerchant::from(&auth), + // Recomputed from the same reference the original /direct/auth used, so the two + // legs always quote an identical merchantRef without needing a persisted mapping. + transaction: IlixiumThreeDsTransactionRef { + merchant_ref: derive_merchant_ref( + &router_data + .resource_common_data + .connector_request_reference_id, + )?, + }, + three_d_secure: IlixiumThreeDsData { + md: pick(&["md", "MD", "Md"], "request.redirect_response.payload.md")?, + pa_res: pick( + &["paRes", "PaRes", "PARes", "pares"], + "request.redirect_response.payload.paRes", + )?, + }, + }) + } +} + +// ============================================================================= +// CAPTURE REQUEST +// ============================================================================= + +/// `captureRequest.transaction`. +/// +/// A deliberate second transaction struct rather than a reuse of [`IlixiumTransaction`]: +/// `captureRequest` requires exactly `amount`, `currency` and `merchantRef`, and Ilixium +/// ignores `transactionType` here (it is inherited from `transactionDetails` but is not part +/// of a capture), so sending it would be noise on a message that identifies the payment by +/// nothing but its reference. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumCaptureTransaction { + /// **The original authorisation's** `merchantRef` — this is the only thing binding the + /// capture to a payment; `captureRequest` carries no gateway id. A reference that matches + /// nothing yields response code 104. + #[serde(rename = "merchantRef")] + pub merchant_ref: String, + /// Minor units, digits only, serialised as a JSON string (`^[\d]{1,12}$`). + pub amount: StringMinorUnit, + /// Must equal the original transaction's currency; a mismatch yields response code 103. + pub currency: Currency, +} + +/// `POST /direct/capture` body. The schema's top-level properties are exactly `version`, +/// `transaction` and `merchant` — there is no card, token, customer or paymentMethodType on a +/// capture. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumCaptureRequest { + pub version: u8, + pub transaction: IlixiumCaptureTransaction, + pub merchant: IlixiumMerchant, +} + +impl + TryFrom< + IlixiumRouterData< + RouterDataV2, + T, + >, + > for IlixiumCaptureRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let common = &router_data.resource_common_data; + + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + let amount = item + .connector + .amount_converter + .convert(request.minor_amount_to_capture, request.currency) + .change_context(errors::IntegrationError::AmountConversionFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some(format!( + "Failed to convert minor_amount_to_capture {} {} into Ilixium's \ + transaction.amount (minor units, digits only, sent as a JSON string).", + request.minor_amount_to_capture.get_amount_as_i64(), + request.currency + )), + }, + })?; + + Ok(Self { + version: ILIXIUM_MESSAGE_VERSION, + transaction: IlixiumCaptureTransaction { + // Recomputed from the same `connector_request_reference_id` the original + // /direct/auth derived its merchantRef from. `derive_merchant_ref` is + // deterministic, so the capture quotes byte-for-byte the reference Ilixium + // already knows the payment by — no persisted auth-to-capture mapping needed. + merchant_ref: derive_merchant_ref(&common.connector_request_reference_id)?, + amount, + // The platform rejects a capture whose currency differs from the original + // (code 103); UCS carries the payment's own currency through on the capture + // request, so it is passed straight across. + currency: request.currency, + }, + merchant: IlixiumMerchant::from(&auth), + }) + } +} + +// ============================================================================= +// VOID (REVERSAL) REQUEST +// ============================================================================= + +/// `reversalRequest.transaction`. +/// +/// `reversalRequest` is field-for-field identical to `captureRequest` — same +/// `required: [merchant, transaction, version]`, same regexes, same length limits; only the +/// schema `title`/`description` differ. It is kept as a sibling of +/// [`IlixiumCaptureTransaction`] rather than reusing it because `amount` means something +/// materially different here: on a capture it may be *less* than the original (partial capture +/// is allowed), on a reversal it **must equal it exactly**. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumReversalTransaction { + /// **The original authorisation's** `merchantRef` — the only thing binding the reversal to + /// a payment; `reversalRequest` carries no gateway id. A reference that matches nothing + /// yields response code 104. + #[serde(rename = "merchantRef")] + pub merchant_ref: String, + /// Minor units, digits only, serialised as a JSON string (`^[\d]{1,12}$`). + /// + /// **Partial reversals are not supported.** This must equal the original transaction's + /// amount exactly; anything else — including `0` or a partial value — is rejected with + /// response code 142, "Reversal amount does not match original transaction". Nothing in + /// this connector ever synthesises the value: it is whatever the caller supplied, and the + /// request is refused locally when the caller supplied nothing. + pub amount: StringMinorUnit, + /// Must equal the original transaction's currency; a mismatch yields response code 103. + pub currency: Currency, +} + +/// `POST /direct/reversal` body. Top-level properties are exactly `version`, `transaction` and +/// `merchant` — the same three the capture body carries. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumVoidRequest { + pub version: u8, + pub transaction: IlixiumReversalTransaction, + pub merchant: IlixiumMerchant, +} + +/// Builds the reversal body from a UCS Void request. +/// +/// **The missing-amount problem.** `PaymentVoidData::amount` and `::currency` are both +/// `Option`, mirroring `PaymentServiceVoidRequest.amount`, which is an `optional Money` on the +/// wire — a caller may legitimately omit it. Ilixium, however, makes `transaction.amount` +/// mandatory *and* requires it to equal the original transaction's amount exactly. There is no +/// second source to fall back on: `PaymentFlowData` is built for the Void flow with `amount`, +/// `minor_amount_captured`, `minor_amount_capturable` and `minor_amount_authorized` all set to +/// `None`, so `resource_common_data` knows nothing about the payment's value either. +/// +/// Every way of papering over that would be worse than failing: +/// * sending `0` or a partial amount earns response code 142 — a remote rejection dressed up as +/// a local success path, and one that consumes an operation slot on the transaction (code +/// 117 serialises operations per transaction); +/// * fabricating an amount risks reversing the wrong figure if Ilixium ever relaxes the +/// equality check. +/// +/// So a Void with no amount is refused here with a `MissingRequiredField` naming `amount` (or +/// `currency`) and spelling out that the *original* transaction's values are required. +impl + TryFrom< + IlixiumRouterData< + RouterDataV2, + T, + >, + > for IlixiumVoidRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let common = &router_data.resource_common_data; + + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + let missing = |field: &'static str, what: &str| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: field, + context: errors::IntegrationErrorContext { + suggested_action: Some(format!( + "Ilixium does not support partial reversals: POST /direct/reversal \ + requires transaction.{what}, and it must match the original \ + transaction exactly. Send the void request with the original \ + authorisation's amount and currency (PaymentServiceVoidRequest.amount \ + carries both)." + )), + doc_url: Some("https://docs.ilixium.com/docs/api/reversal".to_string()), + additional_context: Some( + "PaymentVoidData carries amount/currency as Option because the gRPC \ + field is optional, and the Void flow's PaymentFlowData holds no \ + amount to fall back on. Rather than send 0 or a partial value — both \ + of which Ilixium rejects with response code 142, 'Reversal amount \ + does not match original transaction' — the request is refused here." + .to_string(), + ), + }, + }) + }; + + let minor_amount = request.amount.ok_or_else(|| missing("amount", "amount"))?; + let currency = request + .currency + .ok_or_else(|| missing("currency", "currency"))?; + + let amount = item + .connector + .amount_converter + .convert(minor_amount, currency) + .change_context(errors::IntegrationError::AmountConversionFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some(format!( + "Failed to convert void amount {} {} into Ilixium's \ + transaction.amount (minor units, digits only, sent as a JSON string).", + minor_amount.get_amount_as_i64(), + currency + )), + }, + })?; + + Ok(Self { + version: ILIXIUM_MESSAGE_VERSION, + transaction: IlixiumReversalTransaction { + // Recomputed from `connector_request_reference_id` exactly as Capture does. + // `derive_merchant_ref` is deterministic, so the reversal quotes byte-for-byte + // the reference Ilixium already knows the payment by — provided the caller puts + // the *original authorisation's* reference in `merchant_void_id`, which is what + // `connector_request_reference_id` is populated from on this flow (the capture + // flow has the same requirement on `merchant_capture_id`). A reference that + // matches no transaction yields response code 104. + merchant_ref: derive_merchant_ref(&common.connector_request_reference_id)?, + amount, + currency, + }, + merchant: IlixiumMerchant::from(&auth), + }) + } +} + +// ============================================================================= +// REFUND REQUEST +// ============================================================================= + +/// `refundRequest.transaction`, **variant A** — a refund that references a previous +/// transaction. +/// +/// `refundRequest` is a single schema covering two variants. Variant A carries exactly the same +/// six fields as `captureRequest`/`reversalRequest`; variant B is a *standalone* refund that +/// additionally needs `paymentMethodType`, `token`-or-`card`, `customer` and `paymentInfo`. +/// Only variant A is implemented: a connector Refund flow always has a previous payment, and +/// the vendor states standalone refunds are account-gated ("during testing both types will be +/// accepted, although this may not be the case within the production environment"). +/// +/// A sibling of [`IlixiumCaptureTransaction`] / [`IlixiumReversalTransaction`] rather than a +/// reuse of either, because `amount` means a third thing again: on a capture it may be less than +/// the original (partial capture), on a reversal it **must** equal it exactly, and on a refund it +/// may be less than the original *and* accumulates across successive refunds — Ilixium rejects +/// the request with code 125 only once the **total** would exceed the original authorised amount. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumRefundTransaction { + /// **The original payment's** `merchantRef` — the only thing binding the refund to a + /// transaction; `refundRequest` carries no gateway id and mints no refund id. A reference + /// that matches nothing yields response code 104. + #[serde(rename = "merchantRef")] + pub merchant_ref: String, + /// Minor units, digits only, serialised as a JSON string (`^[\d]{1,12}$`). + /// + /// **Partial refunds are supported and cumulative.** This is + /// `RefundsData::minor_refund_amount` (the amount to refund now), never + /// `minor_payment_amount` (the original payment's total). + pub amount: StringMinorUnit, + /// Must equal the original transaction's currency; a mismatch yields response code 103. + pub currency: Currency, +} + +/// `POST /direct/refund` body, variant A. The three top-level properties are the same ones the +/// capture and reversal bodies carry — no card, token, customer, paymentInfo or +/// paymentMethodType, all of which belong to variant B only. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumRefundRequest { + pub version: u8, + pub transaction: IlixiumRefundTransaction, + pub merchant: IlixiumMerchant, +} + +/// Resolves the **original payment's** `merchantRef` for a refund-side flow. +/// +/// This is the one place where Refund and RSync cannot copy Capture and Void. Those flows read +/// `PaymentFlowData::connector_request_reference_id`, which UCS populates from +/// `merchant_capture_id` / `merchant_void_id` — i.e. from a caller-supplied reference that +/// *identifies the original payment*. On the refund-side flows the analogous field, +/// `RefundFlowData::connector_request_reference_id`, is populated from **`merchant_refund_id`**: +/// it identifies the *refund*, not the payment. Deriving a `merchantRef` from it would address a +/// transaction that does not exist — response code 104 on `/direct/refund`, and on RSync a +/// client-side filter over `/history/operations` that can never match anything. +/// +/// So the payment reference is taken from the flow's *request* data, in this order: +/// +/// 1. **`connector_order_id`** (`PaymentServiceRefundRequest.connector_order_id`, and its +/// `RefundSyncData` counterpart) — the field whose documented purpose is exactly this: +/// "connector-side identifier for the original payment that this refund targets". It is passed +/// through [`derive_merchant_ref`], which is idempotent (see [`is_merchant_ref_verbatim_safe`]), +/// so it works whether the caller supplies the original UCS payment reference or the +/// `merchantRef` Ilixium echoed back in `connector_reference_id`. +/// 2. **`connector_transaction_id`**, but only when it is already `merchantRef`-shaped. For +/// Ilixium this field normally holds `transaction.gatewayRef`, a 36-character dashed UUID that +/// is *not* a `merchantRef` and cannot be converted into one — hashing it would produce a +/// well-formed reference pointing at nothing. The shape test is what separates the two: a +/// caller that put the reference itself here is honoured, a gatewayRef is not. +/// 3. Otherwise the request is **refused locally**. Sending a derived-from-the-wrong-input +/// reference would be a guaranteed 104 dressed up as a local success path, and it consumes an +/// operation slot on the transaction (Ilixium serialises operations per transaction, code +/// 117). +/// +/// Taken as two plain values rather than as a `&RefundsData` so that RSync — which is handed a +/// [`RefundSyncData`] carrying the very same two fields — resolves the reference through exactly +/// this ladder rather than through a copy of it. +fn resolve_original_merchant_ref( + connector_order_id: Option<&str>, + connector_transaction_id: &str, +) -> Result> { + if let Some(order_id) = connector_order_id.filter(|value| !value.is_empty()) { + return derive_merchant_ref(order_id); + } + + if is_merchant_ref_verbatim_safe(connector_transaction_id) { + return Ok(connector_transaction_id.to_owned()); + } + + Err(error_stack::report!( + errors::IntegrationError::MissingRequiredField { + field_name: "connector_order_id", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Set connector_order_id on the refund (or refund-sync) request to the \ + original payment's reference — either the merchant_transaction_id the \ + payment was authorised with, or the connector_reference_id Ilixium returned \ + for it (both resolve to the same transaction.merchantRef). POST \ + /direct/refund binds a refund to a payment by merchantRef alone, and POST \ + /history/operations can only be filtered on it client-side." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/api/refund".to_string()), + additional_context: Some( + "RefundFlowData::connector_request_reference_id is derived from \ + merchant_refund_id, so it identifies the refund rather than the payment, and \ + connector_transaction_id holds Ilixium's gatewayRef (a dashed UUID) which is \ + not a merchantRef and cannot be converted into one. Rather than send a \ + reference that matches no transaction — response code 104, 'No Matching \ + Transaction', or a history filter that selects nothing — the request is \ + refused here." + .to_string(), + ), + }, + } + )) +} + +/// The refund's own reference, as supplied by UCS. +/// +/// Ilixium returns **no refund identifier** (see [`refund_identifier`]), so this value is the +/// connector's only stable handle on an individual refund. It is validated when the request is +/// built — before any money moves — rather than at response time, so that a refund Ilixium has +/// already accepted can never fail for want of an id. +/// +/// `RefundsData::refund_id` and `RefundFlowData::connector_request_reference_id` are both +/// `merchant_refund_id`; the second is checked only so that a caller who populated one and not +/// the other still works. +fn refund_reference(request: &RefundsData, common: &RefundFlowData) -> Option { + [ + request.refund_id.as_str(), + common.connector_request_reference_id.as_str(), + ] + .into_iter() + .find(|value| !value.is_empty()) + .map(str::to_string) +} + +/// [`refund_reference`] as a hard requirement, used when the request body is built. +fn resolve_refund_reference( + request: &RefundsData, + common: &RefundFlowData, +) -> Result> { + refund_reference(request, common).ok_or_else(|| { + error_stack::report!(errors::IntegrationError::MissingRequiredField { + field_name: "merchant_refund_id", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Send merchant_refund_id on every Ilixium refund request.".to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/reconciliation".to_string()), + additional_context: Some( + "POST /direct/refund mints no refund identifier: its response echoes the \ + original payment's merchantRef and gatewayRef, and status.operationRef — the \ + only refund-unique value — is documented as 'available soon'. When Ilixium \ + omits operationRef, merchant_refund_id is the only value that can identify \ + this refund, so the request is refused rather than performed with no way to \ + report a connector_refund_id." + .to_string(), + ), + }, + }) + }) +} + +impl + TryFrom< + IlixiumRouterData< + RouterDataV2, + T, + >, + > for IlixiumRefundRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let common = &router_data.resource_common_data; + + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + // Validated here, at request-build time, purely so the response mapping always has a + // fallback id to report for a refund Ilixium has already performed. + resolve_refund_reference(request, common)?; + + let amount = item + .connector + .amount_converter + .convert(request.minor_refund_amount, request.currency) + .change_context(errors::IntegrationError::AmountConversionFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: None, + additional_context: Some(format!( + "Failed to convert minor_refund_amount {} {} into Ilixium's \ + transaction.amount (minor units, digits only, sent as a JSON string).", + request.minor_refund_amount.get_amount_as_i64(), + request.currency + )), + }, + })?; + + Ok(Self { + version: ILIXIUM_MESSAGE_VERSION, + transaction: IlixiumRefundTransaction { + merchant_ref: resolve_original_merchant_ref( + request.connector_order_id.as_deref(), + &request.connector_transaction_id, + )?, + amount, + // The platform rejects a refund whose currency differs from the original + // (code 103). `RefundsData::currency` is the original payment's currency, which + // is precisely what is required. + currency: request.currency, + }, + merchant: IlixiumMerchant::from(&auth), + }) + } +} + +// ============================================================================= +// RESPONSE TYPES +// ============================================================================= + +/// Request-level outcome. **Every** business failure is returned as HTTP 200 with one of these +/// codes, so this — never the HTTP status — is what the connector branches on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumStatusCode { + Success, + Declined, + Rejected, + Error, + Cancelled, + Pending, + Resubmission, + /// Any code Ilixium adds that this integration does not yet know about. + #[serde(other)] + Unknown, +} + +/// Operation type echoed in the response. `/direct/auth` answers `AUTH_CAP` when +/// `deferredCapture` was false and `AUTH` when it was true; `/direct/capture` answers +/// `CAPTURE` and `/direct/reversal` answers `REVERSAL`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumOperationType { + Auth, + AuthCap, + Capture, + Reversal, + Refund, + Credit, + /// The wire value is pinned explicitly rather than derived from the identifier: the + /// enum's `rename_all = "SCREAMING_SNAKE_CASE"` would turn any respelling of "Threed" + /// into a *different* string (`THREE_D_SECURE_COMPLETE`), which Ilixium never sends, and + /// the variant would silently fall through to `Unknown` instead of failing loudly. + #[serde(rename = "THREED_SECURE_COMPLETE")] + ThreeDsSecureComplete, + Payment, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumAttemptCode { + Success, + Pending, + Rejected, + Error, + Cancelled, + Declined, + Cvv2CheckFailed, + AvsAddressCheckFailed, + AvsPostcodeCheckFailed, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumThreeDsStatus { + Pending, + AuthenticationSuccessful, + AuthenticationFailed, + NotEnrolled, + AuthenticationUnavailable, + AuthenticationAttempted, + AcsError, + InternalError, + #[serde(other)] + Unknown, +} + +/// A single entry of `status.reasons.reason`. The OpenAPI schema types these as strings +/// (`VA22`, `121`), but the legacy 3DS example emits a bare number (`502`), so accept both. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum IlixiumReasonCode { + Text(String), + Numeric(i64), +} + +impl std::fmt::Display for IlixiumReasonCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text(value) => write!(f, "{value}"), + Self::Numeric(value) => write!(f, "{value}"), + } + } +} + +/// `status.reasons` shape. Tech spec CONFLICT: the OpenAPI schema and the `/docs/api/*` +/// examples wrap the codes in an object (`{"reason": ["VA22"]}`), while `/docs/response-codes/` +/// shows a bare array and the legacy 3DS page shows a scalar. The schema is authoritative, but +/// all three shapes are accepted here so a stale wire format can never turn a decline into an +/// unparsable response. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum IlixiumReasons { + Wrapped { reason: IlixiumReasonList }, + Bare(IlixiumReasonList), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum IlixiumReasonList { + Many(Vec), + One(IlixiumReasonCode), +} + +impl IlixiumReasonList { + fn codes(&self) -> Vec { + match self { + Self::Many(codes) => codes.iter().map(ToString::to_string).collect(), + Self::One(code) => vec![code.to_string()], + } + } +} + +impl IlixiumReasons { + pub fn codes(&self) -> Vec { + match self { + Self::Wrapped { reason } => reason.codes(), + Self::Bare(list) => list.codes(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumStatus { + pub code: IlixiumStatusCode, + pub message: Option, + pub reasons: Option, + pub timestamp: Option, + #[serde(rename = "operationRef")] + pub operation_ref: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumTransactionResponse { + #[serde(rename = "merchantRef")] + pub merchant_ref: Option, + /// Gateway-assigned unique reference — this is the connector transaction id. + #[serde(rename = "gatewayRef")] + pub gateway_ref: Option, + pub currency: Option, + #[serde(rename = "transactionType")] + pub transaction_type: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumCardResponse { + #[serde(rename = "acquirerRef")] + pub acquirer_ref: Option, + #[serde(rename = "cardBin")] + pub card_bin: Option, + #[serde(rename = "cardLastFour")] + pub card_last_four: Option, + #[serde(rename = "cardType")] + pub card_type: Option, + /// Present only when the transaction was successfully authorised. + #[serde(rename = "authCode")] + pub auth_code: Option, + pub cvv: Option, + #[serde(rename = "avsAddress")] + pub avs_address: Option, + #[serde(rename = "avsPostcode")] + pub avs_postcode: Option, + #[serde(rename = "threeDSecureStatus")] + pub three_d_secure_status: Option, + /// The ACS form `action` for the browser redirect. + #[serde(rename = "threeDSecureAcsUrl")] + pub three_d_secure_acs_url: Option, + /// POSTed to the ACS as `MD`. + #[serde(rename = "threeDSecureMd")] + pub three_d_secure_md: Option>, + /// POSTed to the ACS as `PaReq`. + #[serde(rename = "threeDSecurePaReq")] + pub three_d_secure_pa_req: Option>, + #[serde(rename = "threeDSecureVersion")] + pub three_d_secure_version: Option, + pub iso8583code: Option, +} + +/// Deliberately omits `amount`/`currency`: they are unused by the Authorize mapping and the +/// vendor's examples type them inconsistently (quoted string in `transaction`, bare number +/// here), so parsing them would add a failure mode for no benefit. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumPaymentAttempt { + pub order: Option, + pub timestamp: Option, + pub code: Option, + pub message: Option, + #[serde(rename = "operationRef")] + pub operation_ref: Option, + #[serde(rename = "paymentMethodType")] + pub payment_method_type: Option, + #[serde(rename = "cardResponse")] + pub card_response: Option, +} + +/// Tech spec CONFLICT: the OpenAPI schema types `paymentAttempt` as an array, but the legacy +/// 3DS documentation page renders it as a single object. Both are accepted. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum IlixiumPaymentAttempts { + Many(Vec), + One(Box), +} + +impl IlixiumPaymentAttempts { + /// The most recent attempt: highest `order`, falling back to the last element when the + /// platform omits `order`. + pub fn latest(&self) -> Option<&IlixiumPaymentAttempt> { + match self { + Self::One(attempt) => Some(attempt.as_ref()), + Self::Many(attempts) => attempts + .iter() + .max_by_key(|attempt| attempt.order.unwrap_or_default()) + .or_else(|| attempts.last()), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumPaymentHistory { + #[serde(rename = "paymentAttempt")] + pub payment_attempt: Option, +} + +/// The shared `paymentResponse` envelope. `/direct/auth`, `/direct/threedcomplete` and +/// `/direct/capture` return byte-for-byte the same schema, so both Authorize legs and Capture +/// deserialise into this one type. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumPaymentResponse { + pub version: Option, + #[serde(rename = "type")] + pub operation_type: Option, + pub transaction: Option, + pub status: IlixiumStatus, + #[serde(rename = "paymentHistory")] + pub payment_history: Option, +} + +/// `/direct/capture` returns the same `paymentResponse` envelope as `/direct/auth`. The alias +/// exists because the connector macros mint one `…Templating` marker type per named response +/// body, so two flows cannot both name `IlixiumPaymentResponse` directly. +pub type IlixiumCaptureResponse = IlixiumPaymentResponse; + +/// `/direct/reversal` returns the same `paymentResponse` envelope, with `type` = `REVERSAL`. +/// A distinct alias for the same reason [`IlixiumCaptureResponse`] is one. +pub type IlixiumVoidResponse = IlixiumPaymentResponse; + +/// The **PreAuthenticate** leg posts the very same `/direct/auth` body as Authorize does. +/// +/// An alias rather than a reuse of the ident: `create_all_prerequisites!` mints one +/// `Templating` marker struct per named request ident, so a second flow cannot name +/// `IlixiumPaymentsRequest` directly without defining that marker twice. +pub type IlixiumPreAuthenticateRequest = IlixiumPaymentsRequest; + +/// `/direct/auth` answers the same `paymentResponse` envelope on the PreAuthenticate leg as it +/// does on Authorize. A distinct alias for the same macro reason as +/// [`IlixiumPreAuthenticateRequest`]. +pub type IlixiumPreAuthenticateResponse = IlixiumPaymentResponse; + +/// `/direct/refund` returns the same `paymentResponse` envelope, with `type` = `REFUND`. +/// A distinct alias for the same reason [`IlixiumCaptureResponse`] is one. +/// +/// Note what this envelope does **not** contain: any refund-specific identifier. +/// `transaction.merchantRef` and `transaction.gatewayRef` both echo the *original payment* — +/// the vendor's own published history example shows an `AUTH_CAP` and a `REFUND` sharing both +/// values — and the schema has no `refundId` field. See [`refund_identifier`]. +pub type IlixiumRefundResponse = IlixiumPaymentResponse; + +impl IlixiumPaymentResponse { + pub fn latest_attempt(&self) -> Option<&IlixiumPaymentAttempt> { + self.payment_history + .as_ref() + .and_then(|history| history.payment_attempt.as_ref()) + .and_then(IlixiumPaymentAttempts::latest) + } + + pub fn card_response(&self) -> Option<&IlixiumCardResponse> { + self.latest_attempt() + .and_then(|attempt| attempt.card_response.as_ref()) + } + + pub fn gateway_ref(&self) -> Option { + self.transaction + .as_ref() + .and_then(|transaction| transaction.gateway_ref.clone()) + } + + pub fn merchant_ref(&self) -> Option { + self.transaction + .as_ref() + .and_then(|transaction| transaction.merchant_ref.clone()) + } + + pub fn reason_codes(&self) -> Vec { + self.status + .reasons + .as_ref() + .map(IlixiumReasons::codes) + .unwrap_or_default() + } + + /// The 3DS branch is gated on the *structure* of the response, never on a reason code. + /// + /// Tech spec CONFLICT #7: `/docs/response-codes/` documents `9` as "3D Secure Required" + /// while the 3DS guide's own PENDING example carries `502`. Both are unreliable, so the + /// decision here is `status.code == PENDING` **and** `threeDSecureStatus == PENDING` + /// **and** an ACS URL is actually present — the three things that must all hold for a + /// redirect to be possible at all. + pub fn three_ds_acs_url(&self) -> Option<&str> { + if self.status.code != IlixiumStatusCode::Pending { + return None; + } + let card_response = self.card_response()?; + if card_response.three_d_secure_status != Some(IlixiumThreeDsStatus::Pending) { + return None; + } + card_response + .three_d_secure_acs_url + .as_deref() + .filter(|url| !url.is_empty()) + } +} + +/// Maps the request-level `status.code` onto a UCS attempt status. +/// +/// `SUCCESS` splits on the operation type Ilixium echoes back: `AUTH_CAP` means the funds were +/// captured in the same call (`Charged`), `AUTH` means authorised-only (`Authorized`). When the +/// platform omits `type`, fall back to what the request asked for. +fn map_attempt_status( + response: &IlixiumPaymentResponse, + requested_auto_capture: bool, +) -> AttemptStatus { + match response.status.code { + IlixiumStatusCode::Success => match response.operation_type { + Some(IlixiumOperationType::AuthCap) => AttemptStatus::Charged, + Some(IlixiumOperationType::Auth) => AttemptStatus::Authorized, + _ => { + if requested_auto_capture { + AttemptStatus::Charged + } else { + AttemptStatus::Authorized + } + } + }, + IlixiumStatusCode::Pending => { + if response.three_ds_acs_url().is_some() { + AttemptStatus::AuthenticationPending + } else { + AttemptStatus::Pending + } + } + IlixiumStatusCode::Cancelled => AttemptStatus::Voided, + IlixiumStatusCode::Declined | IlixiumStatusCode::Rejected | IlixiumStatusCode::Error => { + AttemptStatus::Failure + } + // RESUBMISSION appears in the OpenAPI enum but is undocumented; treat it, and any code + // this integration does not recognise, as still-in-flight rather than guessing a + // terminal outcome. + IlixiumStatusCode::Resubmission | IlixiumStatusCode::Unknown => AttemptStatus::Pending, + } +} + +/// Maps a `/direct/capture` response onto a UCS attempt status. +/// +/// Kept separate from [`map_attempt_status`] because the same `status.code` means something +/// different here: `SUCCESS` on a capture is unambiguously `Charged` (there is no +/// authorised-only outcome to distinguish), and a failure is a *capture* failure — the +/// authorisation itself is untouched and can still be captured again or reversed, which +/// `CaptureFailed` conveys and the generic `Failure` does not. +fn map_capture_status(response: &IlixiumPaymentResponse) -> AttemptStatus { + match response.status.code { + IlixiumStatusCode::Success => AttemptStatus::Charged, + // The platform permits one operation at a time per transaction, so a capture that is + // still in flight has been accepted but not settled. + IlixiumStatusCode::Pending => AttemptStatus::CaptureInitiated, + IlixiumStatusCode::Cancelled => AttemptStatus::Voided, + IlixiumStatusCode::Declined | IlixiumStatusCode::Rejected | IlixiumStatusCode::Error => { + AttemptStatus::CaptureFailed + } + // As in the Authorize mapping: RESUBMISSION is in the OpenAPI enum but undocumented, + // so it — and any code this integration does not recognise — is treated as still in + // flight rather than as a guessed terminal outcome. + IlixiumStatusCode::Resubmission | IlixiumStatusCode::Unknown => { + AttemptStatus::CaptureInitiated + } + } +} + +/// Maps a `/direct/reversal` response onto a UCS attempt status. +/// +/// A sibling of [`map_capture_status`] rather than a reuse of it: the same `status.code` carries +/// a different meaning on a reversal. `SUCCESS` is `Voided` (the ring-fenced funds are released +/// back to the cardholder), and a failure is a *void* failure — the authorisation survives and +/// can still be captured, or reversed again once whatever blocked it clears — which is what +/// `VoidFailed` conveys and `CaptureFailed`/`Failure` do not. +fn map_void_status(response: &IlixiumPaymentResponse) -> AttemptStatus { + match response.status.code { + IlixiumStatusCode::Success => AttemptStatus::Voided, + // `CANCELLED` on a reversal envelope is the platform reporting the *transaction* as + // cancelled, which is the same end state a successful reversal produces. + IlixiumStatusCode::Cancelled => AttemptStatus::Voided, + // PENDING is not documented for Reversal, but the platform permits one operation at a + // time per transaction (code 117), so a reversal still in flight has been accepted + // without having released the funds yet. + IlixiumStatusCode::Pending => AttemptStatus::VoidInitiated, + IlixiumStatusCode::Declined | IlixiumStatusCode::Rejected | IlixiumStatusCode::Error => { + AttemptStatus::VoidFailed + } + // As in the Authorize and Capture mappings: RESUBMISSION is in the OpenAPI enum but + // undocumented, so it — and any code this integration does not recognise — is treated + // as still in flight rather than as a guessed terminal outcome. + IlixiumStatusCode::Resubmission | IlixiumStatusCode::Unknown => { + AttemptStatus::VoidInitiated + } + } +} + +/// Plain-language expansion of the failure codes `/direct/capture` can return. +/// +/// Ilixium's `status.message` is generic ("Validation failed", "Operation rejected"); the +/// actionable detail is the numeric code in `status.reasons.reason`, and these six are the ones +/// the capture endpoint documents. Note that 112 ("already captured") is still reported as a +/// failed capture rather than as `Charged`: it says a capture happened, not that *this* request +/// captured anything, and treating it as success would mask a duplicate-capture bug. +fn capture_failure_hint(code: &str) -> Option<&'static str> { + match code { + "103" => Some("the capture currency does not match the original transaction's currency"), + "104" => Some( + "no transaction matching transaction.merchantRef was found on this merchant account", + ), + "105" => { + Some("the original transaction was not successful, so there is nothing to capture") + } + "108" => Some( + "the original transaction was not of type AUTH — only an authorisation sent with \ + deferredCapture can be captured", + ), + "112" => Some("the transaction has already been captured"), + "117" => Some( + "another operation is already in progress on this transaction — Ilixium permits \ + exactly one at a time", + ), + _ => None, + } +} + +/// [`build_error_response`] with the documented capture codes spelled out in `reason`. Every +/// hint is prefixed with its own code, so nothing the generic builder would have reported is +/// lost. +fn build_capture_error_response( + response: &IlixiumPaymentResponse, + status: AttemptStatus, + http_code: u16, +) -> ErrorResponse { + let mut error = build_error_response(response, status, http_code); + let hints: Vec = response + .reason_codes() + .iter() + .filter_map(|code| capture_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + if !hints.is_empty() { + error.reason = Some(hints.join("; ")); + } + error +} + +/// Plain-language expansion of the failure codes `/direct/reversal` can return. +/// +/// A sibling of [`capture_failure_hint`], not an extension of it: the two endpoints share only +/// 103/104/105/117, and the rest of each set is meaningless on the other flow. In particular +/// **142** — the partial-reversal rejection — has no capture counterpart at all (capture has no +/// "amount does not match original" code, because partial capture is permitted), and 108 +/// ("cannot CAPTURE") is capture-only. +fn reversal_failure_hint(code: &str) -> Option<&'static str> { + match code { + "103" => Some("the reversal currency does not match the original transaction's currency"), + "104" => Some( + "no transaction matching transaction.merchantRef was found on this merchant account", + ), + "105" => { + Some("the original transaction was not authorised, so there is nothing to reverse") + } + "106" => Some("the transaction has already been reversed"), + "109" => Some("the original transaction was not of a reversible type"), + "110" => Some( + "the transaction has already been settled — a refund is required instead of a \ + reversal", + ), + "112" => Some( + "the transaction has already been captured, and the reversal falls outside the \ + reversal-after-capture window configured for this merchant — a refund is required \ + instead", + ), + "117" => Some( + "another operation is already in progress on this transaction — Ilixium permits \ + exactly one at a time", + ), + "142" => Some( + "the reversal amount does not match the original transaction — Ilixium does not \ + support partial voids/reversals, so transaction.amount must equal the original \ + amount exactly", + ), + _ => None, + } +} + +/// [`build_error_response`] with the documented reversal codes spelled out in `reason`. Every +/// hint is prefixed with its own code, so nothing the generic builder would have reported is +/// lost. +fn build_void_error_response( + response: &IlixiumPaymentResponse, + status: AttemptStatus, + http_code: u16, +) -> ErrorResponse { + let mut error = build_error_response(response, status, http_code); + let hints: Vec = response + .reason_codes() + .iter() + .filter_map(|code| reversal_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + if !hints.is_empty() { + error.reason = Some(hints.join("; ")); + } + error +} + +/// Maps a `/direct/refund` response onto a UCS **refund** status. +/// +/// A *fourth sibling* of [`map_attempt_status`] / [`map_capture_status`] / [`map_void_status`], +/// not a reuse of any of them, for two reasons. First, the target enum is different: +/// [`RefundStatus`], not `AttemptStatus` — a refund outcome says nothing about the payment +/// attempt, which stays `Charged` throughout. Second, the same `status.code` means a third thing +/// again: `SUCCESS` is a completed refund, and a failure here leaves the payment intact and +/// still refundable for a *different* amount (a 125 "exceeds original" rejection is retryable +/// with a smaller value), which `RefundStatus::Failure` conveys without implying anything about +/// the payment. +fn map_refund_status(response: &IlixiumPaymentResponse) -> RefundStatus { + match response.status.code { + IlixiumStatusCode::Success => RefundStatus::Success, + // PENDING is not documented for Refund. Ilixium permits one operation at a time per + // transaction (code 117), so a refund still in flight has been accepted without the + // funds having moved yet; RSync reconciles it from /history/operations. + IlixiumStatusCode::Pending => RefundStatus::Pending, + // A refund is a money-out operation that the *merchant* initiates, so `CANCELLED` — "the + // customer chose to cancel the transaction" — describes the original payment, not this + // refund. Nothing was refunded, and unlike Void there is no end state it coincides with. + IlixiumStatusCode::Cancelled + | IlixiumStatusCode::Declined + | IlixiumStatusCode::Rejected + | IlixiumStatusCode::Error => RefundStatus::Failure, + // As in the three payment mappings: RESUBMISSION is in the OpenAPI enum but undocumented, + // so it — and any code this integration does not recognise — is treated as still in + // flight rather than as a guessed terminal outcome. + IlixiumStatusCode::Resubmission | IlixiumStatusCode::Unknown => RefundStatus::Pending, + } +} + +/// Plain-language expansion of the failure codes `/direct/refund` can return. +/// +/// A sibling of [`capture_failure_hint`] / [`reversal_failure_hint`], not an extension of +/// either: the refund set shares only 103/104/105/117 with them and diverges everywhere else. +/// 107/120/124/125 and the 204 account limit have no capture or reversal counterpart, while 108 +/// (capture-only) and 106/109/110/142 (reversal-only) are meaningless here. +/// +/// 111 is the one that most often surprises an integrator: an *uncaptured* authorisation cannot +/// be refunded at all — it must be reversed (Void) instead. +fn refund_failure_hint(code: &str) -> Option<&'static str> { + match code { + "103" => Some("the refund currency does not match the original transaction's currency"), + "104" => Some( + "no transaction matching transaction.merchantRef was found on this merchant account", + ), + "105" => Some("the original transaction was not successful, so there is nothing to refund"), + "107" => Some("the original transaction has already been refunded"), + "111" => Some( + "the original transaction has not been captured — capture it first, or reverse the \ + authorisation (Void) instead of refunding it", + ), + "117" => Some( + "another operation is already in progress on this transaction — Ilixium permits \ + exactly one at a time", + ), + "120" => Some( + "the original payment is not refundable through the Direct API — it used a payment \ + method the Direct API cannot refund", + ), + "121" => Some("the supplied payment method details are invalid"), + "124" => Some( + "the transaction has already been fully refunded — no more refunds can be made \ + against it", + ), + "125" => Some( + "the refund amount would take the total refunded above the original authorised \ + amount — refunds accumulate, so only the unrefunded remainder can still be refunded", + ), + "204" => Some("the account's transaction amount limit has been reached"), + _ => None, + } +} + +/// The refund counterpart of [`build_capture_error_response`] / [`build_void_error_response`]. +/// +/// Written out rather than layered on [`build_error_response`] because that builder stamps +/// `attempt_status: FlowStatus::Payment(..)`, and a refund failure must report +/// `FlowStatus::Refund(..)` — the payment attempt itself is untouched by a failed refund. +fn build_refund_error_response( + response: &IlixiumPaymentResponse, + status: RefundStatus, + http_code: u16, +) -> ErrorResponse { + let reason_codes = response.reason_codes(); + let attempt_message = response + .latest_attempt() + .and_then(|attempt| attempt.message.clone()); + let message = response + .status + .message + .clone() + .or(attempt_message) + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()); + + let hints: Vec = reason_codes + .iter() + .filter_map(|code| refund_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + + ErrorResponse { + status_code: http_code, + // As everywhere else in this connector: the machine-readable detail is + // status.reasons.reason, and status.code is the coarse bucket used when no reasons are + // sent at all. + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", response.status.code).to_uppercase()), + message, + reason: if !hints.is_empty() { + Some(hints.join("; ")) + } else if reason_codes.is_empty() { + response.status.message.clone() + } else { + Some(reason_codes.join(", ")) + }, + attempt_status: Some(domain_types::router_data::FlowStatus::Refund(status)), + connector_transaction_id: response.gateway_ref(), + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// Resolves `RefundsResponseData::connector_refund_id`. +/// +/// **Ilixium mints no refund identifier.** `transaction.merchantRef` and +/// `transaction.gatewayRef` in a refund response both echo the *original payment* — the vendor's +/// published `/history/operations` example shows an `AUTH_CAP` and a `REFUND` sharing both values +/// — and `paymentResponse` has no `refundId` field. `/docs/direct/reconciliation` says so +/// outright: "The `merchantRef` field is designed to only identify the transaction, not the +/// operations within it." +/// +/// The resolution order is therefore: +/// +/// 1. **`status.operationRef`** — a ULID that "will uniquely identify each operation" and the +/// only refund-unique value the API defines. The same page warns it "will be available soon", +/// so it may simply be absent. +/// 2. **`paymentHistory.paymentAttempt[latest].operationRef`** — the same value, echoed +/// per attempt; checked in case the platform populates one place before the other. +/// 3. **The refund's own UCS reference** (`merchant_refund_id`, validated in +/// [`resolve_refund_reference`] when the request was built). Deterministic, unique per refund, +/// and honest about its origin. +/// +/// What is deliberately *not* used is the payment's `merchantRef` or `gatewayRef`: reusing either +/// would give every partial refund on one payment the same `connector_refund_id`. +/// +/// **Known limitation:** until `operationRef` goes live, N partial refunds against one payment are +/// distinguished only by the UCS-side reference. Ilixium itself cannot tell them apart — +/// `/history/operations` returns N `type: REFUND` entries differing only by `entryDate`, +/// `processedDate` and `amount` — so an id from step 3 cannot be resolved back through any Ilixium +/// endpoint. +fn refund_identifier( + response: &IlixiumPaymentResponse, + refund_reference: Option, + http_code: u16, +) -> Result> { + let operation_ref = response + .status + .operation_ref + .clone() + .or_else(|| { + response + .latest_attempt() + .and_then(|attempt| attempt.operation_ref.clone()) + }) + .filter(|value| !value.is_empty()); + + if let Some(operation_ref) = operation_ref { + return Ok(operation_ref); + } + + tracing::debug!( + "Ilixium returned no status.operationRef on a refund response; falling back to the UCS \ + refund reference as connector_refund_id. Multiple partial refunds on one payment are \ + not individually distinguishable at Ilixium until operationRef goes live." + ); + + // Unreachable in practice: `IlixiumRefundRequest::try_from` refuses to build a body without + // a refund reference, so a response can only exist when one was present. Kept as a loud + // failure rather than an empty id in case that invariant is ever broken. + refund_reference.ok_or_else(|| { + error_stack::report!(errors::ConnectorError::ResponseDeserializationFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(http_code), + additional_context: Some( + "Ilixium's refund response carries no status.operationRef and UCS supplied \ + no merchant_refund_id, so this refund has no identifier: the response's \ + merchantRef and gatewayRef both echo the original payment and would collide \ + across partial refunds." + .to_string(), + ), + }, + }) + }) +} + +/// Builds the ACS auto-POST form. Ilixium never receives the return URL — the merchant owns +/// the `TermUrl` — so the connector assembles the form itself (tech spec UNDECIDED #5, +/// option (a)). The 1.x MPI field names `MD`/`PaReq` are retained by Ilixium even for 3DS2. +/// +/// `term_url` is supplied by the caller rather than read off the request, because the two flows +/// that can receive a 3DS challenge hold it under different names and types: `Authorize` has +/// `complete_authorize_url`/`router_return_url` (`Option`), `PreAuthenticate` has +/// `continue_redirection_url`/`router_return_url` (`Option`). +fn build_three_ds_redirect_form( + response: &IlixiumPaymentResponse, + acs_url: &str, + term_url: Option, + http_code: u16, +) -> Result> { + let deserialization_error = |detail: &str| { + error_stack::report!(errors::ConnectorError::ResponseDeserializationFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(http_code), + additional_context: Some(detail.to_string()), + }, + }) + }; + + let card_response = response.card_response().ok_or_else(|| { + deserialization_error( + "Ilixium returned PENDING with an ACS URL but no cardResponse to read \ + threeDSecureMd / threeDSecurePaReq from", + ) + })?; + + let md = card_response.three_d_secure_md.as_ref().ok_or_else(|| { + deserialization_error("Ilixium 3DS PENDING response is missing threeDSecureMd") + })?; + let pa_req = card_response + .three_d_secure_pa_req + .as_ref() + .ok_or_else(|| { + deserialization_error("Ilixium 3DS PENDING response is missing threeDSecurePaReq") + })?; + + // The ACS posts `md`/`paRes` back to TermUrl, which must be the UCS endpoint that + // re-invokes Authorize; `complete_authorize_url` / `continue_redirection_url` is exactly + // that. `router_return_url` is the merchant-facing landing page and is only a fallback. + let term_url = term_url.ok_or_else(|| { + error_stack::report!(errors::ConnectorError::ResponseHandlingFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(http_code), + additional_context: Some( + "Ilixium 3DS requires a TermUrl for the ACS form, but neither the \ + completion URL nor router_return_url was supplied" + .to_string(), + ), + }, + }) + })?; + + let mut form_fields = std::collections::HashMap::with_capacity(3); + form_fields.insert("MD".to_string(), md.peek().to_owned()); + form_fields.insert("PaReq".to_string(), pa_req.peek().to_owned()); + form_fields.insert("TermUrl".to_string(), term_url); + + Ok(RedirectForm::Form { + endpoint: acs_url.to_string(), + method: common_utils::request::Method::Post, + form_fields, + }) +} + +fn build_error_response( + response: &IlixiumPaymentResponse, + status: AttemptStatus, + http_code: u16, +) -> ErrorResponse { + let reason_codes = response.reason_codes(); + let attempt_message = response + .latest_attempt() + .and_then(|attempt| attempt.message.clone()); + let message = response + .status + .message + .clone() + .or(attempt_message) + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()); + + ErrorResponse { + status_code: http_code, + // Ilixium's machine-readable failure detail lives in status.reasons.reason; the + // status.code itself is the coarse bucket and is the only thing available when the + // platform sends no reasons at all. + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", response.status.code).to_uppercase()), + message, + reason: if reason_codes.is_empty() { + response.status.message.clone() + } else { + Some(reason_codes.join(", ")) + }, + attempt_status: Some(domain_types::router_data::FlowStatus::Payment(status)), + connector_transaction_id: response.gateway_ref(), + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +impl + TryFrom> + for RouterDataV2, PaymentsResponseData> +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let status = map_attempt_status(&response, item.router_data.request.is_auto_capture()); + + let redirection_data = match response.three_ds_acs_url() { + Some(acs_url) => { + let request = &item.router_data.request; + Some(Box::new(build_three_ds_redirect_form( + &response, + acs_url, + request + .complete_authorize_url + .clone() + .or_else(|| request.router_return_url.clone()), + item.http_code, + )?)) + } + None => None, + }; + + let payments_response = if status == AttemptStatus::Failure { + Err(build_error_response(&response, status, item.http_code)) + } else { + Ok(PaymentsResponseData::TransactionResponse { + // `transaction.gatewayRef` is Ilixium's own unique reference and the only id + // that identifies the transaction for follow-up operations. Validation + // rejections never reach here (they map to Failure above), but a PENDING + // response before the gateway has minted one still needs a sensible fallback. + resource_id: response + .gateway_ref() + .map(ResponseId::ConnectorTransactionId) + .unwrap_or(ResponseId::NoResponseId), + redirection_data, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + network_txn_link_id: None, + // The echoed `transaction.merchantRef`. When the UCS reference had to be + // hashed down to fit Ilixium's 20-character `[\w]` limit, this is the only + // place the caller can learn the reference Ilixium's back office actually + // knows the payment by, so surfacing it here is what makes reconciliation + // possible. + connector_response_reference_id: response.merchant_ref(), + incremental_authorization_allowed: None, + splits: None, + status_code: item.http_code, + }) + }; + + Ok(Self { + response: payments_response, + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +/// `POST /direct/auth` response on the **PreAuthenticate** leg. +/// +/// The same `paymentResponse` envelope and the same status mapping the Authorize leg uses — this +/// *is* the authorisation, so `map_attempt_status` applies unchanged. Only the response variant +/// differs. +/// +/// Note this leg is terminal in two of its three outcomes. Ilixium decides at response time +/// whether to challenge, so a `SUCCESS` here means the payment is already charged and there is no +/// second leg. HS suppresses the follow-up Authorize (its `should_continue` default is `false`) +/// and finalises the attempt from the status set below, so nothing further is required here to +/// avoid a duplicate `/direct/auth` — see response code 102, "Duplicate Merchant Ref". +impl + TryFrom> + for RouterDataV2< + PreAuthenticate, + PaymentFlowData, + PaymentsPreAuthenticateData, + PaymentsResponseData, + > +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + // The PreAuthenticate leg only ever runs for a 3DS payment, which UCS models as + // auto-capture unless the caller said otherwise; `deferredCapture` was derived from the + // same value on the way out, so the response is read back the same way. + let requested_auto_capture = item.router_data.request.is_auto_capture().unwrap_or(true); + let status = map_attempt_status(&response, requested_auto_capture); + + let redirection_data = match response.three_ds_acs_url() { + Some(acs_url) => { + let request = &item.router_data.request; + Some(Box::new(build_three_ds_redirect_form( + &response, + acs_url, + request + .continue_redirection_url + .as_ref() + .or(request.router_return_url.as_ref()) + .map(ToString::to_string), + item.http_code, + )?)) + } + None => None, + }; + + let payments_response = if status == AttemptStatus::Failure { + Err(build_error_response(&response, status, item.http_code)) + } else { + Ok(PaymentsResponseData::PreAuthenticateResponse { + resource_id: Some( + response + .gateway_ref() + .map(ResponseId::ConnectorTransactionId) + .unwrap_or(ResponseId::NoResponseId), + ), + // Ilixium performs 3-D Secure inside the platform and returns no CAVV/ECI to the + // merchant, so there is no authentication payload to carry forward. The two legs + // are linked by `transaction.merchantRef`, which both recompute deterministically + // from the same UCS reference — see `derive_merchant_ref`. + authentication_data: None, + redirection_data, + connector_response_reference_id: response.merchant_ref(), + status_code: item.http_code, + }) + }; + + Ok(Self { + response: payments_response, + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let status = map_void_status(&response); + + let payments_response = if status == AttemptStatus::VoidFailed { + Err(build_void_error_response(&response, status, item.http_code)) + } else { + Ok(PaymentsResponseData::TransactionResponse { + // A reversal echoes the *original payment's* `transaction.gatewayRef` — the + // connector transaction id UCS already holds. The only reversal-unique value + // Ilixium mints is `status.operationRef`, which is not a transaction + // identifier, so falling back to the id the request came in with keeps the + // payment addressable even if the platform omits the echo. + resource_id: ResponseId::ConnectorTransactionId( + response.gateway_ref().unwrap_or_else(|| { + item.router_data.request.connector_transaction_id.clone() + }), + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + network_txn_link_id: None, + // The echoed `transaction.merchantRef` — the original authorisation's + // reference, and the value Ilixium's back office knows the payment by once the + // UCS reference has been hashed down to the 20-character `[\w]` limit. + connector_response_reference_id: response.merchant_ref(), + incremental_authorization_allowed: None, + splits: None, + status_code: item.http_code, + }) + }; + + Ok(Self { + response: payments_response, + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let status = map_capture_status(&response); + + let payments_response = if status == AttemptStatus::CaptureFailed { + Err(build_capture_error_response( + &response, + status, + item.http_code, + )) + } else { + Ok(PaymentsResponseData::TransactionResponse { + // A capture echoes the *original payment's* `transaction.gatewayRef`, which is + // exactly the connector transaction id UCS already holds; the only + // capture-unique value Ilixium mints is `status.operationRef`, which is not a + // transaction identifier. Falling back to the id the request came in with + // keeps the payment addressable even if the platform omits the echo. + resource_id: response + .gateway_ref() + .map(ResponseId::ConnectorTransactionId) + .unwrap_or_else(|| item.router_data.request.connector_transaction_id.clone()), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + network_txn_link_id: None, + // The echoed `transaction.merchantRef` — the original authorisation's + // reference, and the value Ilixium's back office knows the payment by once the + // UCS reference has been hashed down to the 20-character `[\w]` limit. + connector_response_reference_id: response.merchant_ref(), + incremental_authorization_allowed: None, + splits: None, + status_code: item.http_code, + }) + }; + + Ok(Self { + response: payments_response, + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let status = map_refund_status(&response); + + let refunds_response = if status == RefundStatus::Failure { + Err(build_refund_error_response( + &response, + status, + item.http_code, + )) + } else { + Ok(RefundsResponseData { + connector_refund_id: refund_identifier( + &response, + refund_reference( + &item.router_data.request, + &item.router_data.resource_common_data, + ), + item.http_code, + )?, + refund_status: status, + status_code: item.http_code, + // `cardResponse.acquirerRef` is only ever populated on payment attempts; the + // refund envelope's paymentAttempt carries no acquirer reference number, and + // Ilixium documents none for refunds. + acquirer_reference_number: None, + }) + }; + + Ok(Self { + response: refunds_response, + resource_common_data: RefundFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// ============================================================================= +// PSYNC — POST /history/operations +// ============================================================================= +// +// Ilixium publishes no per-payment status endpoint on the Direct API (tech spec, *Why PSync / +// RSync cannot use the status endpoints*). `POST /history/operations` is the only query it +// offers, and it is a **bulk reconciliation report**: +// +// * it is filtered **only** by merchant + time window — there is no `merchantRef`, `gatewayRef`, +// `operationRef`, `customerId`, `type` or `status` parameter, so every payment-level filter is +// applied client-side over the returned `operation[]` array; +// * the window is capped at **24 hours**; +// * unlike every other Ilixium message, the request carries **no `version` field**; +// * `periodStartDate` / `periodEndDate` must match `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$` — +// no fractional seconds and no numeric offset — even though the timestamps the API *returns* +// carry milliseconds, so a returned `entryDate` can never be fed back unmodified. + +/// The maximum period `/history/operations` accepts, in hours. Exceeding it is a validation +/// rejection (`VA73` family), not a truncation. +const HISTORY_MAX_WINDOW_HOURS: i64 = 24; + +/// `connector_feature_data` keys through which a caller can anchor the history window on the +/// payment's own creation time instead of on "now". See [`resolve_history_window`]. +const HISTORY_PERIOD_START_KEYS: [&str; 2] = + ["ilixium_history_period_start", "history_period_start"]; + +/// Renders one period bound in the *only* shape `historyOperationsRequest` accepts: +/// `yyyy-MM-ddTHH:mm:ssZ`, with a literal `Z` and **no fractional seconds**. +/// +/// The instant is first shifted to UTC, because the literal `Z` in the pattern is an assertion +/// about the offset, not a formatting directive — rendering a `+05:30` wall clock under a `Z` +/// suffix would silently shift the window by the offset. +fn format_history_period( + instant: OffsetDateTime, +) -> Result> { + let format = + time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z"); + instant + .to_offset(UtcOffset::UTC) + .format(&format) + .change_context(errors::IntegrationError::RequestEncodingFailed { + context: errors::IntegrationErrorContext { + suggested_action: None, + doc_url: Some("https://docs.ilixium.com/docs/direct/history".to_string()), + additional_context: Some( + "Failed to render a POST /history/operations period bound as \ + yyyy-MM-ddTHH:mm:ssZ." + .to_string(), + ), + }, + }) +} + +/// Reads the caller-supplied window anchor out of `PaymentsSyncData::connector_feature_data`. +/// +/// Accepted as an RFC 3339 instant (`2025-12-09T11:56:11Z`, or with an offset / fractional +/// seconds — this is *input* to the connector, not the wire format, so it is parsed leniently and +/// re-rendered by [`format_history_period`]). A value that is present but unparsable is a hard +/// error: silently falling back to "now" would answer a question the caller did not ask. +fn extract_history_period_start( + feature_data: Option<&common_utils::pii::SecretSerdeValue>, +) -> Result, error_stack::Report> { + let Some(feature_data) = feature_data else { + return Ok(None); + }; + let value = feature_data.clone().expose(); + let Some(raw) = HISTORY_PERIOD_START_KEYS + .iter() + .find_map(|key| value.get(*key).and_then(|found| found.as_str())) + else { + return Ok(None); + }; + + OffsetDateTime::parse(raw, &Rfc3339) + .map(Some) + .change_context(errors::IntegrationError::InvalidDataFormat { + field_name: "connector_feature_data.ilixium_history_period_start", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "Supply the start of the /history/operations window as an RFC 3339 instant, \ + e.g. \"2025-12-09T11:56:11Z\". It should be at or just before the payment's \ + creation time; Ilixium reports at most 24 hours from that point." + .to_string(), + ), + doc_url: Some("https://docs.ilixium.com/docs/direct/history".to_string()), + additional_context: None, + }, + }) +} + +/// Picks the `[periodStartDate, periodEndDate]` pair for this sync. +/// +/// **Why it is not anchored on the payment automatically.** Neither `PaymentsSyncData` nor +/// `PaymentFlowData` carries a creation timestamp — there is no `created_at`, and the only +/// time-shaped value anywhere on the PSync `RouterDataV2` is whatever the caller puts in +/// `connector_feature_data`. So: +/// +/// * **Caller-anchored** — when `connector_feature_data.ilixium_history_period_start` is present, +/// the window is `[start, min(start + 24h, now)]`. This is the accurate mode: a caller that +/// knows when the payment was created can sync it at any age. +/// * **Default** — otherwise `[now - 24h, now]`, the widest window the API allows. A payment +/// whose operations all fall outside it simply will not appear in the report, which surfaces as +/// the explicit "not found in window" error built by [`build_history_not_found_error`] rather +/// than as a guessed status. +/// +/// The 24-hour cap is satisfied by construction — the window is *clamped*, never widened — so the +/// platform can never answer `VA73` because of a period this function chose. +fn resolve_history_window( + feature_data: Option<&common_utils::pii::SecretSerdeValue>, +) -> Result<(OffsetDateTime, OffsetDateTime), error_stack::Report> { + let now = OffsetDateTime::now_utc(); + let max_window = Duration::hours(HISTORY_MAX_WINDOW_HOURS); + + let overflow = |bound: &'static str| { + error_stack::report!(errors::IntegrationError::InvalidDataFormat { + field_name: "connector_feature_data.ilixium_history_period_start", + context: errors::IntegrationErrorContext { + suggested_action: Some(format!( + "The supplied history window start is so far from the representable range \ + that the {bound} could not be computed. Supply a real payment creation \ + time." + )), + doc_url: Some("https://docs.ilixium.com/docs/direct/history".to_string()), + additional_context: None, + }, + }) + }; + + match extract_history_period_start(feature_data)? { + Some(start) => { + if start >= now { + return Err(error_stack::report!( + errors::IntegrationError::InvalidDataFormat { + field_name: "connector_feature_data.ilixium_history_period_start", + context: errors::IntegrationErrorContext { + suggested_action: Some( + "The history window start must be in the past — Ilixium reports \ + operations that have already happened. Supply the payment's \ + creation time." + .to_string(), + ), + doc_url: Some( + "https://docs.ilixium.com/docs/direct/history".to_string() + ), + additional_context: None, + }, + } + )); + } + let end = start + .checked_add(max_window) + .ok_or_else(|| overflow("period end"))? + .min(now); + Ok((start, end)) + } + None => { + let start = now + .checked_sub(max_window) + .ok_or_else(|| overflow("period start"))?; + Ok((start, now)) + } + } +} + +/// `historyOperationsRequest.reportFormat`. Sent explicitly because the Direct API defaults to +/// XML, and this integration parses JSON. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumReportFormat { + Json, +} + +/// `POST /history/operations` body. +/// +/// **There is deliberately no `version` field.** `historyOperationsRequest` is the one Ilixium +/// request schema that does not declare one, so sending [`ILIXIUM_MESSAGE_VERSION`] here would be +/// an unknown property on the message *and* would change the bytes the `X-MERCHANT-DIGEST` is +/// computed over. +/// +/// There is likewise no place to put the payment's identity: the schema's four properties are +/// exactly the two period bounds, `merchant` and `reportFormat`. Selecting one payment out of the +/// report is [`IlixiumHistoryResponse::latest_payment_operation`]'s job. +#[derive(Debug, Clone, Serialize)] +pub struct IlixiumHistoryRequest { + /// `yyyy-MM-ddTHH:mm:ssZ` — see [`format_history_period`]. + #[serde(rename = "periodStartDate")] + pub period_start_date: String, + /// `yyyy-MM-ddTHH:mm:ssZ`, at most 24 hours after `periodStartDate`. + #[serde(rename = "periodEndDate")] + pub period_end_date: String, + pub merchant: IlixiumMerchant, + #[serde(rename = "reportFormat")] + pub report_format: IlixiumReportFormat, +} + +impl + TryFrom< + IlixiumRouterData< + RouterDataV2, + T, + >, + > for IlixiumHistoryRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + // Derived and discarded: the reference has no home in this request body, but it is what + // the response mapping filters `operation[]` on, so a sync that could never match + // anything is refused here rather than after a pointless bulk report has been fetched. + derive_merchant_ref( + &router_data + .resource_common_data + .connector_request_reference_id, + )?; + + let (period_start, period_end) = + resolve_history_window(router_data.request.connector_feature_data.as_ref())?; + + Ok(Self { + period_start_date: format_history_period(period_start)?, + period_end_date: format_history_period(period_end)?, + merchant: IlixiumMerchant::from(&auth), + report_format: IlixiumReportFormat::Json, + }) + } +} + +// ----------------------------------------------------------------------------- +// PSYNC RESPONSE TYPES +// ----------------------------------------------------------------------------- + +/// `historyStatus.code` — a **different enum** from [`IlixiumStatusCode`], which is why this is a +/// sibling type rather than a reuse. +/// +/// It adds `EXCEPTION` and `VALIDATION_ERRORS` and drops `RESUBMISSION`; deserialising a history +/// envelope through `IlixiumStatusCode` would collapse both new values onto its `#[serde(other)]` +/// arm and lose the distinction between "the query was malformed" and "the operation failed". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumHistoryStatusCode { + Success, + Declined, + Rejected, + Error, + Cancelled, + Pending, + /// "An exception has occurred during the processing of the request … please contact support." + Exception, + /// "The request was not accepted because it failed validation checks." The `reasons` element + /// then carries the individual codes (`VA72` = `periodStartDate`, `VA73` = `periodEndDate`). + ValidationErrors, + /// Any code Ilixium adds that this integration does not yet know about. + #[serde(other)] + Unknown, +} + +/// `HistoryTransaction.transactionType` — again a **different enum** from the one every +/// `/direct/*` request and `paymentResponse` uses (`ECOMMERCE` / `MAIL_ORDER` / +/// `TELEPHONE_ORDER`). Reusing [`IlixiumTransactionType`] here would fail to match every value +/// the history report can return. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IlixiumHistoryTransactionType { + CnpEcommerce, + CnpMailOrder, + CnpTelephoneOrder, + #[serde(other)] + Unknown, +} + +/// `historyStatus`, used at two levels with two different meanings: on the envelope it is the +/// outcome of the *query*, and on an `operation[]` entry it is the outcome of that *operation*. +/// +/// Kept separate from [`IlixiumStatus`] purely because of the `code` enum; `reasons` reuses +/// [`IlixiumReasons`], whose shape is identical. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumHistoryStatus { + pub code: IlixiumHistoryStatusCode, + /// The History API does **not** apply ISO-8583 formatting to this field, so the wording + /// differs from the corresponding `paymentResponse` message. Never string-match across the + /// two. + pub message: Option, + pub reasons: Option, + /// `required` in `statusDetails`, yet omitted from the vendor's own success example. + pub timestamp: Option, + #[serde(rename = "operationRef")] + pub operation_ref: Option, +} + +impl IlixiumHistoryStatus { + pub fn reason_codes(&self) -> Vec { + self.reasons + .as_ref() + .map(IlixiumReasons::codes) + .unwrap_or_default() + } +} + +/// `HistoryTransaction` — a different schema from the `transactionDetails` echoed by +/// `paymentResponse`, and much thinner: there is no `paymentHistory` and no `cardResponse` +/// anywhere in a history entry, so PSync can recover **no** `authCode`, `iso8583code`, 3-D Secure +/// field or card token. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumHistoryTransaction { + pub amount: Option, + pub currency: Option, + /// The only value that ties a report entry back to a UCS payment. + #[serde(rename = "merchantRef")] + pub merchant_ref: Option, + #[serde(rename = "parentRef")] + pub parent_ref: Option, + #[serde(rename = "gatewayRef")] + pub gateway_ref: Option, + #[serde(rename = "transactionType")] + pub transaction_type: Option, + #[serde(rename = "recurringMode")] + pub recurring_mode: Option, +} + +/// One `operation[]` entry. +/// +/// Every field is optional even where the schema marks it required: the vendor's own published +/// example omits `processedDate` on an entry that never completed processing, and one +/// unexpectedly-absent field on one unrelated entry must not make the whole report — and +/// therefore every payment's sync — undeserialisable. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumHistoryOperation { + #[serde(rename = "entryDate")] + pub entry_date: Option, + #[serde(rename = "processedDate")] + pub processed_date: Option, + /// Same Operation Type enum as `paymentResponse.type` (this one Ilixium really does share). + #[serde(rename = "type")] + pub operation_type: Option, + #[serde(rename = "customerId")] + pub customer_id: Option, + pub transaction: Option, + pub status: Option, +} + +impl IlixiumHistoryOperation { + /// `entryDate` as an instant. The API returns millisecond-bearing RFC 3339 + /// (`2025-12-09T15:05:01.000Z`), which is *not* the shape the request accepts — see + /// [`format_history_period`]. + fn entry_instant(&self) -> Option { + self.entry_date + .as_deref() + .and_then(|raw| OffsetDateTime::parse(raw, &Rfc3339).ok()) + } + + fn merchant_ref(&self) -> Option<&str> { + self.transaction + .as_ref() + .and_then(|transaction| transaction.merchant_ref.as_deref()) + } + + fn gateway_ref(&self) -> Option { + self.transaction + .as_ref() + .and_then(|transaction| transaction.gateway_ref.clone()) + } + + /// `status.operationRef` — the ULID that "will uniquely identify each operation". The only + /// value that can tell two REFUND entries on one payment apart, and absent from both entries + /// of the vendor's own published example. See [`IlixiumHistoryResponse::match_refund_operation`]. + fn operation_ref(&self) -> Option<&str> { + self.status + .as_ref() + .and_then(|status| status.operation_ref.as_deref()) + .filter(|value| !value.is_empty()) + } + + /// `transaction.amount` parsed from its minor-unit digit string (`^[\d]{1,12}$`). + fn minor_amount(&self) -> Option { + self.transaction + .as_ref() + .and_then(|transaction| transaction.amount.as_deref()) + .and_then(|amount| amount.parse::().ok()) + } +} + +/// Operation types that describe the **payment's own** lifecycle, and are therefore the ones +/// PSync selects between. +/// +/// `REFUND` is excluded because it is RSync's subject, not PSync's — the vendor's own published +/// example has an `AUTH_CAP` and a `REFUND` sharing one `merchantRef`, so a filter that ignored +/// `type` would report a payment's status from its refund. `CREDIT` is a `/direct/credit` payout +/// and is not part of any payment. `PAYMENT` is the APM/hosted-UI surface this connector never +/// touches. +/// +/// `REVERSAL` **is** included even though it is not produced by Authorize or Capture: it is what +/// this connector's own Void flow records, and the tech spec's sync-mapping table maps +/// `REVERSAL` + `SUCCESS` to `Voided`. Omitting it would make PSync report a successfully voided +/// payment as `Authorized`. +fn is_payment_lifecycle_operation(operation_type: IlixiumOperationType) -> bool { + matches!( + operation_type, + IlixiumOperationType::Auth + | IlixiumOperationType::AuthCap + | IlixiumOperationType::Capture + | IlixiumOperationType::Reversal + | IlixiumOperationType::ThreeDsSecureComplete + ) +} + +/// `historyResponse`. +/// +/// A genuinely different envelope from [`IlixiumPaymentResponse`] — different `status` enum, +/// different transaction schema, an `operation[]` array instead of `paymentHistory`, and no +/// `version` or `type` at the top level — so this is a new type rather than an alias. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IlixiumHistoryResponse { + /// The outcome of the **query**, not of any payment. A `SUCCESS` here says nothing at all + /// about the operations in `operation[]`. + pub status: IlixiumHistoryStatus, + /// Absent on some failures and explicitly `[]` on a validation rejection. + #[serde(default)] + pub operation: Vec, +} + +impl IlixiumHistoryResponse { + /// The latest payment-lifecycle operation recorded for `merchant_ref`, or `None` when the + /// payment does not appear in the queried window at all. + /// + /// **Latest, never first.** `/docs/direct/history` is explicit that a late outcome is + /// appended as a *new* entry and that "historical information is never modified", so the most + /// recent `entryDate` is the current truth. Entries whose `entryDate` is missing or + /// unparsable sort below every dated entry (`None < Some` for `Option`), and + /// ties are broken by document order so that a later duplicate still wins. + pub fn latest_payment_operation(&self, merchant_ref: &str) -> Option<&IlixiumHistoryOperation> { + self.operation + .iter() + .enumerate() + .filter(|(_, operation)| { + operation.merchant_ref() == Some(merchant_ref) + && operation + .operation_type + .is_some_and(is_payment_lifecycle_operation) + }) + .max_by(|(left_index, left), (right_index, right)| { + left.entry_instant() + .cmp(&right.entry_instant()) + .then_with(|| left_index.cmp(right_index)) + }) + .map(|(_, operation)| operation) + } +} + +/// Maps one history `operation[]` entry onto a UCS attempt status. +/// +/// A **sibling** of [`map_attempt_status`] / [`map_capture_status`] / [`map_void_status`], not an +/// extension of any of them, for three reasons: the `status.code` enum is +/// [`IlixiumHistoryStatusCode`] rather than [`IlixiumStatusCode`]; the operation type is read off +/// the entry rather than off the envelope, so one mapper has to cover authorisation, capture and +/// reversal outcomes at once; and there is no `cardResponse`, so the 3-D Secure branch that +/// [`map_attempt_status`] relies on cannot exist here. +fn map_history_sync_status( + operation: &IlixiumHistoryOperation, + status: &IlixiumHistoryStatus, + requested_auto_capture: bool, +) -> AttemptStatus { + let operation_type = operation.operation_type; + match status.code { + IlixiumHistoryStatusCode::Success => match operation_type { + Some(IlixiumOperationType::AuthCap) | Some(IlixiumOperationType::Capture) => { + AttemptStatus::Charged + } + Some(IlixiumOperationType::Auth) => AttemptStatus::Authorized, + Some(IlixiumOperationType::Reversal) => AttemptStatus::Voided, + // A completed 3-D Secure authentication finalises whichever authorisation opened it, + // and the history entry does not say whether that authorisation deferred capture — + // so fall back to what this payment asked for, exactly as `map_attempt_status` does + // when the platform omits `type`. + _ => { + if requested_auto_capture { + AttemptStatus::Charged + } else { + AttemptStatus::Authorized + } + } + }, + // Ilixium permits one operation at a time per transaction, so a PENDING entry is an + // operation that has been accepted and not yet resolved. + IlixiumHistoryStatusCode::Pending => match operation_type { + Some(IlixiumOperationType::Capture) => AttemptStatus::CaptureInitiated, + Some(IlixiumOperationType::Reversal) => AttemptStatus::VoidInitiated, + _ => AttemptStatus::Pending, + }, + IlixiumHistoryStatusCode::Cancelled => AttemptStatus::Voided, + IlixiumHistoryStatusCode::Declined + | IlixiumHistoryStatusCode::Rejected + | IlixiumHistoryStatusCode::Error + | IlixiumHistoryStatusCode::Exception + | IlixiumHistoryStatusCode::ValidationErrors => match operation_type { + // A failed capture or reversal leaves the authorisation itself intact, which is what + // the flow-specific failure statuses convey and the generic `Failure` does not. + Some(IlixiumOperationType::Capture) => AttemptStatus::CaptureFailed, + Some(IlixiumOperationType::Reversal) => AttemptStatus::VoidFailed, + _ => AttemptStatus::Failure, + }, + // An unrecognised code is treated as still in flight rather than as a guessed terminal + // outcome — the same choice the three payment-response mappers make. + IlixiumHistoryStatusCode::Unknown => AttemptStatus::Pending, + } +} + +/// Plain-language expansion of the validation codes `/history/operations` can return **about the +/// query itself**. +/// +/// A sibling of [`capture_failure_hint`] / [`reversal_failure_hint`]: this endpoint's failure +/// vocabulary is entirely disjoint from theirs, because nothing here is a payment outcome. +fn history_query_failure_hint(code: &str) -> Option<&'static str> { + match code { + "VA72" | "VB72" | "VC72" => Some( + "periodStartDate was missing or malformed — it must match yyyy-MM-ddTHH:mm:ssZ \ + exactly, with no fractional seconds and a literal Z", + ), + "VA73" | "VB73" | "VC73" => Some( + "periodEndDate was missing or malformed — it must match yyyy-MM-ddTHH:mm:ssZ \ + exactly, and the period it closes may span at most 24 hours", + ), + "VA74" | "VB74" | "VC74" => Some("reportFormat was missing or was not one of JSON / XML"), + _ => None, + } +} + +/// The error surfaced when the *query* failed — `VALIDATION_ERRORS`, `EXCEPTION`, or any other +/// non-`SUCCESS` envelope code. +/// +/// `attempt_status` deliberately echoes the payment's **existing** status: a report this +/// connector could not read says nothing whatsoever about the payment, and inventing a status +/// from a failed query is exactly the mistake this flow has to avoid. +fn build_history_query_error( + response: &IlixiumHistoryResponse, + current_status: AttemptStatus, + http_code: u16, +) -> ErrorResponse { + let reason_codes = response.status.reason_codes(); + let hints: Vec = reason_codes + .iter() + .filter_map(|code| history_query_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + + ErrorResponse { + status_code: http_code, + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", response.status.code).to_uppercase()), + message: response + .status + .message + .clone() + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()), + reason: Some(if hints.is_empty() { + format!( + "POST /history/operations answered {:?}; the payment's status could not be read \ + and is left unchanged.", + response.status.code + ) + } else { + hints.join("; ") + }), + attempt_status: Some(domain_types::router_data::FlowStatus::Payment( + current_status, + )), + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// The error surfaced when the query succeeded but the payment is **not in the report**. +/// +/// This is emphatically *not* a failed payment. Ilixium's report is windowed and capped at 24 +/// hours, so an absent `merchantRef` means one of: the payment is older than the queried window, +/// it is newer than `periodEndDate`, or the platform has not recorded an operation for it yet. +/// None of those is a payment outcome, so `attempt_status` echoes the status the sync started +/// with — `PaymentFlowData::status`, which the framework seeds to `Pending` for a sync — leaving +/// the caller's view of the payment exactly as it was, and the message spells out the one thing +/// the caller can actually do about it. +fn build_history_not_found_error( + merchant_ref: &str, + current_status: AttemptStatus, + http_code: u16, +) -> ErrorResponse { + ErrorResponse { + status_code: http_code, + code: "ILIXIUM_NOT_IN_HISTORY_WINDOW".to_string(), + message: format!( + "Ilixium's operation history contains no payment operation for \ + transaction.merchantRef {merchant_ref} in the queried period. The payment's status \ + is unchanged." + ), + reason: Some( + "POST /history/operations is the only query the Ilixium Direct API offers and it \ + reports a window of at most 24 hours, which this connector defaults to [now - 24h, \ + now]. A payment older than that cannot be synced against the default window: \ + re-issue the sync with connector_feature_data.ilixium_history_period_start set to \ + the payment's creation time (RFC 3339, e.g. \"2025-12-09T11:56:11Z\") and the \ + window will be anchored there instead." + .to_string(), + ), + attempt_status: Some(domain_types::router_data::FlowStatus::Payment( + current_status, + )), + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// The error surfaced when the matched operation itself failed. +/// +/// Unlike the two above, this one *is* a payment outcome, so `attempt_status` carries the mapped +/// failure status. History entries carry no ISO-8583 code and no `cardResponse`, so the reason +/// codes in `status.reasons.reason` are the only machine-readable detail there is. +fn build_history_operation_error( + status: &IlixiumHistoryStatus, + attempt_status: AttemptStatus, + gateway_ref: Option, + http_code: u16, +) -> ErrorResponse { + let reason_codes = status.reason_codes(); + ErrorResponse { + status_code: http_code, + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", status.code).to_uppercase()), + message: status + .message + .clone() + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()), + reason: if reason_codes.is_empty() { + status.message.clone() + } else { + Some(reason_codes.join(", ")) + }, + attempt_status: Some(domain_types::router_data::FlowStatus::Payment( + attempt_status, + )), + connector_transaction_id: gateway_ref, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let current_status = item.router_data.resource_common_data.status; + + // The report is not filterable server-side, so the payment is selected by the same + // merchantRef the Authorize/Capture/Void legs derived — deterministically, from the same + // UCS reference — rather than by any id Ilixium minted. On a sync, + // `connector_request_reference_id` is populated from the request's + // `merchant_transaction_id`, so that field must carry the *original authorisation's* + // reference; omitting it leaves the reference empty and `derive_merchant_ref` refuses it + // by name rather than silently matching nothing. + let merchant_ref = derive_merchant_ref( + &item + .router_data + .resource_common_data + .connector_request_reference_id, + ) + .change_context(errors::ConnectorError::ResponseHandlingFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(item.http_code), + additional_context: Some( + "Could not derive the Ilixium transaction.merchantRef to match this payment \ + against POST /history/operations." + .to_string(), + ), + }, + })?; + + // The envelope status is the outcome of the *query*. Anything but SUCCESS means the + // report could not be produced, which tells us nothing about the payment. + if response.status.code != IlixiumHistoryStatusCode::Success { + return Ok(Self { + response: Err(build_history_query_error( + &response, + current_status, + item.http_code, + )), + ..item.router_data + }); + } + + let Some(operation) = response.latest_payment_operation(&merchant_ref) else { + return Ok(Self { + response: Err(build_history_not_found_error( + &merchant_ref, + current_status, + item.http_code, + )), + ..item.router_data + }); + }; + + // `operation[].status` is schema-required; an entry without one carries no outcome at + // all, so it is handled exactly like an entry that was never recorded. + let Some(operation_status) = operation.status.as_ref() else { + return Ok(Self { + response: Err(build_history_not_found_error( + &merchant_ref, + current_status, + item.http_code, + )), + ..item.router_data + }); + }; + + let status = map_history_sync_status( + operation, + operation_status, + item.router_data.request.is_auto_capture(), + ); + let gateway_ref = operation.gateway_ref(); + let operation_merchant_ref = operation.merchant_ref().map(ToOwned::to_owned); + + // The raw outcome as the platform worded it. History messages omit ISO-8583 codes, so + // this text will not match the corresponding /direct/* response's message. + let raw_connector_status = RawConnectorStatus { + code: Some(format!("{:?}", operation_status.code).to_uppercase()), + message: operation_status.message.clone(), + reason: { + let codes = operation_status.reason_codes(); + (!codes.is_empty()).then(|| codes.join(", ")) + }, + }; + + let payments_response = if matches!( + status, + AttemptStatus::Failure | AttemptStatus::CaptureFailed | AttemptStatus::VoidFailed + ) { + Err(build_history_operation_error( + operation_status, + status, + gateway_ref.clone(), + item.http_code, + )) + } else { + Ok(PaymentsResponseData::TransactionResponse { + // `transaction.gatewayRef` is Ilixium's own reference for the transaction. When + // the report omits it, the id the sync was issued with is still correct, so it + // is echoed rather than downgraded to `NoResponseId`. + resource_id: gateway_ref.clone().map_or_else( + || item.router_data.request.connector_transaction_id.clone(), + ResponseId::ConnectorTransactionId, + ), + // A history entry has no `cardResponse`, so there is no ACS URL, no authCode, no + // 3-D Secure data and no token to recover here — PSync can never produce a + // redirect. + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + network_txn_link_id: None, + connector_response_reference_id: operation_merchant_ref, + incremental_authorization_allowed: None, + splits: None, + status_code: item.http_code, + }) + }; + + Ok(Self { + response: payments_response, + resource_common_data: PaymentFlowData { + status, + raw_connector_status: Some(raw_connector_status), + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// ============================================================================= +// RSYNC — POST /history/operations +// ============================================================================= +// +// **The same endpoint, the same request body and the same envelope as PSync** — only the +// client-side filter over `operation[]` differs. Ilixium publishes no refund-status endpoint and +// no lookup by refund id of any kind, so a refund sync is the identical bulk, `<= 24h` +// reconciliation report, scanned for entries whose `transaction.merchantRef` is the **original +// payment's** and whose `type` is `REFUND`. +// +// `type == "CREDIT"` is deliberately **excluded**: that is a `/direct/credit` payout to a payment +// method — its own request schema, its own error codes (126, 205–208) — and never a refund of a +// payment. The vendor's published example carries one, on a different `merchantRef`, precisely so +// the distinction is observable. +// +// Everything the window and the envelope need is already built for PSync and reused verbatim: +// [`resolve_history_window`], [`format_history_period`], [`extract_history_period_start`], +// [`IlixiumHistoryStatusCode`], [`IlixiumHistoryStatus`], [`IlixiumHistoryTransaction`], +// [`IlixiumHistoryOperation`] and [`IlixiumHistoryResponse`]. The four traps PSync had to handle +// are therefore already handled: the envelope's `status.code` is the history enum (which adds +// `EXCEPTION` / `VALIDATION_ERRORS` and drops `RESUBMISSION`), `transactionType` is the +// `CNP_*` enum, history entries carry no `paymentHistory` / `cardResponse`, and the period bounds +// are rendered as `yyyy-MM-ddTHH:mm:ssZ` with no fractional seconds. + +/// The RSync request body — **the same message PSync sends**, aliased under its own name. +/// +/// The alias is not cosmetic. `create_all_prerequisites!` mints one `Templating` marker +/// struct per named request/response ident, so naming [`IlixiumHistoryRequest`] on a second flow +/// would try to define `IlixiumHistoryRequestTemplating` twice. A distinct ident gives RSync its +/// own marker while keeping exactly one request type, one serialisation and one digest. +pub type IlixiumRefundHistoryRequest = IlixiumHistoryRequest; + +/// The RSync response envelope — **the same message PSync parses**, aliased for the same +/// `Templating` reason as [`IlixiumRefundHistoryRequest`]. +pub type IlixiumRefundHistoryResponse = IlixiumHistoryResponse; + +impl + TryFrom< + IlixiumRouterData< + RouterDataV2, + T, + >, + > for IlixiumRefundHistoryRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: IlixiumRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let router_data = &item.router_data; + let request = &router_data.request; + let auth = IlixiumAuthType::try_from(&router_data.connector_config)?; + + // Derived and discarded, exactly as PSync does — but from `RefundSyncData`, never from + // `RefundFlowData::connector_request_reference_id`, which on this flow is + // `merchant_refund_id` and identifies the *refund*. The value has no home in the request + // body (its four properties are the two period bounds, `merchant` and `reportFormat`), yet + // it is the whole of the client-side filter, so a sync that could never match anything is + // refused here rather than after a pointless bulk report has been fetched. + resolve_original_merchant_ref( + request.connector_order_id.as_deref(), + &request.connector_transaction_id, + )?; + + let (period_start, period_end) = + resolve_history_window(request.connector_feature_data.as_ref())?; + + Ok(Self { + period_start_date: format_history_period(period_start)?, + period_end_date: format_history_period(period_end)?, + merchant: IlixiumMerchant::from(&auth), + report_format: IlixiumReportFormat::Json, + }) + } +} + +// ----------------------------------------------------------------------------- +// RSYNC RESPONSE MATCHING +// ----------------------------------------------------------------------------- + +/// Length of a ULID in its canonical Crockford-Base32 text form. +const OPERATION_REF_LEN: usize = 26; + +/// Whether a value looks like an Ilixium `operationRef`. +/// +/// `/docs/direct/reconciliation` describes `operationRef` as a ULID, which is 26 characters of +/// Crockford Base32. The test is deliberately a *shape* test rather than a strict alphabet check: +/// its only job is to separate a real `operationRef` from the fallback id +/// [`refund_identifier`] hands out when Ilixium omits one (a UCS `merchant_refund_id`, which is a +/// dashed/prefixed value of a quite different length). Being slightly permissive costs nothing — +/// a value that passes but matches no entry simply falls through to the heuristic below. +fn is_operation_ref_shaped(value: &str) -> bool { + value.len() == OPERATION_REF_LEN && value.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// How a `type: REFUND` history entry was selected for the refund being synced. +/// +/// The distinction is carried out of the matcher rather than collapsed inside it because the two +/// arms have genuinely different authority, and the caller logs accordingly. +enum RefundOperationMatch<'a> { + /// Selected by an exact `status.operationRef` match. Authoritative: `operationRef` is + /// documented to uniquely identify an operation. + Exact(&'a IlixiumHistoryOperation), + /// Selected by the documented heuristic because no `operationRef` was available to match on. + /// `candidates` is how many `type: REFUND` entries remained after narrowing — anything above + /// 1 means the choice was genuinely ambiguous. + Heuristic { + operation: &'a IlixiumHistoryOperation, + candidates: usize, + }, +} + +impl RefundOperationMatch<'_> { + fn operation(&self) -> &IlixiumHistoryOperation { + match self { + Self::Exact(operation) => operation, + Self::Heuristic { operation, .. } => operation, + } + } +} + +impl IlixiumHistoryResponse { + /// Selects the `type: REFUND` entry that corresponds to the refund being synced. + /// + /// **The candidate set** is every entry whose `transaction.merchantRef` is the *original + /// payment's* and whose `type` is exactly `REFUND`. `CREDIT` is excluded by construction: it + /// is a `/direct/credit` payout, not a refund of a payment, and treating one as a refund would + /// report money-out that no refund record ever asked for. Every other operation type belongs + /// to the payment's own lifecycle and is PSync's subject (see + /// [`Self::latest_payment_operation`]). + /// + /// **Selecting one refund out of the candidates is the hard part, and it cannot be done + /// reliably today.** A variant-A refund is bound to its payment by the payment's own + /// `merchantRef`, and its history entry echoes the payment's `merchantRef` *and* `gatewayRef` + /// — observable in the vendor's own published example, where an `AUTH_CAP` and a `REFUND` + /// share both values, with **no `operationRef` on either**. So N partial refunds against one + /// payment produce N entries differing only by `entryDate`, `processedDate` and `amount`. + /// + /// The ladder is therefore: + /// + /// 1. **Exact `operationRef` match** — used when `RefundSyncData::connector_refund_id` holds + /// an [`is_operation_ref_shaped`] value (i.e. `/direct/refund` returned a real + /// `operationRef` and [`refund_identifier`] passed it through). This is the only + /// authoritative match, and the only one that survives multiple partial refunds. + /// 2. If that id is `operationRef`-shaped and the report *does* carry `operationRef`s on the + /// candidates but none of them is ours, the refund is **not in this window** — `None` — and + /// is emphatically *not* silently swapped for some other refund of the same payment. + /// 3. **Heuristic**, used only when no `operationRef` is available on either side: narrow to + /// the candidates whose `transaction.amount` equals the amount being synced (skipped when + /// that would leave nothing, so a report that omits `amount` still matches), then take the + /// **latest by `entryDate`** — the same "late outcomes are appended, never edited" rule + /// PSync relies on — with ties broken by document order. + /// + /// **Known limitation (tech spec UNDECIDED §12).** Step 3 cannot distinguish two refunds of + /// the same amount on the same payment. Syncing refund #1 of three identical partial refunds + /// returns the *latest* one's status. The caller is told: the match arm is reported back so + /// the response mapping can log the ambiguity, and the number of candidates is carried with + /// it. This resolves itself the moment Ilixium's `operationRef` goes live, at which point + /// step 1 applies and steps 2–3 become unreachable. + fn match_refund_operation( + &self, + merchant_ref: &str, + connector_refund_id: &str, + minor_refund_amount: Option, + ) -> Option> { + let candidates: Vec<(usize, &IlixiumHistoryOperation)> = self + .operation + .iter() + .enumerate() + .filter(|(_, operation)| { + operation.merchant_ref() == Some(merchant_ref) + // `REFUND` and nothing else. In particular never `CREDIT`, which is a payout. + && operation.operation_type == Some(IlixiumOperationType::Refund) + }) + .collect(); + + if candidates.is_empty() { + return None; + } + + if is_operation_ref_shaped(connector_refund_id) { + if let Some((_, operation)) = candidates + .iter() + .find(|(_, operation)| operation.operation_ref() == Some(connector_refund_id)) + { + return Some(RefundOperationMatch::Exact(operation)); + } + + // The platform is populating `operationRef`, and none of this payment's refunds is the + // one being synced. Falling through to the heuristic here would hand back a *different* + // refund's outcome under this refund's id, which is exactly the silent misattribution + // this flow has to avoid. + if candidates + .iter() + .any(|(_, operation)| operation.operation_ref().is_some()) + { + return None; + } + } + + // Narrow by amount when that is possible *and* leaves something — a report entry may omit + // `transaction.amount` entirely, and an empty narrowing would turn a findable refund into a + // spurious "not in window". + let narrowed: Vec<(usize, &IlixiumHistoryOperation)> = match minor_refund_amount { + Some(amount) => { + let matching: Vec<(usize, &IlixiumHistoryOperation)> = candidates + .iter() + .filter(|(_, operation)| operation.minor_amount() == Some(amount)) + .copied() + .collect(); + if matching.is_empty() { + candidates + } else { + matching + } + } + None => candidates, + }; + + let candidate_count = narrowed.len(); + narrowed + .into_iter() + .max_by(|(left_index, left), (right_index, right)| { + left.entry_instant() + .cmp(&right.entry_instant()) + .then_with(|| left_index.cmp(right_index)) + }) + .map(|(_, operation)| RefundOperationMatch::Heuristic { + operation, + candidates: candidate_count, + }) + } +} + +/// Maps one `type: REFUND` history entry onto a UCS [`RefundStatus`]. +/// +/// A **fifth sibling** of [`map_attempt_status`] / [`map_capture_status`] / [`map_void_status`] / +/// [`map_refund_status`], not a reuse of any of them. The nearest neighbour, [`map_refund_status`], +/// is keyed on [`IlixiumStatusCode`]; this one is keyed on [`IlixiumHistoryStatusCode`], a +/// different enum that adds `EXCEPTION` and `VALIDATION_ERRORS` and drops `RESUBMISSION`. Feeding +/// a history entry through `map_refund_status` would not even type-check, and collapsing the two +/// enums to make it would lose the distinction between "the operation failed" and "the query was +/// malformed". +/// +/// The nearest neighbour in the *other* direction, [`map_history_sync_status`], shares this enum +/// but targets `AttemptStatus` and branches on the operation type; here the operation type is +/// already known to be `REFUND`, so only the status code matters. +fn map_history_refund_status(status: &IlixiumHistoryStatus) -> RefundStatus { + match status.code { + IlixiumHistoryStatusCode::Success => RefundStatus::Success, + // Ilixium permits one operation at a time per transaction (code 117), so a PENDING refund + // entry is one the platform accepted and has not yet resolved. + IlixiumHistoryStatusCode::Pending => RefundStatus::Pending, + // As in `map_refund_status`: `CANCELLED` describes the original payment ("the customer + // chose to cancel the transaction"), not this merchant-initiated refund, so it means the + // refund did not happen. `VALIDATION_ERRORS` on an *operation* entry is the recorded + // rejection of that refund — the vendor's own example shows exactly this, a `REJECTED` + // REFUND entry with reason 103 — and not a complaint about the history query, which would + // appear on the envelope instead. + IlixiumHistoryStatusCode::Cancelled + | IlixiumHistoryStatusCode::Declined + | IlixiumHistoryStatusCode::Rejected + | IlixiumHistoryStatusCode::Error + | IlixiumHistoryStatusCode::Exception + | IlixiumHistoryStatusCode::ValidationErrors => RefundStatus::Failure, + // A code this integration does not recognise is treated as still in flight rather than as + // a guessed terminal outcome — the same choice all four sibling mappers make. + IlixiumHistoryStatusCode::Unknown => RefundStatus::Pending, + } +} + +/// The error surfaced when the *query* failed — the refund-side counterpart of +/// [`build_history_query_error`]. +/// +/// Written out rather than layered on it because that builder stamps +/// `attempt_status: FlowStatus::Payment(..)`, and a refund sync must report +/// `FlowStatus::Refund(..)`. As there, the status echoed back is the refund's **existing** one: a +/// report this connector could not read says nothing whatsoever about the refund. +fn build_refund_history_query_error( + response: &IlixiumRefundHistoryResponse, + current_status: RefundStatus, + http_code: u16, +) -> ErrorResponse { + let reason_codes = response.status.reason_codes(); + let hints: Vec = reason_codes + .iter() + .filter_map(|code| history_query_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + + ErrorResponse { + status_code: http_code, + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", response.status.code).to_uppercase()), + message: response + .status + .message + .clone() + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()), + reason: Some(if hints.is_empty() { + format!( + "POST /history/operations answered {:?}; the refund's status could not be read \ + and is left unchanged.", + response.status.code + ) + } else { + hints.join("; ") + }), + attempt_status: Some(domain_types::router_data::FlowStatus::Refund( + current_status, + )), + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// The error surfaced when the query succeeded but no `type: REFUND` entry for this payment is in +/// the report — the refund-side counterpart of [`build_history_not_found_error`]. +/// +/// This is emphatically *not* a failed refund, so `attempt_status` echoes the status the sync +/// started with. Three things can put a refund here, and the message names all of them: the refund +/// is outside the queried `<= 24h` window, the platform has not recorded an operation for it yet, +/// or `operationRef` is live and none of the payment's refund entries is this one. +fn build_refund_not_found_error( + merchant_ref: &str, + current_status: RefundStatus, + http_code: u16, +) -> ErrorResponse { + ErrorResponse { + status_code: http_code, + code: "ILIXIUM_REFUND_NOT_IN_HISTORY_WINDOW".to_string(), + message: format!( + "Ilixium's operation history contains no REFUND operation for \ + transaction.merchantRef {merchant_ref} in the queried period. The refund's status is \ + unchanged." + ), + reason: Some( + "POST /history/operations is the only query the Ilixium Direct API offers — there is \ + no refund-status endpoint and no lookup by refund id — and it reports a window of at \ + most 24 hours, which this connector defaults to [now - 24h, now]. A refund older \ + than that cannot be synced against the default window: re-issue the sync with \ + connector_feature_data.ilixium_history_period_start set to at or just before the \ + refund's creation time (RFC 3339, e.g. \"2025-12-09T11:56:11Z\") and the window will \ + be anchored there instead. Note also that CREDIT operations are payouts, not \ + refunds, and are never matched." + .to_string(), + ), + attempt_status: Some(domain_types::router_data::FlowStatus::Refund( + current_status, + )), + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// The error surfaced when the matched REFUND entry itself failed — the refund-side counterpart of +/// [`build_history_operation_error`]. +/// +/// Unlike the two above, this one *is* an outcome, so `attempt_status` carries the mapped +/// [`RefundStatus`]. History entries carry no ISO-8583 code and no `cardResponse`, so +/// `status.reasons.reason` is the only machine-readable detail there is; it is expanded through +/// [`refund_failure_hint`], the same vocabulary `/direct/refund` uses, because a REFUND history +/// entry records the outcome of exactly that operation. +fn build_refund_history_operation_error( + status: &IlixiumHistoryStatus, + refund_status: RefundStatus, + gateway_ref: Option, + http_code: u16, +) -> ErrorResponse { + let reason_codes = status.reason_codes(); + let hints: Vec = reason_codes + .iter() + .filter_map(|code| refund_failure_hint(code).map(|hint| format!("{code}: {hint}"))) + .collect(); + + ErrorResponse { + status_code: http_code, + code: reason_codes + .first() + .cloned() + .unwrap_or_else(|| format!("{:?}", status.code).to_uppercase()), + message: status + .message + .clone() + .unwrap_or_else(|| common_utils::consts::NO_ERROR_MESSAGE.to_string()), + reason: if !hints.is_empty() { + Some(hints.join("; ")) + } else if reason_codes.is_empty() { + status.message.clone() + } else { + Some(reason_codes.join(", ")) + }, + attempt_status: Some(domain_types::router_data::FlowStatus::Refund(refund_status)), + connector_transaction_id: gateway_ref, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + typed_connector_response: None, + raw_connector_response: None, + raw_connector_request: None, + typed_connector_request: None, + } +} + +/// Resolves the `connector_refund_id` an RSync reports back. +/// +/// Prefers the matched entry's `status.operationRef` — if the platform has started populating it, +/// echoing it upgrades a UCS-side fallback id to the real Ilixium one — and otherwise echoes the +/// id the sync was issued with, which is still correct. Erroring is reachable only if a sync was +/// somehow issued with no refund id at all. +fn resolve_sync_refund_identifier( + operation: &IlixiumHistoryOperation, + connector_refund_id: &str, + http_code: u16, +) -> Result> { + if let Some(operation_ref) = operation.operation_ref() { + return Ok(operation_ref.to_owned()); + } + + if !connector_refund_id.is_empty() { + return Ok(connector_refund_id.to_owned()); + } + + Err(error_stack::report!( + errors::ConnectorError::ResponseHandlingFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(http_code), + additional_context: Some( + "The matched Ilixium REFUND history entry carries no status.operationRef and \ + the sync was issued with no connector_refund_id, so this refund has no \ + identifier: the entry's merchantRef and gatewayRef both echo the original \ + payment and would collide across partial refunds." + .to_string(), + ), + }, + } + )) +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData, + ) -> Result { + let response = item.response; + let request = &item.router_data.request; + let current_status = item.router_data.resource_common_data.status; + + // The **original payment's** reference, resolved from `RefundSyncData` through the same + // ladder the Refund flow uses — never from `RefundFlowData::connector_request_reference_id`, + // which is `merchant_refund_id` and identifies the refund. Ilixium keys REFUND history + // entries by the payment's `merchantRef`, so anything else matches nothing. + let merchant_ref = resolve_original_merchant_ref( + request.connector_order_id.as_deref(), + &request.connector_transaction_id, + ) + .change_context(errors::ConnectorError::ResponseHandlingFailed { + context: errors::ResponseTransformationErrorContext { + http_status_code: Some(item.http_code), + additional_context: Some( + "Could not resolve the original payment's Ilixium transaction.merchantRef to \ + match this refund against POST /history/operations." + .to_string(), + ), + }, + })?; + + // The envelope status is the outcome of the *query*. Anything but SUCCESS means the report + // could not be produced, which tells us nothing about the refund. + if response.status.code != IlixiumHistoryStatusCode::Success { + return Ok(Self { + response: Err(build_refund_history_query_error( + &response, + current_status, + item.http_code, + )), + ..item.router_data + }); + } + + let refund_amount = request + .refund_money + .as_ref() + .map(|money| money.amount.get_amount_as_i64()); + + let Some(matched) = response.match_refund_operation( + &merchant_ref, + &request.connector_refund_id, + refund_amount, + ) else { + return Ok(Self { + response: Err(build_refund_not_found_error( + &merchant_ref, + current_status, + item.http_code, + )), + ..item.router_data + }); + }; + + if let RefundOperationMatch::Heuristic { candidates, .. } = &matched { + if *candidates > 1 { + tracing::warn!( + connector = "ilixium", + merchant_ref = %merchant_ref, + candidates = *candidates, + "Ilixium's operation history holds several REFUND entries for one payment and \ + none carries a status.operationRef, so this refund was matched by the \ + documented heuristic (amount, then latest entryDate) rather than \ + identified. Partial refunds of equal amounts on one payment are not \ + individually distinguishable at Ilixium until operationRef goes live." + ); + } + } + + let operation = matched.operation(); + + // `operation[].status` is schema-required; an entry without one carries no outcome at all, + // so it is handled exactly like an entry that was never recorded. + let Some(operation_status) = operation.status.as_ref() else { + return Ok(Self { + response: Err(build_refund_not_found_error( + &merchant_ref, + current_status, + item.http_code, + )), + ..item.router_data + }); + }; + + let status = map_history_refund_status(operation_status); + + // The raw outcome as the platform worded it. History messages omit ISO-8583 codes, so this + // text will not match the corresponding /direct/refund response's message. + let raw_connector_status = RawConnectorStatus { + code: Some(format!("{:?}", operation_status.code).to_uppercase()), + message: operation_status.message.clone(), + reason: { + let codes = operation_status.reason_codes(); + (!codes.is_empty()).then(|| codes.join(", ")) + }, + }; + + let refunds_response = if status == RefundStatus::Failure { + Err(build_refund_history_operation_error( + operation_status, + status, + operation.gateway_ref(), + item.http_code, + )) + } else { + Ok(RefundsResponseData { + connector_refund_id: resolve_sync_refund_identifier( + operation, + &request.connector_refund_id, + item.http_code, + )?, + refund_status: status, + status_code: item.http_code, + // A history entry has no `cardResponse`, so there is no acquirer reference number + // to recover — and Ilixium documents none for refunds in any case. + acquirer_reference_number: None, + }) + }; + + Ok(Self { + response: refunds_response, + resource_common_data: RefundFlowData { + status, + raw_connector_status: Some(raw_connector_status), + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} diff --git a/crates/integrations/connector-integration/src/connectors/imerchantsolutions/transformers.rs b/crates/integrations/connector-integration/src/connectors/imerchantsolutions/transformers.rs index 1d8e100945..69a23e91bf 100644 --- a/crates/integrations/connector-integration/src/connectors/imerchantsolutions/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/imerchantsolutions/transformers.rs @@ -632,7 +632,8 @@ impl { + | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(errors::IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Imerchantsolutions"), errors::IntegrationErrorContext { diff --git a/crates/integrations/connector-integration/src/connectors/loonio/transformers.rs b/crates/integrations/connector-integration/src/connectors/loonio/transformers.rs index 327bb06501..d4defe085b 100644 --- a/crates/integrations/connector-integration/src/connectors/loonio/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/loonio/transformers.rs @@ -250,10 +250,13 @@ impl Err(IntegrationError::NotImplemented( - utils::get_unimplemented_payment_method_error_message("Loonio"), - Default::default(), - ))?, + | PaymentMethodData::MobilePayment(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { + Err(IntegrationError::NotImplemented( + utils::get_unimplemented_payment_method_error_message("Loonio"), + Default::default(), + ))? + } } } } diff --git a/crates/integrations/connector-integration/src/connectors/mifinity/transformers.rs b/crates/integrations/connector-integration/src/connectors/mifinity/transformers.rs index 86081487da..bb25ad6402 100644 --- a/crates/integrations/connector-integration/src/connectors/mifinity/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/mifinity/transformers.rs @@ -260,7 +260,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Mifinity"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/multisafepay/transformers.rs b/crates/integrations/connector-integration/src/connectors/multisafepay/transformers.rs index 26b5869666..9ffbaeea9d 100644 --- a/crates/integrations/connector-integration/src/connectors/multisafepay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/multisafepay/transformers.rs @@ -174,13 +174,12 @@ fn get_order_type_from_payment_method( | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { - Err(IntegrationError::NotImplemented( - crate::utils::get_unimplemented_payment_method_error_message("multisafepay"), - Default::default(), - )) - .attach_printable("Payment method not supported")? - } + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(IntegrationError::NotImplemented( + crate::utils::get_unimplemented_payment_method_error_message("multisafepay"), + Default::default(), + )) + .attach_printable("Payment method not supported")?, }; Ok(payment_type) @@ -374,13 +373,12 @@ fn get_gateway_from_payment_method( | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { - Err(IntegrationError::NotImplemented( - crate::utils::get_unimplemented_payment_method_error_message("multisafepay"), - Default::default(), - )) - .attach_printable("Payment method not supported")? - } + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(IntegrationError::NotImplemented( + crate::utils::get_unimplemented_payment_method_error_message("multisafepay"), + Default::default(), + )) + .attach_printable("Payment method not supported")?, }; Ok(gateway) diff --git a/crates/integrations/connector-integration/src/connectors/nexinets/transformers.rs b/crates/integrations/connector-integration/src/connectors/nexinets/transformers.rs index 37c7505588..409562b615 100644 --- a/crates/integrations/connector-integration/src/connectors/nexinets/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/nexinets/transformers.rs @@ -774,12 +774,11 @@ fn get_payment_details_and_product< | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { - Err(IntegrationError::NotImplemented( - utils::get_unimplemented_payment_method_error_message("nexinets"), - Default::default(), - ))? - } + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(IntegrationError::NotImplemented( + utils::get_unimplemented_payment_method_error_message("nexinets"), + Default::default(), + ))?, } } diff --git a/crates/integrations/connector-integration/src/connectors/noon/transformers.rs b/crates/integrations/connector-integration/src/connectors/noon/transformers.rs index a66c318857..885ccc75c5 100644 --- a/crates/integrations/connector-integration/src/connectors/noon/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/noon/transformers.rs @@ -392,7 +392,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Noon"), connector: "Noon", @@ -1313,7 +1314,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Noon"), connector: "Noon", diff --git a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs index 3406363089..0625cda1ae 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs @@ -1654,7 +1654,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Paypal"), connector: "Paypal", @@ -3212,7 +3213,8 @@ impl { + | PaymentMethodData::MobilePayment(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Paypal"), connector: "Paypal", diff --git a/crates/integrations/connector-integration/src/connectors/placetopay/transformers.rs b/crates/integrations/connector-integration/src/connectors/placetopay/transformers.rs index 114db73541..398447611b 100644 --- a/crates/integrations/connector-integration/src/connectors/placetopay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/placetopay/transformers.rs @@ -211,7 +211,8 @@ impl | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Placetopay"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/razorpay/transformers.rs b/crates/integrations/connector-integration/src/connectors/razorpay/transformers.rs index e885744bd7..5a5e1eb066 100644 --- a/crates/integrations/connector-integration/src/connectors/razorpay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/razorpay/transformers.rs @@ -574,7 +574,8 @@ impl { + | PaymentMethodData::OpenBanking(_) + | PaymentMethodData::NoInstrumentAfterRedirect) => { return Err(IntegrationError::NotImplemented( format!("Payment Method {pm:?} not supported for Razorpay"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/redsys/transformers.rs b/crates/integrations/connector-integration/src/connectors/redsys/transformers.rs index 682487b11a..1df92a8d72 100644 --- a/crates/integrations/connector-integration/src/connectors/redsys/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/redsys/transformers.rs @@ -287,6 +287,7 @@ where | Some(PaymentMethodData::CardDetailsForNetworkTransactionId(_)) | Some(PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_)) | Some(PaymentMethodData::CardWithNoCvc(_)) + | Some(PaymentMethodData::NoInstrumentAfterRedirect) | None => Err(IntegrationError::NotImplemented( domain_types::utils::get_unimplemented_payment_method_error_message("redsys"), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/sanlam_common/transformers.rs b/crates/integrations/connector-integration/src/connectors/sanlam_common/transformers.rs index 3c6eb05393..84e221eaf3 100644 --- a/crates/integrations/connector-integration/src/connectors/sanlam_common/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/sanlam_common/transformers.rs @@ -278,7 +278,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: get_unimplemented_payment_method_error_message("AbsaSanlam"), connector: "AbsaSanlam", diff --git a/crates/integrations/connector-integration/src/connectors/stax/transformers.rs b/crates/integrations/connector-integration/src/connectors/stax/transformers.rs index 768f528205..b242a52169 100644 --- a/crates/integrations/connector-integration/src/connectors/stax/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/stax/transformers.rs @@ -1109,7 +1109,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( "Only card and ACH bank debit tokenization are supported for Stax".to_string(), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/stripe/transformers.rs b/crates/integrations/connector-integration/src/connectors/stripe/transformers.rs index 78ed3ff32e..0c06ec9886 100644 --- a/crates/integrations/connector-integration/src/connectors/stripe/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/stripe/transformers.rs @@ -1606,13 +1606,12 @@ fn create_stripe_payment_method< | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { - Err(IntegrationError::NotImplemented( - get_unimplemented_payment_method_error_message("stripe"), - Default::default(), - ) - .into()) - } + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(IntegrationError::NotImplemented( + get_unimplemented_payment_method_error_message("stripe"), + Default::default(), + ) + .into()), } } @@ -5224,7 +5223,8 @@ impl | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::NetworkToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(IntegrationError::NotImplemented( get_unimplemented_payment_method_error_message("stripe"), Default::default(), @@ -5623,6 +5623,7 @@ impl | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId( _, ) + | PaymentMethodData::NoInstrumentAfterRedirect | PaymentMethodData::Card(_) => Err(IntegrationError::NotImplemented( "Network tokenization for payment method".to_string(), Default::default(), diff --git a/crates/integrations/connector-integration/src/connectors/trustly/transformers.rs b/crates/integrations/connector-integration/src/connectors/trustly/transformers.rs index 1d0683cc6d..9e127ced55 100644 --- a/crates/integrations/connector-integration/src/connectors/trustly/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/trustly/transformers.rs @@ -426,7 +426,8 @@ impl Err(error_stack::report!( + | PaymentMethodData::MobilePayment(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(error_stack::report!( errors::IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Trustly"), connector: "Trustly", diff --git a/crates/integrations/connector-integration/src/connectors/trustpay/transformers.rs b/crates/integrations/connector-integration/src/connectors/trustpay/transformers.rs index 01c1c4962b..c44c7408b1 100644 --- a/crates/integrations/connector-integration/src/connectors/trustpay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/trustpay/transformers.rs @@ -1840,7 +1840,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("trustpay"), connector: "trustpay", diff --git a/crates/integrations/connector-integration/src/connectors/volt/transformers.rs b/crates/integrations/connector-integration/src/connectors/volt/transformers.rs index 2aa1f05023..6164600508 100644 --- a/crates/integrations/connector-integration/src/connectors/volt/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/volt/transformers.rs @@ -300,7 +300,8 @@ impl { + | PaymentMethodData::CardDetailsForNetworkTransactionId(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { Err(error_stack::report!(IntegrationError::NotSupported { message: utils::get_unimplemented_payment_method_error_message("Volt"), connector: "Volt", diff --git a/crates/integrations/connector-integration/src/connectors/wellsfargo/transformers.rs b/crates/integrations/connector-integration/src/connectors/wellsfargo/transformers.rs index d2c1fd0207..9e7144e544 100644 --- a/crates/integrations/connector-integration/src/connectors/wellsfargo/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/wellsfargo/transformers.rs @@ -621,11 +621,14 @@ impl | PaymentMethodData::OpenBanking(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) | PaymentMethodData::CardWithNoCvc(_) - | PaymentMethodData::MobilePayment(_) => Err(IntegrationError::NotSupported { - message: "Payment method".to_string(), - connector: "Wellsfargo", - context: Default::default(), - })?, + | PaymentMethodData::MobilePayment(_) + | PaymentMethodData::NoInstrumentAfterRedirect => { + Err(IntegrationError::NotSupported { + message: "Payment method".to_string(), + connector: "Wellsfargo", + context: Default::default(), + })? + } }; // Get amount and currency - amount is in minor units (cents) diff --git a/crates/integrations/connector-integration/src/connectors/worldpay/transformers.rs b/crates/integrations/connector-integration/src/connectors/worldpay/transformers.rs index 1b3f221e2b..4dd8739496 100644 --- a/crates/integrations/connector-integration/src/connectors/worldpay/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/worldpay/transformers.rs @@ -260,7 +260,8 @@ fn fetch_payment_instrument< | PaymentMethodData::OpenBanking(_) | PaymentMethodData::PaymentMethodToken(_) | PaymentMethodData::DecryptedWalletTokenDetailsForNetworkTransactionId(_) - | PaymentMethodData::NetworkToken(_) => Err(IntegrationError::NotImplemented(utils::get_unimplemented_payment_method_error_message("worldpay") , Default::default()) + | PaymentMethodData::NetworkToken(_) + | PaymentMethodData::NoInstrumentAfterRedirect => Err(IntegrationError::NotImplemented(utils::get_unimplemented_payment_method_error_message("worldpay") , Default::default()) .into()) } } diff --git a/crates/integrations/connector-integration/src/default_implementations.rs b/crates/integrations/connector-integration/src/default_implementations.rs index fb944d7f79..b35fcc070a 100644 --- a/crates/integrations/connector-integration/src/default_implementations.rs +++ b/crates/integrations/connector-integration/src/default_implementations.rs @@ -291,6 +291,7 @@ default_impl_verify_webhook_source_v2!( Grabpay, Tesouro, Boost, + Ilixium, ], ); // PayPal has its own implementation in paypal.rs @@ -368,6 +369,7 @@ macro_rules! default_impl_recharge_v2 { default_impl_recharge_v2!( Boost, + Ilixium, Tesouro, AbsaSanlam, Aci, @@ -593,6 +595,7 @@ macro_rules! default_impl_get_payment_method_v2 { // Same connector universe as default_impl_recharge_v2! above. default_impl_create_payment_method_v2!( Boost, + Ilixium, Tesouro, AbsaSanlam, Aci, @@ -696,6 +699,7 @@ default_impl_create_payment_method_v2!( default_impl_get_payment_method_v2!( Boost, + Ilixium, Tesouro, AbsaSanlam, Aci, @@ -800,6 +804,7 @@ default_impl_get_payment_method_v2!( default_impl_payment_method_eligibility_v2!( not_supported: [ Boost, + Ilixium, Tesouro, Adyen, Authorizedotnet, @@ -965,6 +970,7 @@ macro_rules! default_impl_refresh_payment_method_v2 { default_impl_refresh_payment_method_v2!( Boost, + Ilixium, AbsaSanlam, Aci, Adyen, diff --git a/crates/integrations/connector-integration/src/types.rs b/crates/integrations/connector-integration/src/types.rs index b8cd3f9ae4..18a7e21801 100644 --- a/crates/integrations/connector-integration/src/types.rs +++ b/crates/integrations/connector-integration/src/types.rs @@ -142,6 +142,7 @@ impl Box::new(connectors::Grabpay::::new()), ConnectorEnum::Tesouro => Box::new(connectors::Tesouro::::new()), ConnectorEnum::Boost => Box::new(connectors::Boost::::new()), + ConnectorEnum::Ilixium => Box::new(connectors::Ilixium::::new()), } } } diff --git a/crates/internal/field-probe/src/auth.rs b/crates/internal/field-probe/src/auth.rs index 641c45e5e6..c4199cbb18 100644 --- a/crates/internal/field-probe/src/auth.rs +++ b/crates/internal/field-probe/src/auth.rs @@ -792,5 +792,11 @@ pub(crate) fn dummy_auth(connector: &ConnectorEnum) -> ConnectorSpecificConfig { merchant_secret: k(), base_url: None, }, + ConnectorEnum::Ilixium => ConnectorSpecificConfig::Ilixium { + api_key: k(), + key1: k(), + api_secret: k(), + base_url: None, + }, } } diff --git a/crates/internal/integration-tests/src/connector_specs/ilixium/specs.json b/crates/internal/integration-tests/src/connector_specs/ilixium/specs.json new file mode 100644 index 0000000000..55ec8ff41f --- /dev/null +++ b/crates/internal/integration-tests/src/connector_specs/ilixium/specs.json @@ -0,0 +1,12 @@ +{ + "connector": "ilixium", + "supported_suites": [ + "PaymentService/Authorize", + "PaymentService/Capture", + "PaymentService/Get", + "PaymentService/Refund", + "RefundService/Get", + "PaymentService/Void", + "PaymentMethodAuthenticationService/PreAuthenticate" + ] +} diff --git a/crates/types-traits/domain_types/src/connector_types.rs b/crates/types-traits/domain_types/src/connector_types.rs index 722c32c63b..02c69bb5c6 100644 --- a/crates/types-traits/domain_types/src/connector_types.rs +++ b/crates/types-traits/domain_types/src/connector_types.rs @@ -163,6 +163,7 @@ pub enum ConnectorEnum { Grabpay, Tesouro, Boost, + Ilixium, } // snake case for enum variants @@ -520,6 +521,7 @@ impl ForeignTryFrom for ConnectorEnum { grpc_api_types::payments::Connector::Glomopay => Ok(Self::Glomopay), grpc_api_types::payments::Connector::Givepayments => Ok(Self::Givepayments), grpc_api_types::payments::Connector::Boost => Ok(Self::Boost), + grpc_api_types::payments::Connector::Ilixium => Ok(Self::Ilixium), grpc_api_types::payments::Connector::Grabpay => Ok(Self::Grabpay), grpc_api_types::payments::Connector::Unspecified => { Err(IntegrationError::InvalidDataFormat { @@ -2228,6 +2230,14 @@ pub struct PaymentsPreAuthenticateData { pub mandate_reference: Option, /// Merchant transaction id, used to derive the FRM DDC sessionId (e.g. Kount). pub merchant_transaction_id: Option, + /// Merchant-supplied connector metadata, mirroring `PaymentsAuthorizeData::metadata`. + /// + /// The gRPC request has always carried this (`PaymentMethodAuthenticationService + /// PreAuthenticateRequest.metadata`) but it was previously dropped on the floor here, so a + /// connector whose PreAuthenticate leg sends a full authorisation could not reach + /// merchant-supplied fields that have no home in the UCS payment model — Ilixium's + /// schema-mandatory `customer.dateOfBirth`, for one. + pub metadata: Option, } impl PaymentsPreAuthenticateData { @@ -4058,6 +4068,7 @@ impl From> for PaymentMethodData match pm_data { PaymentMethodData::Card(_) => Self::Card, PaymentMethodData::CardWithNoCvc(_) => Self::CardWithNoCvc, + PaymentMethodData::NoInstrumentAfterRedirect => Self::NoInstrumentAfterRedirect, PaymentMethodData::CardRedirect(card_redirect_data) => match card_redirect_data { payment_method_data::CardRedirectData::Knet {} => Self::Knet, payment_method_data::CardRedirectData::Benefit {} => Self::Benefit, @@ -5669,6 +5680,7 @@ impl ForeignTryFrom AuthType::Maya(_) => Ok(Self::Payment(ConnectorEnum::Maya)), AuthType::Tesouro(_) => Ok(Self::Payment(ConnectorEnum::Tesouro)), AuthType::Boost(_) => Ok(Self::Payment(ConnectorEnum::Boost)), + AuthType::Ilixium(_) => Ok(Self::Payment(ConnectorEnum::Ilixium)), AuthType::Imerchantsolutions(_) => Ok(Self::Payment(ConnectorEnum::Imerchantsolutions)), AuthType::TsysTransit(_) => Ok(Self::Payment(ConnectorEnum::TsysTransit)), AuthType::TwocTwopPaco(_) => Ok(Self::Payment(ConnectorEnum::TwocTwopPaco)), diff --git a/crates/types-traits/domain_types/src/payment_method_data.rs b/crates/types-traits/domain_types/src/payment_method_data.rs index ddd2cfdfc8..db4ed7a07f 100644 --- a/crates/types-traits/domain_types/src/payment_method_data.rs +++ b/crates/types-traits/domain_types/src/payment_method_data.rs @@ -383,6 +383,17 @@ pub enum PaymentMethodData { OpenBanking(OpenBankingData), NetworkToken(NetworkTokenData), MobilePayment(MobilePaymentData), + /// No raw payment instrument accompanies this request. + /// + /// Emitted only for the second leg of a redirect-based authorization (e.g. the 3DS + /// return leg): the customer has come back from the ACS/hosted page and the caller no + /// longer holds the card or wallet details, so the connector must finalise the payment + /// from the redirect payload plus its own transaction reference instead. + /// + /// A connector that cannot finalise without an instrument should reject this variant + /// (the usual `_ => NotImplemented` arm already does), never treat it as a fallback for + /// an ordinary authorization. + NoInstrumentAfterRedirect, } impl PaymentMethodData { diff --git a/crates/types-traits/domain_types/src/router_data.rs b/crates/types-traits/domain_types/src/router_data.rs index c1f7470711..d1924a8346 100644 --- a/crates/types-traits/domain_types/src/router_data.rs +++ b/crates/types-traits/domain_types/src/router_data.rs @@ -966,6 +966,16 @@ pub enum ConnectorSpecificConfig { merchant_secret: Secret, base_url: Option, }, + /// Ilixium Direct API. + /// `api_key` = Digest Calculation Password (input to X-MERCHANT-DIGEST, never sent) + /// `key1` = MerchantId (request body `merchant.merchantId`) + /// `api_secret` = AccountId (request body `merchant.accountId`) + Ilixium { + api_key: Secret, + key1: Secret, + api_secret: Secret, + base_url: Option, + }, } impl ConnectorSpecificConfig { @@ -1313,6 +1323,11 @@ impl ConnectorSpecificConfig { api_secret }, Boost { api_key }, + Ilixium { + api_key, + key1, + api_secret + }, Imerchantsolutions { api_key }, Interpayments { api_key }, TwocTwopPaco { @@ -1783,6 +1798,11 @@ impl ConnectorSpecificConfig { api_secret }, Boost { api_key }, + Ilixium { + api_key, + key1, + api_secret + }, Imerchantsolutions { api_key }, Interpayments { api_key }, TwocTwopPaco { @@ -2405,6 +2425,12 @@ impl ForeignTryFrom for Conne merchant_secret: boost.merchant_secret.ok_or_else(err)?, base_url: boost.base_url, }), + AuthType::Ilixium(ilixium) => Ok(Self::Ilixium { + api_key: ilixium.api_key.ok_or_else(err)?, + key1: ilixium.key1.ok_or_else(err)?, + api_secret: ilixium.api_secret.ok_or_else(err)?, + base_url: ilixium.base_url, + }), AuthType::Imerchantsolutions(imerchantsolutions) => Ok(Self::Imerchantsolutions { api_key: imerchantsolutions.api_key.ok_or_else(err)?, merchant_id: imerchantsolutions.merchant_id, @@ -3605,6 +3631,19 @@ impl ForeignTryFrom<(&ConnectorAuthType, &connector_types::ConnectorVariant)> }), _ => Err(err().into()), }, + ConnectorEnum::Ilixium => match auth { + ConnectorAuthType::SignatureKey { + api_key, + key1, + api_secret, + } => Ok(Self::Ilixium { + api_key: api_key.clone(), + key1: key1.clone(), + api_secret: api_secret.clone(), + base_url: None, + }), + _ => Err(err().into()), + }, ConnectorEnum::Boost => match auth { ConnectorAuthType::BodyKey { api_key, key1 } => Ok(Self::Boost { client_id: api_key.clone(), diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index 7f6d64bbd3..d4b3edc098 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -427,6 +427,7 @@ pub struct Connectors { pub grabpay: ConnectorParams, pub tesouro: ConnectorParams, pub boost: ConnectorParams, + pub ilixium: ConnectorParams, pub santander: ConnectorParams, } @@ -785,6 +786,9 @@ impl Connectors { ConnectorEnum::Boost => { patched.boost.apply(params_patch); } + ConnectorEnum::Ilixium => { + patched.ilixium.apply(params_patch); + } ConnectorEnum::Maya => { patched.maya.apply(params_patch); } @@ -798,7 +802,7 @@ impl Connectors { context: IntegrationErrorContext { additional_context: Some(format!( "Connector '{}' is not supported for dynamic URL patching from superposition. \ - Supported connectors: stripe, adyen, paypal, braintree, checkout, cybersource, revolut, aci, bankofamerica, worldpay, rapyd, fiserv, nexinets, elavon, novalnet, trustpay, forte, bambora, bamboraapac, barclaycard, billwerk, bluesnap, calida, cashfree, celero, cryptopay, datatrans, finix, fiservcommercehub, fiservemea, globalpay, helcim, hipay, imerchantsolutions, jpmorgan, loonio, mifinity, mollie, multisafepay, nexixpay, payload, payme, placetopay, powertranz, revolv3, absa_sanlam, shift4, silverflow, stax, truelayer, trustly, trustpayments, tsys, wellsfargo, worldpayvantiv, worldpayxml, zift, gigadat, givepayments, boost", + Supported connectors: stripe, adyen, paypal, braintree, checkout, cybersource, revolut, aci, bankofamerica, worldpay, rapyd, fiserv, nexinets, elavon, novalnet, trustpay, forte, bambora, bamboraapac, barclaycard, billwerk, bluesnap, calida, cashfree, celero, cryptopay, datatrans, finix, fiservcommercehub, fiservemea, globalpay, helcim, hipay, imerchantsolutions, jpmorgan, loonio, mifinity, mollie, multisafepay, nexixpay, payload, payme, placetopay, powertranz, revolv3, absa_sanlam, shift4, silverflow, stax, truelayer, trustly, trustpayments, tsys, wellsfargo, worldpayvantiv, worldpayxml, zift, gigadat, givepayments, boost, ilixium", connector )), ..Default::default() @@ -4566,6 +4570,28 @@ impl< )?), }; + // The redirect-return leg of an authorization carries no instrument, so there is no + // payment method type to derive from it. Every other authorization still requires one. + let payment_method_type = match value.payment_method.clone() { + Some(payment_method) => >::foreign_try_from(payment_method)?, + None if matches!( + payment_method_data, + PaymentMethodData::NoInstrumentAfterRedirect + ) => + { + None + } + None => { + return Err(report!(IntegrationError::InvalidDataFormat { + field_name: "unknown", + context: IntegrationErrorContext { + additional_context: Some("Payment method data is required".to_string()), + ..Default::default() + }, + })); + } + }; + Ok(Self { authentication_data, capture_method: Some(CaptureMethod::foreign_try_from(value.capture_method)?), @@ -4580,17 +4606,7 @@ impl< .cloned() .map(BrowserInformation::foreign_try_from) .transpose()?, - payment_method_type: >::foreign_try_from( - value.payment_method.clone().ok_or_else(|| { - IntegrationError::InvalidDataFormat { - field_name: "unknown", - context: IntegrationErrorContext { - additional_context: Some("Payment method data is required".to_string()), - ..Default::default() - }, - } - })?, - )?, + payment_method_type, minor_amount: common_utils::types::MinorUnit::new(amount.minor_amount), email, customer_document_details, @@ -13299,6 +13315,9 @@ pub enum PaymentMethodDataType { QwikcilverWalletDirect, Skrill, CardWithNoCvc, + /// Companion of [`PaymentMethodData::NoInstrumentAfterRedirect`]: the redirect-return leg + /// carries no instrument, so it matches no mandate-capable payment method data type. + NoInstrumentAfterRedirect, } impl ForeignTryFrom for Secret { @@ -16262,6 +16281,12 @@ impl< .transpose()?, mandate_reference: None, merchant_transaction_id: value.merchant_transaction_id, + // Mirrors the Authorize conversion. Connectors whose PreAuthenticate leg sends a full + // authorisation need the merchant metadata the caller already puts on the wire. + metadata: value + .metadata + .map(|m| SecretSerdeValue::foreign_try_from((m, "metadata"))) + .transpose()?, }) } } @@ -16583,8 +16608,27 @@ impl connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_order_id.clone(), ), - customer_id: None, - connector_customer: None, + // Mirrors the Authorize conversion, which reads both from `connector_customer_id`. + // Previously hardcoded `None`, so a connector reached through PreAuthenticate saw a + // different customer identity than the same connector reached through Authorize. + customer_id: value + .customer + .clone() + .and_then(|customer| customer.connector_customer_id) + .map(|customer_id| CustomerId::try_from(Cow::from(customer_id))) + .transpose() + .change_context(IntegrationError::InvalidDataFormat { + field_name: "customer.connector_customer_id", + context: IntegrationErrorContext { + additional_context: Some("Failed to parse Customer Id".to_string()), + suggested_action: Some("Provide a valid connector customer ID".to_string()), + doc_url: None, + }, + })?, + connector_customer: value + .customer + .clone() + .and_then(|customer| customer.connector_customer_id), description: value.description, return_url: value.return_url.clone(), connector_feature_data: value diff --git a/crates/types-traits/grpc-api-types/proto/payment.proto b/crates/types-traits/grpc-api-types/proto/payment.proto index 5fbeea121b..62ebeb6ed3 100644 --- a/crates/types-traits/grpc-api-types/proto/payment.proto +++ b/crates/types-traits/grpc-api-types/proto/payment.proto @@ -900,6 +900,7 @@ enum Connector { MAYA = 134; GRABPAY = 135; BOOST = 136; + ILIXIUM = 137; } // Payment method types @@ -5650,6 +5651,16 @@ message BoostConfig { optional string base_url = 50; } +message IlixiumConfig { + // Digest Calculation Password (never transmitted; input to X-MERCHANT-DIGEST) + SecretString api_key = 1; + // MerchantId - sent in the request body as merchant.merchantId + SecretString key1 = 2; + // AccountId - sent in the request body as merchant.accountId + SecretString api_secret = 3; + optional string base_url = 50; +} + // ConnectorSpecificConfig message with oneof containing all connector // configurations. Comment above each field (e.g. "// PAYPAL = 62") is the // Connector enum value from this file; @@ -5966,6 +5977,8 @@ message ConnectorSpecificConfig { GrabpayConfig grabpay = 144; // BOOST = 136 BoostConfig boost = 145; + // ILIXIUM = 137 + IlixiumConfig ilixium = 146; } } diff --git a/data/field_probe/ilixium.json b/data/field_probe/ilixium.json new file mode 100644 index 0000000000..07062a63b4 --- /dev/null +++ b/data/field_probe/ilixium.json @@ -0,0 +1,797 @@ +{ + "connector": "ilixium", + "flows": { + "authenticate": { + "default": { + "status": "not_supported", + "error": "authenticate flow not supported by ilixium connector" + } + }, + "authorize": { + "Ach": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankDebit(AchBankDebit { account_number: *** alloc::string::String ***, routing_number: *** alloc::string::String ***, card_holder_name: None, bank_account_holder_name: Some(*** alloc::string::String ***), bank_name: None, bank_type: None, bank_holder_type: None })" + }, + "AchBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(AchBankTransfer)" + }, + "Affirm": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: PayLater(AffirmRedirect)" + }, + "Afterpay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: PayLater(AfterpayClearpayRedirect)" + }, + "Alfamart": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Alfamart(AlfamartVoucherData))" + }, + "AliPayRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(AliPayRedirect(AliPayRedirection))" + }, + "AmazonPayRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(AmazonPayRedirect(AmazonPayRedirectData))" + }, + "ApplePay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(ApplePay(ApplePayWalletData { payment_data: Encrypted(\"eyJ2ZXJzaW9uIjoiRUNfdjEiLCJkYXRhIjoicHJvYmUiLCJzaWduYXR1cmUiOiJwcm9iZSJ9\"), payment_method: ApplepayPaymentMethod { display_name: \"Visa 1111\", network: \"Visa\", pm_type: \"debit\" }, transaction_identifier: \"probe_txn_id\" }))" + }, + "ApplePayDecrypted": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(ApplePay(ApplePayWalletData { payment_data: Decrypted(ApplePayDecryptedData { application_primary_account_number: CardNumber(411111**********), application_expiration_month: *** alloc::string::String ***, application_expiration_year: *** alloc::string::String ***, payment_data: ApplePayCryptogramData { online_payment_cryptogram: *** alloc::string::String ***, eci_indicator: Some(\"05\") } }), payment_method: ApplepayPaymentMethod { display_name: \"Visa 1111\", network: \"Visa\", pm_type: \"debit\" }, transaction_identifier: \"probe_txn_id\" }))" + }, + "ApplePayThirdPartySdk": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(ApplePayThirdPartySdk(ApplePayThirdPartySdkData { token: Some(*** alloc::string::String ***) }))" + }, + "Bacs": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankDebit(BacsBankDebit { account_number: *** alloc::string::String ***, sort_code: *** alloc::string::String ***, bank_account_holder_name: Some(*** alloc::string::String ***) })" + }, + "BacsBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(BacsBankTransfer)" + }, + "BancontactCard": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(BancontactCard { card_number: Some(CardNumber(411111**********)), card_exp_month: Some(*** alloc::string::String ***), card_exp_year: Some(*** alloc::string::String ***), card_holder_name: Some(*** alloc::string::String ***) })" + }, + "BcaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(BcaBankTransfer)" + }, + "Becs": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankDebit(BecsBankDebit { account_number: *** alloc::string::String ***, bsb_number: *** alloc::string::String ***, bank_account_holder_name: Some(*** alloc::string::String ***) })" + }, + "BillDeskRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(BillDeskRedirect(BillDeskRedirection))" + }, + "Bizum": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Bizum)" + }, + "Blik": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Blik { blik_code: Some(\"777124\") })" + }, + "Bluecode": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(BluecodeRedirect)" + }, + "BniVaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(BniVaBankTransfer)" + }, + "Boleto": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Boleto(BoletoVoucherData { social_security_number: None, expiration_date: None }))" + }, + "BriVaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(BriVaBankTransfer)" + }, + "Card": { + "status": "supported", + "proto_request": { + "merchant_transaction_id": "probe_txn_001", + "amount": { + "minor_amount": 1000, + "currency": "USD" + }, + "payment_method": { + "card": { + "card_number": "4111111111111111", + "card_exp_month": "03", + "card_exp_year": "2030", + "card_cvc": "737", + "card_holder_name": "John Doe" + } + }, + "capture_method": "AUTOMATIC", + "customer": { + "email": "test@example.com" + }, + "address": { + "billing_address": { + "first_name": "John", + "last_name": "Doe", + "country_alpha2_code": "US" + } + }, + "auth_type": "NO_THREE_DS", + "return_url": "https://example.com/return" + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/direct/auth", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"version\":2,\"deferredCapture\":false,\"transaction\":{\"transactionType\":\"ECOMMERCE\",\"merchantRef\":\"probe_txn_001\",\"amount\":\"1000\",\"currency\":\"USD\"},\"paymentMethodType\":\"CARD\",\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"},\"card\":{\"cardNumber\":\"4111111111111111\",\"expiryDate\":\"032030\",\"securityCode\":\"737\"},\"customer\":{\"customerId\":\"probe_txn_001\",\"email\":\"test@example.com\",\"firstName\":\"John\",\"surname\":\"Doe\",\"address\":{\"country\":\"US\"}},\"paymentInfo\":{\"country\":\"US\"}}" + } + }, + "CashappQr": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(CashappQr(CashappQr))" + }, + "CashfreeRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(CashfreeRedirect(CashfreeRedirection))" + }, + "CimbVaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(CimbVaBankTransfer)" + }, + "ClassicReward": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Reward" + }, + "Crypto": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "Dana": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(DanaRedirect)" + }, + "DanamonVaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(DanamonVaBankTransfer)" + }, + "DuitNow": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "EVoucher": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Reward" + }, + "EaseBuzzRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(EaseBuzzRedirect(EaseBuzzRedirection))" + }, + "Efecty": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Efecty)" + }, + "Eft": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Eft { provider: \"ozow\" })" + }, + "Eps": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Eps { bank_name: None, country: None })" + }, + "FamilyMart": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(FamilyMart(JCSVoucherData))" + }, + "GCash": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(GcashRedirect(GcashRedirection))" + }, + "Giropay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Giropay { bank_account_bic: None, bank_account_iban: None, country: None })" + }, + "Givex": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "GoPay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(GoPayRedirect(GoPayRedirection))" + }, + "GooglePay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(GooglePay(GooglePayWalletData { pm_type: \"CARD\", description: \"Visa 1111\", info: GooglePayPaymentMethodInfo { card_network: \"VISA\", card_details: \"1111\", assurance_details: None }, tokenization_data: Encrypted(GpayEncryptedTokenizationData { token_type: \"PAYMENT_GATEWAY\", token: \"{\\\"id\\\":\\\"tok_probe_gpay\\\",\\\"object\\\":\\\"token\\\",\\\"type\\\":\\\"card\\\"}\" }) }))" + }, + "GooglePayDecrypted": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(GooglePay(GooglePayWalletData { pm_type: \"CARD\", description: \"Visa 1111\", info: GooglePayPaymentMethodInfo { card_network: \"VISA\", card_details: \"1111\", assurance_details: None }, tokenization_data: Decrypted(GooglePayDecryptedData { card_exp_month: *** alloc::string::String ***, card_exp_year: *** alloc::string::String ***, application_primary_account_number: CardNumber(411111**********), cryptogram: Some(*** alloc::string::String ***), eci_indicator: Some(\"05\") }) }))" + }, + "GooglePayThirdPartySdk": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(GooglePayThirdPartySdk(GooglePayThirdPartySdkData { token: Some(*** alloc::string::String ***) }))" + }, + "Ideal": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Ideal { bank_name: None })" + }, + "Indomaret": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Indomaret(IndomaretVoucherData))" + }, + "IndonesianBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(IndonesianBankTransfer { bank_name: None })" + }, + "InstantBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(InstantBankTransfer)" + }, + "InstantBankTransferFinland": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(InstantBankTransferFinland)" + }, + "InstantBankTransferPoland": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(InstantBankTransferPoland)" + }, + "Interac": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Interac { country: None, email: None })" + }, + "KakaoPay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(KakaoPayRedirect(KakaoPayRedirection))" + }, + "Klarna": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: PayLater(KlarnaRedirect)" + }, + "Lawson": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Lawson(JCSVoucherData))" + }, + "LazyPayRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(LazyPayRedirect(LazyPayRedirection))" + }, + "LocalBankRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(LocalBankRedirect)" + }, + "LocalBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(LocalBankTransfer { bank_code: None })" + }, + "MandiriVaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(MandiriVaBankTransfer)" + }, + "MbWay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(MbWay(MbWayData))" + }, + "Mifinity": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(Mifinity(MifinityData { date_of_birth: *** time::date::Date ***, language_preference: Some(\"en\") }))" + }, + "MiniStop": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(MiniStop(JCSVoucherData))" + }, + "MobilePayRedirect": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "Momo": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(MomoRedirect(MomoRedirection))" + }, + "MultibancoBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(MultibancoBankTransfer)" + }, + "Netbanking": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Netbanking { issuer: HdfcBank })" + }, + "OnlineBankingCzechRepublic": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingCzechRepublic { issuer: CeskaSporitelna })" + }, + "OnlineBankingFinland": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingFinland { email: Some(Email(****@example.com)) })" + }, + "OnlineBankingFpx": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingFpx { issuer: Maybank })" + }, + "OnlineBankingPoland": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingPoland { issuer: BankPekaoSa })" + }, + "OnlineBankingSlovakia": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingSlovakia { issuer: TatraPay })" + }, + "OnlineBankingThailand": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OnlineBankingThailand { issuer: BangkokBank })" + }, + "OpenBanking": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OpenBanking { account_number: None, sort_code: None, iban: None, account_holder_name: None, additional_details: None })" + }, + "OpenBankingPis": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "OpenBankingUk": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(OpenBankingUk { issuer: None, country: None })" + }, + "Oxxo": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Oxxo)" + }, + "PagoEfectivo": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(PagoEfectivo)" + }, + "PayEasy": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(PayEasy(JCSVoucherData))" + }, + "PaySafeCard": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: GiftCard(PaySafeCard)" + }, + "PayURedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(PayURedirect(PayURedirection))" + }, + "PaypalRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(PaypalRedirect(PaypalRedirection { email: Some(Email(****@example.com)) }))" + }, + "PaypalSdk": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(PaypalSdk(PayPalWalletData { token: \"probe_paypal_sdk_token\" }))" + }, + "Paysera": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "Paze": { + "status": "not_supported", + "error": "Invalid data format: payment_method. The provided payment method variant is empty or not supported by this flow" + }, + "PermataBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(PermataBankTransfer)" + }, + "PhonePeRedirect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(PhonePeRedirect(PhonePeRedirection))" + }, + "Pix": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(Pix { pix_key: None, cpf: None, cnpj: None, source_bank_account_id: None, destination_bank_account_id: None, expiry_date: None })" + }, + "Przelewy24": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Przelewy24 { bank_name: None })" + }, + "Pse": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(Pse)" + }, + "RedCompra": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(RedCompra)" + }, + "RedPagos": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(RedPagos)" + }, + "RevolutPay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(RevolutPay(RevolutPayData))" + }, + "SamsungPay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(SamsungPay(SamsungPayWalletData { payment_credential: SamsungPayWalletCredentials { method: Some(\"3DS\"), recurring_payment: Some(false), card_brand: Visa, dpan_last_four_digits: None, card_last_four_digits: \"1234\", token_data: SamsungPayTokenData { three_ds_type: Some(\"S\"), version: \"100\", data: *** alloc::string::String *** } } }))" + }, + "Satispay": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(Satispay(SatispayData))" + }, + "Seicomart": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(Seicomart(JCSVoucherData))" + }, + "Sepa": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankDebit(SepaBankDebit { iban: *** alloc::string::String ***, bank_account_holder_name: Some(*** alloc::string::String ***) })" + }, + "SepaBankTransfer": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankTransfer(SepaBankTransfer)" + }, + "SepaGuaranteedDebit": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankDebit(SepaGuaranteedBankDebit { iban: *** alloc::string::String ***, bank_account_holder_name: Some(*** alloc::string::String ***) })" + }, + "SevenEleven": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Voucher(SevenEleven(JCSVoucherData))" + }, + "Skrill": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(Skrill(SkrillData))" + }, + "Sofort": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Sofort { country: None, preferred_language: None })" + }, + "Swish": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(SwishQr(SwishQrData))" + }, + "TouchNGo": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(TouchNGoRedirect(TouchNGoRedirection))" + }, + "Trustly": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: BankRedirect(Trustly { country: None })" + }, + "Twint": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(TwintRedirect)" + }, + "UpiCollect": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Upi(UpiCollect(UpiCollectData { vpa_id: Some(****@upi), upi_source: None }))" + }, + "UpiIntent": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Upi(UpiIntent(UpiIntentData { upi_source: None, app_name: None }))" + }, + "UpiQr": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Upi(UpiQr(UpiQrData { upi_source: None }))" + }, + "Vipps": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(VippsRedirect)" + }, + "WeChatPayQr": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(WeChatPayQr(WeChatPayQr))" + }, + "Wero": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: Wallet(Wero(WeroData))" + } + }, + "capture": { + "default": { + "status": "supported", + "proto_request": { + "merchant_capture_id": "probe_capture_001", + "connector_transaction_id": "probe_connector_txn_001", + "amount_to_capture": { + "minor_amount": 1000, + "currency": "USD" + } + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/direct/capture", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"version\":2,\"transaction\":{\"merchantRef\":\"probe_capture_001\",\"amount\":\"1000\",\"currency\":\"USD\"},\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"}}" + } + } + }, + "create_client_authentication_token": { + "default": { + "status": "not_supported", + "error": "client_authentication_token flow not supported by ilixium connector" + } + }, + "create_order": { + "default": { + "status": "not_supported", + "error": "create_order flow not supported by ilixium connector" + } + }, + "create_server_authentication_token": { + "default": { + "status": "not_supported", + "error": "server_authentication_token flow not supported by ilixium connector" + } + }, + "create_server_session_authentication_token": { + "default": { + "status": "not_supported", + "error": "server_session_authentication_token flow not supported by ilixium connector" + } + }, + "customer_create": { + "default": { + "status": "not_supported", + "error": "create_connector_customer flow not supported by ilixium connector" + } + }, + "customer_get": { + "default": { + "status": "not_supported", + "error": "get_connector_customer flow not supported by ilixium connector" + } + }, + "dispute_accept": { + "default": { + "status": "not_supported", + "error": "accept_dispute flow not supported by ilixium connector" + } + }, + "dispute_defend": { + "default": { + "status": "not_supported", + "error": "defend_dispute flow not supported by ilixium connector" + } + }, + "dispute_get": { + "default": { + "status": "not_implemented" + } + }, + "dispute_submit_evidence": { + "default": { + "status": "not_supported", + "error": "submit_evidence flow not supported by ilixium connector" + } + }, + "eligibility": { + "default": { + "status": "not_supported", + "error": "eligibility flow not supported by ilixium connector" + } + }, + "get": { + "default": { + "status": "supported", + "proto_request": { + "merchant_transaction_id": "probe_merchant_txn_001", + "connector_transaction_id": "probe_connector_txn_001", + "amount": { + "minor_amount": 1000, + "currency": "USD" + } + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/history/operations", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"periodStartDate\":\"2020-01-01T00:00:00+00:00\",\"periodEndDate\":\"2020-01-01T00:00:00+00:00\",\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"},\"reportFormat\":\"JSON\"}" + } + } + }, + "handle_event": { + "default": { + "status": "not_implemented" + } + }, + "incremental_authorization": { + "default": { + "status": "not_supported", + "error": "incremental_authorization flow not supported by ilixium connector" + } + }, + "parse_event": { + "default": { + "status": "not_implemented" + } + }, + "payment_method_eligibility": { + "default": { + "status": "not_implemented" + } + }, + "post_authenticate": { + "default": { + "status": "not_supported", + "error": "post_authenticate flow not supported by ilixium connector" + } + }, + "pre_authenticate": { + "default": { + "status": "error", + "error": "Stuck on field: connector_request_reference_id — Missing required field: connector_request_reference_id" + } + }, + "proxy_authorize": { + "default": { + "status": "supported", + "proto_request": { + "merchant_transaction_id": "probe_proxy_txn_001", + "amount": { + "minor_amount": 1000, + "currency": "USD" + }, + "card_proxy": { + "card_number": "4111111111111111", + "card_exp_month": "03", + "card_exp_year": "2030", + "card_cvc": "123", + "card_holder_name": "John Doe", + "card_network": "VISA" + }, + "customer": { + "email": "test@example.com" + }, + "address": { + "billing_address": { + "first_name": "John", + "last_name": "Doe", + "country_alpha2_code": "US" + } + }, + "capture_method": "AUTOMATIC", + "auth_type": "NO_THREE_DS", + "return_url": "https://example.com/return" + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/direct/auth", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"version\":2,\"deferredCapture\":false,\"transaction\":{\"transactionType\":\"ECOMMERCE\",\"merchantRef\":\"probe_proxy_txn_001\",\"amount\":\"1000\",\"currency\":\"USD\"},\"paymentMethodType\":\"CARD\",\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"},\"card\":{\"cardNumber\":\"{{$card_number}}\",\"expiryDate\":\"032030\",\"securityCode\":\"{{$card_cvc}}\"},\"customer\":{\"customerId\":\"probe_proxy_txn_001\",\"email\":\"test@example.com\",\"firstName\":\"John\",\"surname\":\"Doe\",\"address\":{\"country\":\"US\"}},\"paymentInfo\":{\"country\":\"US\"}}" + } + } + }, + "proxy_setup_recurring": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: setup_mandate flow for ilixium" + } + }, + "recurring_charge": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: repeat_payment flow for ilixium" + } + }, + "recurring_revoke": { + "default": { + "status": "not_supported", + "error": "mandate_revoke flow not supported by ilixium connector" + } + }, + "refresh": { + "default": { + "status": "not_implemented" + } + }, + "refund": { + "default": { + "status": "supported", + "proto_request": { + "merchant_refund_id": "probe_refund_001", + "connector_transaction_id": "probe_connector_txn_001", + "payment_amount": 1000, + "refund_amount": { + "minor_amount": 1000, + "currency": "USD" + }, + "reason": "customer_request", + "connector_order_id": "connector_order_id" + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/direct/refund", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"version\":2,\"transaction\":{\"merchantRef\":\"connector_order_id\",\"amount\":\"1000\",\"currency\":\"USD\"},\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"}}" + } + } + }, + "refund_get": { + "default": { + "status": "error", + "error": "Stuck on field: connector_order_id. RefundFlowData::connector_request_reference_id is derived from merchant_refund_id, so it identifies the refund rather than the payment, and connector_transaction_id holds Ilixium's gatewayRef — Missing required field: connector_order_id. RefundFlowData::connector_request_reference_id is derived from merchant_refund_id, so it identifies the refund rather than the payment, and connector_transaction_id holds Ilixium's gatewayRef (a dashed UUID) which is not a merchantRef and cannot be converted into one. Rather than send a reference that matches no transaction — response code 104, 'No Matching Transaction', or a history filter that selects nothing — the request is refused here." + } + }, + "reverse": { + "default": { + "status": "not_supported", + "error": "void_post_capture flow not supported by ilixium connector" + } + }, + "setup_recurring": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: setup_mandate flow for ilixium" + } + }, + "token_authorize": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: Ilixium supports only raw card payments on POST /direct/auth; wallets, APMs, bank transfers and stored-card tokens are out of scope for this connector implementation. Unsupported payment_method_data variant for Ilixium /direct/auth: PaymentMethodToken(PaymentMethodToken { token: *** alloc::string::String *** })" + } + }, + "token_setup_recurring": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: setup_mandate flow for ilixium" + } + }, + "tokenize": { + "default": { + "status": "not_implemented", + "error": "This feature is not implemented: payment_method_token flow for ilixium" + } + }, + "verify_redirect": { + "default": { + "status": "not_implemented" + } + }, + "void": { + "default": { + "status": "supported", + "proto_request": { + "merchant_void_id": "probe_void_001", + "connector_transaction_id": "probe_connector_txn_001", + "amount": { + "minor_amount": 1000, + "currency": "USD" + } + }, + "sample": { + "url": "https://prprocessing.ilixium.com/platform/ili/direct/reversal", + "method": "Post", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "via": "HyperSwitch", + "x-merchant-digest": "cHJvYmVfa2V5OnByb2JlX3NlY3JldA==" + }, + "body": "{\"version\":2,\"transaction\":{\"merchantRef\":\"probe_void_001\",\"amount\":\"1000\",\"currency\":\"USD\"},\"merchant\":{\"merchantId\":\"probe_key\",\"accountId\":\"probe_key\"}}" + } + } + } + } +} \ No newline at end of file diff --git a/data/integration-source-links.json b/data/integration-source-links.json index 5fea39f78b..89d8929e95 100644 --- a/data/integration-source-links.json +++ b/data/integration-source-links.json @@ -116,5 +116,16 @@ "https://docs.tamara.co/docs/postman-collection", "https://docs.tamara.co/docs/testing-checklist", "https://docs.tamara.co/docs/online-go-live-testing-checklist" + ], + "Ilixium": [ + "https://docs.ilixium.com/docs/api/authorisation", + "https://docs.ilixium.com/docs/api/threedcomplete", + "https://docs.ilixium.com/docs/direct/3dsecure", + "https://docs.ilixium.com/docs/direct/digest", + "https://docs.ilixium.com/docs/direct/introduction", + "https://docs.ilixium.com/docs/direct/optional-address", + "https://docs.ilixium.com/docs/api/api", + "https://docs.ilixium.com/docs/response-codes/", + "https://docs.ilixium.com/docs/sandbox/" ] -} \ No newline at end of file +} diff --git a/docs-generated/all_connector.md b/docs-generated/all_connector.md index 22c10cb027..92ea68aef3 100644 --- a/docs-generated/all_connector.md +++ b/docs-generated/all_connector.md @@ -69,6 +69,7 @@ Authorize a payment amount on a payment method. This reserves funds without capt | [Hyperpg](connectors/hyperpg.md) | ✓ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | x | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | | [Hyperswitch](connectors/hyperswitch.md) | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | | [Iatapay](connectors/iatapay.md) | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | ✓ | ✓ | ✓ | x | x | x | x | x | x | x | x | x | ✓ | ✓ | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | +| [Ilixium](connectors/ilixium.md) | ✓ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | | [Imerchantsolutions](connectors/imerchantsolutions.md) | ✓ | ⚠ | ? | ✓ | ⚠ | ? | ✓ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | | [Itaubank](connectors/itaubank.md) | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | | [Jpmorgan](connectors/jpmorgan.md) | ✓ | x | ⚠ | ⚠ | ⚠ | ? | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | ✓ | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | @@ -183,6 +184,7 @@ Consolidated view of Get, Void, Refund, Capture, Reverse, CreateOrder, and other | [Hyperpg](connectors/hyperpg.md) | ✓ | ⚠ | x | ⚠ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | | [Hyperswitch](connectors/hyperswitch.md) | ✓ | ⚠ | x | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ? | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | x | ✓ | ✓ | x | | [Iatapay](connectors/iatapay.md) | ✓ | x | x | x | ⚠ | ✓ | x | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | x | ✓ | x | x | x | ⚠ | x | x | x | x | x | ✓ | ⚠ | ⚠ | x | x | x | x | ⚠ | x | x | ⚠ | ⚠ | x | +| [Ilixium](connectors/ilixium.md) | ✓ | ✓ | x | ✓ | x | ✓ | x | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ⚠ | ⚠ | x | ? | x | x | x | ⚠ | x | x | x | x | x | x | x | x | x | x | x | x | ⚠ | x | x | ⚠ | ⚠ | x | | [Imerchantsolutions](connectors/imerchantsolutions.md) | ✓ | ✓ | ⚠ | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ⚠ | ? | ⚠ | ✓ | x | x | x | ⚠ | x | x | x | x | x | ⚠ | x | x | x | x | x | x | ⚠ | x | x | ✓ | ✓ | x | | [Itaubank](connectors/itaubank.md) | ⚠ | x | x | x | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | x | x | x | x | x | x | x | x | ✓ | x | x | x | x | x | x | ⚠ | x | x | ⚠ | ⚠ | x | | [Jpmorgan](connectors/jpmorgan.md) | ✓ | ✓ | ? | ✓ | ⚠ | ✓ | ⚠ | ⚠ | ✓ | ✓ | ⚠ | ✓ | ✓ | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | ✓ | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | diff --git a/docs-generated/connectors/ilixium.md b/docs-generated/connectors/ilixium.md new file mode 100644 index 0000000000..2b204cccfd --- /dev/null +++ b/docs-generated/connectors/ilixium.md @@ -0,0 +1,366 @@ +# Ilixium + + + +## SDK Configuration + +Use this config for all flows in this connector. Replace `YOUR_API_KEY` with your actual credentials. + + + + + + + + + +
PythonJavaScriptKotlinRust
+ +
Python + +```python +from payments.generated import sdk_config_pb2, payment_pb2, payment_methods_pb2 + +config = sdk_config_pb2.ConnectorConfig( + options=sdk_config_pb2.SdkOptions(environment=sdk_config_pb2.Environment.SANDBOX), + connector_config=payment_pb2.ConnectorSpecificConfig( + ilixium=payment_pb2.IlixiumConfig( + api_key=payment_methods_pb2.SecretString(value="YOUR_API_KEY"), + key1=payment_methods_pb2.SecretString(value="YOUR_KEY1"), + api_secret=payment_methods_pb2.SecretString(value="YOUR_API_SECRET"), + base_url="YOUR_BASE_URL", + ), + ), +) + +``` + +
+ +
+ +
JavaScript + +```javascript +const { PaymentClient } = require('hyperswitch-prism'); +const { ConnectorConfig, Environment, Connector } = require('hyperswitch-prism').types; + +const config = ConnectorConfig.create({ + connector: Connector.ILIXIUM, + environment: Environment.SANDBOX, + auth: { + ilixium: { + apiKey: { value: 'YOUR_API_KEY' }, + key1: { value: 'YOUR_KEY1' }, + apiSecret: { value: 'YOUR_API_SECRET' }, + baseUrl: 'YOUR_BASE_URL', + } + }, +}); +``` + +
+ +
+ +
Kotlin + +```kotlin +val config = ConnectorConfig.newBuilder() + .setOptions(SdkOptions.newBuilder().setEnvironment(Environment.SANDBOX).build()) + .setConnectorConfig( + ConnectorSpecificConfig.newBuilder() + .setIlixium(IlixiumConfig.newBuilder() + .setApiKey(SecretString.newBuilder().setValue("YOUR_API_KEY").build()) + .setKey1(SecretString.newBuilder().setValue("YOUR_KEY1").build()) + .setApiSecret(SecretString.newBuilder().setValue("YOUR_API_SECRET").build()) + .setBaseUrl("YOUR_BASE_URL") + .build()) + .build() + ) + .build() +``` + +
+ +
+ +
Rust + +```rust +use grpc_api_types::payments::*; +use grpc_api_types::payments::connector_specific_config; + +let config = ConnectorConfig { + connector_config: Some(ConnectorSpecificConfig { + config: Some(connector_specific_config::Config::Ilixium(IlixiumConfig { + api_key: Some(hyperswitch_masking::Secret::new("YOUR_API_KEY".to_string())), // Authentication credential + key1: Some(hyperswitch_masking::Secret::new("YOUR_KEY1".to_string())), // Authentication credential + api_secret: Some(hyperswitch_masking::Secret::new("YOUR_API_SECRET".to_string())), // Authentication credential + base_url: Some("https://sandbox.example.com".to_string()), // Base URL for API calls + ..Default::default() + })), + }), + options: Some(SdkOptions { + environment: Environment::Sandbox.into(), + }), +}; +``` + +
+ +
+ +## Integration Scenarios + +Complete, runnable examples for common integration patterns. Each example shows the full flow with status handling. Copy-paste into your app and replace placeholder values. + +### One-step Payment (Authorize + Capture) + +Simple payment that authorizes and captures in one call. Use for immediate charges. + +**Response status handling:** + +| Status | Recommended action | +|--------|-------------------| +| `AUTHORIZED` | Payment authorized and captured — funds will be settled automatically | +| `PENDING` | Payment processing — await webhook for final status before fulfilling | +| `FAILED` | Payment declined — surface error to customer, do not retry without new details | + +**Examples:** [Python](../../examples/ilixium/ilixium.py#L133) · [JavaScript](../../examples/ilixium/ilixium.js) · [Kotlin](../../examples/ilixium/ilixium.kt#L124) · [Rust](../../examples/ilixium/ilixium.rs#L168) + +### Card Payment (Authorize + Capture) + +Two-step card payment. First authorize, then capture. Use when you need to verify funds before finalizing. + +**Response status handling:** + +| Status | Recommended action | +|--------|-------------------| +| `AUTHORIZED` | Funds reserved — proceed to Capture to settle | +| `PENDING` | Awaiting async confirmation — wait for webhook before capturing | +| `FAILED` | Payment declined — surface error to customer, do not retry without new details | + +**Examples:** [Python](../../examples/ilixium/ilixium.py#L152) · [JavaScript](../../examples/ilixium/ilixium.js) · [Kotlin](../../examples/ilixium/ilixium.kt#L140) · [Rust](../../examples/ilixium/ilixium.rs#L184) + +### Refund + +Return funds to the customer for a completed payment. + +**Examples:** [Python](../../examples/ilixium/ilixium.py#L177) · [JavaScript](../../examples/ilixium/ilixium.js) · [Kotlin](../../examples/ilixium/ilixium.kt#L162) · [Rust](../../examples/ilixium/ilixium.rs#L207) + +### Void Payment + +Cancel an authorized but not-yet-captured payment. + +**Examples:** [Python](../../examples/ilixium/ilixium.py#L202) · [JavaScript](../../examples/ilixium/ilixium.js) · [Kotlin](../../examples/ilixium/ilixium.kt#L184) · [Rust](../../examples/ilixium/ilixium.rs#L230) + +### Get Payment Status + +Retrieve current payment status from the connector. + +**Examples:** [Python](../../examples/ilixium/ilixium.py#L224) · [JavaScript](../../examples/ilixium/ilixium.js) · [Kotlin](../../examples/ilixium/ilixium.kt#L203) · [Rust](../../examples/ilixium/ilixium.rs#L249) + +## API Reference + +| Flow (Service.RPC) | Category | gRPC Request Message | +|--------------------|----------|----------------------| +| [PaymentService.Authorize](#paymentserviceauthorize) | Payments | `PaymentServiceAuthorizeRequest` | +| [PaymentService.Capture](#paymentservicecapture) | Payments | `PaymentServiceCaptureRequest` | +| [PaymentService.Get](#paymentserviceget) | Payments | `PaymentServiceGetRequest` | +| [PaymentService.ProxyAuthorize](#paymentserviceproxyauthorize) | Payments | `PaymentServiceProxyAuthorizeRequest` | +| [PaymentService.Refund](#paymentservicerefund) | Payments | `PaymentServiceRefundRequest` | +| [PaymentService.Void](#paymentservicevoid) | Payments | `PaymentServiceVoidRequest` | + +### Payments + +#### PaymentService.Authorize + +Authorize a payment amount on a payment method. This reserves funds without capturing them, essential for verifying availability before finalizing. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceAuthorizeRequest` | +| **Response** | `PaymentServiceAuthorizeResponse` | + +**Supported payment method types:** + +| Payment Method | Supported | +|----------------|:---------:| +| Card | ✓ | +| Bancontact | ⚠ | +| Apple Pay | ⚠ | +| Apple Pay Dec | ⚠ | +| Apple Pay SDK | ⚠ | +| Google Pay | ⚠ | +| Google Pay Dec | ⚠ | +| Google Pay SDK | ⚠ | +| PayPal SDK | ⚠ | +| Amazon Pay | ⚠ | +| Cash App | ⚠ | +| PayPal | ⚠ | +| WeChat Pay | ⚠ | +| Alipay | ⚠ | +| Revolut Pay | ⚠ | +| MiFinity | ⚠ | +| Bluecode | ⚠ | +| Paze | x | +| Samsung Pay | ⚠ | +| MB Way | ⚠ | +| Satispay | ⚠ | +| Wero | ⚠ | +| GoPay | ⚠ | +| GCash | ⚠ | +| Momo | ⚠ | +| Dana | ⚠ | +| Kakao Pay | ⚠ | +| Touch 'n Go | ⚠ | +| Twint | ⚠ | +| Vipps | ⚠ | +| Swish | ⚠ | +| Affirm | ⚠ | +| Afterpay | ⚠ | +| Klarna | ⚠ | +| UPI Collect | ⚠ | +| UPI Intent | ⚠ | +| UPI QR | ⚠ | +| Thailand | ⚠ | +| Czech | ⚠ | +| Finland | ⚠ | +| FPX | ⚠ | +| Poland | ⚠ | +| Slovakia | ⚠ | +| UK | ⚠ | +| PIS | x | +| Generic | ⚠ | +| Local | ⚠ | +| iDEAL | ⚠ | +| Sofort | ⚠ | +| Trustly | ⚠ | +| Giropay | ⚠ | +| EPS | ⚠ | +| Przelewy24 | ⚠ | +| PSE | ⚠ | +| BLIK | ⚠ | +| Interac | ⚠ | +| Bizum | ⚠ | +| EFT | ⚠ | +| DuitNow | x | +| ACH | ⚠ | +| SEPA | ⚠ | +| BACS | ⚠ | +| Multibanco | ⚠ | +| Instant | ⚠ | +| Instant FI | ⚠ | +| Instant PL | ⚠ | +| Pix | ⚠ | +| Permata | ⚠ | +| BCA | ⚠ | +| BNI VA | ⚠ | +| BRI VA | ⚠ | +| CIMB VA | ⚠ | +| Danamon VA | ⚠ | +| Mandiri VA | ⚠ | +| Local | ⚠ | +| Indonesian | ⚠ | +| ACH | ⚠ | +| SEPA | ⚠ | +| BACS | ⚠ | +| BECS | ⚠ | +| SEPA Guaranteed | ⚠ | +| Crypto | x | +| Reward | ⚠ | +| Givex | x | +| PaySafeCard | ⚠ | +| E-Voucher | ⚠ | +| Boleto | ⚠ | +| Efecty | ⚠ | +| Pago Efectivo | ⚠ | +| Red Compra | ⚠ | +| Red Pagos | ⚠ | +| Alfamart | ⚠ | +| Indomaret | ⚠ | +| Oxxo | ⚠ | +| 7-Eleven | ⚠ | +| Lawson | ⚠ | +| Mini Stop | ⚠ | +| Family Mart | ⚠ | +| Seicomart | ⚠ | +| Pay Easy | ⚠ | + +**Payment method objects** — use these in the `payment_method` field of the Authorize request. + +##### Card (Raw PAN) + +```python +"payment_method": { + "card": { + "card_number": "4111111111111111", + "card_exp_month": "03", + "card_exp_year": "2030", + "card_cvc": "737", + "card_holder_name": "John Doe" + } +} +``` + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts#L255) · [Kotlin](../../examples/ilixium/ilixium.kt#L221) · [Rust](../../examples/ilixium/ilixium.rs) + +#### PaymentService.Capture + +Finalize an authorized payment by transferring funds. Captures the authorized amount to complete the transaction and move funds to your merchant account. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceCaptureRequest` | +| **Response** | `PaymentServiceCaptureResponse` | + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts#L264) · [Kotlin](../../examples/ilixium/ilixium.kt#L233) · [Rust](../../examples/ilixium/ilixium.rs) + +#### PaymentService.Get + +Retrieve current payment status from the payment processor. Enables synchronization between your system and payment processors for accurate state tracking. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceGetRequest` | +| **Response** | `PaymentServiceGetResponse` | + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts#L273) · [Kotlin](../../examples/ilixium/ilixium.kt#L243) · [Rust](../../examples/ilixium/ilixium.rs) + +#### PaymentService.ProxyAuthorize + +Authorize using vault-aliased card data. Proxy substitutes before connector. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceProxyAuthorizeRequest` | +| **Response** | `PaymentServiceAuthorizeResponse` | + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts#L282) · [Kotlin](../../examples/ilixium/ilixium.kt#L251) · [Rust](../../examples/ilixium/ilixium.rs) + +#### PaymentService.Refund + +Process a partial or full refund for a captured payment. Returns funds to the customer when goods are returned or services are cancelled. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceRefundRequest` | +| **Response** | `RefundResponse` | + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts#L291) · [Kotlin](../../examples/ilixium/ilixium.kt#L286) · [Rust](../../examples/ilixium/ilixium.rs) + +#### PaymentService.Void + +Cancel an authorized payment that has not been captured. Releases held funds back to the customer's payment method when a transaction cannot be completed. + +| | Message | +|---|---------| +| **Request** | `PaymentServiceVoidRequest` | +| **Response** | `PaymentServiceVoidResponse` | + +**Examples:** [Python](../../examples/ilixium/ilixium.py) · [TypeScript](../../examples/ilixium/ilixium.ts) · [Kotlin](../../examples/ilixium/ilixium.kt#L296) · [Rust](../../examples/ilixium/ilixium.rs) diff --git a/docs-generated/llms.txt b/docs-generated/llms.txt index 8a88682a0b..23a16ffaf1 100644 --- a/docs-generated/llms.txt +++ b/docs-generated/llms.txt @@ -1,5 +1,5 @@ # Connector Service — LLM Navigation Index -# Connectors: 105 +# Connectors: 106 # # This file helps AI coding assistants navigate connector-service documentation. # Each connector block lists: doc path, scenarios, supported payment methods, @@ -8,7 +8,7 @@ # Usage: fetch this file first, then fetch the specific connector doc or example. overview: - total_connectors: 105 + total_connectors: 106 docs_root: docs-generated/connectors/ examples_root: examples/ all_connectors_matrix: docs-generated/all_connector.md @@ -390,6 +390,14 @@ payment_methods: Ideal, LocalBankRedirect, UpiCollect, UpiIntent, UpiQr flows: authorize, create_server_authentication_token, get, refund, refund_get examples_python: none +## Ilixium +connector_id: ilixium +doc: docs/connectors/ilixium.md +scenarios: checkout_autocapture, checkout_card, refund, void_payment, get_payment +payment_methods: Card +flows: authorize, capture, get, proxy_authorize, refund, void +examples_python: examples/ilixium/ilixium.py + ## Imerchantsolutions connector_id: imerchantsolutions doc: docs/connectors/imerchantsolutions.md diff --git a/examples/ilixium/ilixium.kt b/examples/ilixium/ilixium.kt new file mode 100644 index 0000000000..b185f9255e --- /dev/null +++ b/examples/ilixium/ilixium.kt @@ -0,0 +1,323 @@ +// This file is auto-generated. Do not edit manually. +// Replace YOUR_API_KEY and placeholder values with real data. +// Regenerate: python3 scripts/generate-connector-docs.py ilixium +// +// Ilixium — all scenarios and flows in one file. +// Run a scenario: ./gradlew run --args="ilixium processCheckoutCard" + +package examples.ilixium + +import types.Payment.* +import types.PaymentMethods.* +import payments.PaymentClient +import payments.AuthenticationType +import payments.CaptureMethod +import payments.CardNetwork +import payments.CountryAlpha2 +import payments.Currency +import payments.ConnectorConfig +import payments.SdkOptions +import payments.Environment +import payments.ConnectorSpecificConfig +import types.Payment.IlixiumConfig +import payments.SecretString + +val SUPPORTED_FLOWS = listOf("authorize", "capture", "get", "proxy_authorize", "refund", "void") + +val _defaultConfig: ConnectorConfig = ConnectorConfig.newBuilder() + .setOptions(SdkOptions.newBuilder().setEnvironment(Environment.SANDBOX).build()) + .setConnectorConfig( + ConnectorSpecificConfig.newBuilder() + .setIlixium(IlixiumConfig.newBuilder() + .setApiKey(SecretString.newBuilder().setValue("YOUR_API_KEY").build()) + .setKey1(SecretString.newBuilder().setValue("YOUR_KEY1").build()) + .setApiSecret(SecretString.newBuilder().setValue("YOUR_API_SECRET").build()) + .setBaseUrl("YOUR_BASE_URL") + .build()) + .build() + ) + .build() + + + +private fun buildAuthorizeRequest(captureMethodStr: String): PaymentServiceAuthorizeRequest { + return PaymentServiceAuthorizeRequest.newBuilder().apply { + merchantTransactionId = "probe_txn_001" // Identification. + amountBuilder.apply { // The amount for the payment. + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + paymentMethodBuilder.apply { // Payment method to be used. + cardBuilder.apply { // Generic card payment. + cardNumberBuilder.value = "4111111111111111" // Card Identification. + cardExpMonthBuilder.value = "03" + cardExpYearBuilder.value = "2030" + cardCvcBuilder.value = "737" + cardHolderNameBuilder.value = "John Doe" // Cardholder Information. + } + } + captureMethod = CaptureMethod.valueOf(captureMethodStr) // Method for capturing the payment. + customerBuilder.apply { // Customer Information. + emailBuilder.value = "test@example.com" // Customer's email address. + } + addressBuilder.apply { // Address Information. + billingAddressBuilder.apply { + firstNameBuilder.value = "John" // Personal Information. + lastNameBuilder.value = "Doe" + countryAlpha2Code = CountryAlpha2.US + } + } + authType = AuthenticationType.NO_THREE_DS // Authentication Details. + returnUrl = "https://example.com/return" // URLs for Redirection and Webhooks. + }.build() +} + +private fun buildCaptureRequest(connectorTransactionIdStr: String): PaymentServiceCaptureRequest { + return PaymentServiceCaptureRequest.newBuilder().apply { + merchantCaptureId = "probe_capture_001" // Identification. + connectorTransactionId = connectorTransactionIdStr + amountToCaptureBuilder.apply { // Capture Details. + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }.build() +} + +private fun buildGetRequest(connectorTransactionIdStr: String): PaymentServiceGetRequest { + return PaymentServiceGetRequest.newBuilder().apply { + merchantTransactionId = "probe_merchant_txn_001" // Identification. + connectorTransactionId = connectorTransactionIdStr + amountBuilder.apply { // Amount Information. + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }.build() +} + +private fun buildRefundRequest(connectorTransactionIdStr: String): PaymentServiceRefundRequest { + return PaymentServiceRefundRequest.newBuilder().apply { + merchantRefundId = "probe_refund_001" // Identification. + connectorTransactionId = connectorTransactionIdStr + paymentAmount = 1000L // Amount Information. + refundAmountBuilder.apply { + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + reason = "customer_request" // Reason for the refund. + connectorOrderId = "connector_order_id" // Connector-side identifier for the original payment that this refund targets. + }.build() +} + +private fun buildVoidRequest(connectorTransactionIdStr: String): PaymentServiceVoidRequest { + return PaymentServiceVoidRequest.newBuilder().apply { + merchantVoidId = "probe_void_001" // Identification. + connectorTransactionId = connectorTransactionIdStr + amountBuilder.apply { // Amount Information. + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }.build() +} + +// Scenario: One-step Payment (Authorize + Capture) +// Simple payment that authorizes and captures in one call. Use for immediate charges. +fun processCheckoutAutocapture(txnId: String, config: ConnectorConfig = _defaultConfig): Map { + val paymentClient = PaymentClient(config) + + // Step 1: Authorize — reserve funds on the payment method + val authorizeResponse = paymentClient.authorize(buildAuthorizeRequest("AUTOMATIC")) + + when (authorizeResponse.status.name) { + "FAILED" -> throw RuntimeException("Payment failed: ${authorizeResponse.error.unifiedDetails.message}") + "PENDING" -> return mapOf("status" to "PENDING") // await webhook before proceeding + } + + return mapOf("status" to authorizeResponse.status.name, "transactionId" to authorizeResponse.connectorTransactionId, "error" to authorizeResponse.error) +} + +// Scenario: Card Payment (Authorize + Capture) +// Two-step card payment. First authorize, then capture. Use when you need to verify funds before finalizing. +fun processCheckoutCard(txnId: String, config: ConnectorConfig = _defaultConfig): Map { + val paymentClient = PaymentClient(config) + + // Step 1: Authorize — reserve funds on the payment method + val authorizeResponse = paymentClient.authorize(buildAuthorizeRequest("MANUAL")) + + when (authorizeResponse.status.name) { + "FAILED" -> throw RuntimeException("Payment failed: ${authorizeResponse.error.unifiedDetails.message}") + "PENDING" -> return mapOf("status" to "PENDING") // await webhook before proceeding + } + + // Step 2: Capture — settle the reserved funds + val captureResponse = paymentClient.capture(buildCaptureRequest(authorizeResponse.connectorTransactionId ?: "")) + + if (captureResponse.status.name == "FAILED") + throw RuntimeException("Capture failed: ${captureResponse.error.unifiedDetails.message}") + + return mapOf("status" to captureResponse.status.name, "transactionId" to authorizeResponse.connectorTransactionId, "error" to authorizeResponse.error) +} + +// Scenario: Refund +// Return funds to the customer for a completed payment. +fun processRefund(txnId: String, config: ConnectorConfig = _defaultConfig): Map { + val paymentClient = PaymentClient(config) + + // Step 1: Authorize — reserve funds on the payment method + val authorizeResponse = paymentClient.authorize(buildAuthorizeRequest("AUTOMATIC")) + + when (authorizeResponse.status.name) { + "FAILED" -> throw RuntimeException("Payment failed: ${authorizeResponse.error.unifiedDetails.message}") + "PENDING" -> return mapOf("status" to "PENDING") // await webhook before proceeding + } + + // Step 2: Refund — return funds to the customer + val refundResponse = paymentClient.refund(buildRefundRequest(authorizeResponse.connectorTransactionId ?: "")) + + if (refundResponse.status.name == "FAILED") + throw RuntimeException("Refund failed: ${refundResponse.error.unifiedDetails.message}") + + return mapOf("status" to refundResponse.status.name, "error" to refundResponse.error) +} + +// Scenario: Void Payment +// Cancel an authorized but not-yet-captured payment. +fun processVoidPayment(txnId: String, config: ConnectorConfig = _defaultConfig): Map { + val paymentClient = PaymentClient(config) + + // Step 1: Authorize — reserve funds on the payment method + val authorizeResponse = paymentClient.authorize(buildAuthorizeRequest("MANUAL")) + + when (authorizeResponse.status.name) { + "FAILED" -> throw RuntimeException("Payment failed: ${authorizeResponse.error.unifiedDetails.message}") + "PENDING" -> return mapOf("status" to "PENDING") // await webhook before proceeding + } + + // Step 2: Void — release reserved funds (cancel authorization) + val voidResponse = paymentClient.void(buildVoidRequest(authorizeResponse.connectorTransactionId ?: "")) + + return mapOf("status" to voidResponse.status.name, "transactionId" to authorizeResponse.connectorTransactionId, "error" to voidResponse.error) +} + +// Scenario: Get Payment Status +// Retrieve current payment status from the connector. +fun processGetPayment(txnId: String, config: ConnectorConfig = _defaultConfig): Map { + val paymentClient = PaymentClient(config) + + // Step 1: Authorize — reserve funds on the payment method + val authorizeResponse = paymentClient.authorize(buildAuthorizeRequest("MANUAL")) + + when (authorizeResponse.status.name) { + "FAILED" -> throw RuntimeException("Payment failed: ${authorizeResponse.error.unifiedDetails.message}") + "PENDING" -> return mapOf("status" to "PENDING") // await webhook before proceeding + } + + // Step 2: Get — retrieve current payment status from the connector + val getResponse = paymentClient.get(buildGetRequest(authorizeResponse.connectorTransactionId ?: "")) + + return mapOf("status" to getResponse.status.name, "transactionId" to getResponse.connectorTransactionId, "error" to getResponse.error) +} + +// Flow: PaymentService.Authorize (Card) +fun authorize(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = buildAuthorizeRequest("AUTOMATIC") + val response = client.authorize(request) + when (response.status.name) { + "FAILED" -> throw RuntimeException("Authorize failed: ${response.error.unifiedDetails.message}") + "PENDING" -> println("Pending — await webhook before proceeding") + else -> println("Authorized: ${response.connectorTransactionId}") + } +} + +// Flow: PaymentService.Capture +fun capture(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = buildCaptureRequest("probe_connector_txn_001") + val response = client.capture(request) + if (response.status.name == "FAILED") + throw RuntimeException("Capture failed: ${response.error.unifiedDetails.message}") + println("Done: ${response.status.name}") +} + +// Flow: PaymentService.Get +fun get(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = buildGetRequest("probe_connector_txn_001") + val response = client.get(request) + println("Status: ${response.status.name}") +} + +// Flow: PaymentService.ProxyAuthorize +fun proxyAuthorize(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = PaymentServiceProxyAuthorizeRequest.newBuilder().apply { + merchantTransactionId = "probe_proxy_txn_001" + amountBuilder.apply { + minorAmount = 1000L // Amount in minor units (e.g., 1000 = $10.00). + currency = Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + cardProxyBuilder.apply { // Card proxy for vault-aliased payments (VGS, Basis Theory, Spreedly). Real card values are substituted by the proxy before reaching the connector. + cardNumberBuilder.value = "4111111111111111" // Card Identification. + cardExpMonthBuilder.value = "03" + cardExpYearBuilder.value = "2030" + cardCvcBuilder.value = "123" + cardHolderNameBuilder.value = "John Doe" // Cardholder Information. + cardNetwork = CardNetwork.VISA + } + customerBuilder.apply { + emailBuilder.value = "test@example.com" // Customer's email address. + } + addressBuilder.apply { + billingAddressBuilder.apply { + firstNameBuilder.value = "John" // Personal Information. + lastNameBuilder.value = "Doe" + countryAlpha2Code = CountryAlpha2.US + } + } + captureMethod = CaptureMethod.AUTOMATIC + authType = AuthenticationType.NO_THREE_DS + returnUrl = "https://example.com/return" + }.build() + val response = client.proxy_authorize(request) + println("Status: ${response.status.name}") +} + +// Flow: PaymentService.Refund +fun refund(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = buildRefundRequest("probe_connector_txn_001") + val response = client.refund(request) + if (response.status.name == "FAILED") + throw RuntimeException("Refund failed: ${response.error.unifiedDetails.message}") + println("Done: ${response.status.name}") +} + +// Flow: PaymentService.Void +fun void(txnId: String, config: ConnectorConfig = _defaultConfig) { + val client = PaymentClient(config) + val request = buildVoidRequest("probe_connector_txn_001") + val response = client.void(request) + if (response.status.name == "FAILED") + throw RuntimeException("Void failed: ${response.error.unifiedDetails.message}") + println("Done: ${response.status.name}") +} + + +fun main(args: Array) { + val txnId = "order_001" + val flow = args.firstOrNull() ?: "processCheckoutAutocapture" + when (flow) { + "processCheckoutAutocapture" -> processCheckoutAutocapture(txnId) + "processCheckoutCard" -> processCheckoutCard(txnId) + "processRefund" -> processRefund(txnId) + "processVoidPayment" -> processVoidPayment(txnId) + "processGetPayment" -> processGetPayment(txnId) + "authorize" -> authorize(txnId) + "capture" -> capture(txnId) + "get" -> get(txnId) + "proxyAuthorize" -> proxyAuthorize(txnId) + "refund" -> refund(txnId) + "void" -> void(txnId) + else -> System.err.println("Unknown flow: $flow. Available: processCheckoutAutocapture, processCheckoutCard, processRefund, processVoidPayment, processGetPayment, authorize, capture, get, proxyAuthorize, refund, void") + } +} diff --git a/examples/ilixium/ilixium.py b/examples/ilixium/ilixium.py new file mode 100644 index 0000000000..e8536e3660 --- /dev/null +++ b/examples/ilixium/ilixium.py @@ -0,0 +1,297 @@ +# This file is auto-generated. Do not edit manually. +# Replace YOUR_API_KEY and placeholder values with real data. +# Regenerate: python3 scripts/generate-connector-docs.py ilixium +# +# Ilixium — all integration scenarios and flows in one file. +# Run a scenario: python3 ilixium.py checkout_card + +import asyncio +import sys +from payments import PaymentClient +from payments.generated import sdk_config_pb2, payment_pb2, payment_methods_pb2 + +SUPPORTED_FLOWS = ["authorize", "capture", "get", "proxy_authorize", "refund", "void"] + +_default_config = sdk_config_pb2.ConnectorConfig( + options=sdk_config_pb2.SdkOptions(environment=sdk_config_pb2.Environment.SANDBOX), + connector_config=payment_pb2.ConnectorSpecificConfig( + ilixium=payment_pb2.IlixiumConfig( + api_key=payment_methods_pb2.SecretString(value="YOUR_API_KEY"), + key1=payment_methods_pb2.SecretString(value="YOUR_KEY1"), + api_secret=payment_methods_pb2.SecretString(value="YOUR_API_SECRET"), + base_url="YOUR_BASE_URL", + ), + ), +) + + + + +def _build_authorize_request(capture_method: str): + return payment_pb2.PaymentServiceAuthorizeRequest( + merchant_transaction_id="probe_txn_001", # Identification. + amount=payment_pb2.Money( # The amount for the payment. + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + payment_method=payment_methods_pb2.PaymentMethod( # Payment method to be used. + card=payment_methods_pb2.CardDetails( + card_number=payment_methods_pb2.CardNumberType(value="4111111111111111"), # Card Identification. + card_exp_month=payment_methods_pb2.SecretString(value="03"), + card_exp_year=payment_methods_pb2.SecretString(value="2030"), + card_cvc=payment_methods_pb2.SecretString(value="737"), + card_holder_name=payment_methods_pb2.SecretString(value="John Doe"), # Cardholder Information. + ), + ), + capture_method=payment_pb2.CaptureMethod.Value(capture_method), # Method for capturing the payment. + customer=payment_pb2.Customer( # Customer Information. + email=payment_methods_pb2.SecretString(value="test@example.com"), # Customer's email address. + ), + address=payment_pb2.PaymentAddress( # Address Information. + billing_address=payment_pb2.Address( + first_name=payment_methods_pb2.SecretString(value="John"), # Personal Information. + last_name=payment_methods_pb2.SecretString(value="Doe"), + country_alpha2_code=payment_methods_pb2.CountryAlpha2.Value("US"), + ), + ), + auth_type=payment_pb2.AuthenticationType.Value("NO_THREE_DS"), # Authentication Details. + return_url="https://example.com/return", # URLs for Redirection and Webhooks. + ) + +def _build_capture_request(connector_transaction_id: str): + return payment_pb2.PaymentServiceCaptureRequest( + merchant_capture_id="probe_capture_001", # Identification. + connector_transaction_id=connector_transaction_id, + amount_to_capture=payment_pb2.Money( # Capture Details. + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + ) + +def _build_get_request(connector_transaction_id: str): + return payment_pb2.PaymentServiceGetRequest( + merchant_transaction_id="probe_merchant_txn_001", # Identification. + connector_transaction_id=connector_transaction_id, + amount=payment_pb2.Money( # Amount Information. + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + ) + +def _build_proxy_authorize_request(): + return payment_pb2.PaymentServiceProxyAuthorizeRequest( + merchant_transaction_id="probe_proxy_txn_001", + amount=payment_pb2.Money( + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + card_proxy=payment_methods_pb2.ProxyCardDetails( # Card proxy for vault-aliased payments (VGS, Basis Theory, Spreedly). Real card values are substituted by the proxy before reaching the connector. + card_number=payment_methods_pb2.SecretString(value="4111111111111111"), # Card Identification. + card_exp_month=payment_methods_pb2.SecretString(value="03"), + card_exp_year=payment_methods_pb2.SecretString(value="2030"), + card_cvc=payment_methods_pb2.SecretString(value="123"), + card_holder_name=payment_methods_pb2.SecretString(value="John Doe"), # Cardholder Information. + card_network=payment_methods_pb2.CardNetwork.Value("VISA"), + ), + customer=payment_pb2.Customer( + email=payment_methods_pb2.SecretString(value="test@example.com"), # Customer's email address. + ), + address=payment_pb2.PaymentAddress( + billing_address=payment_pb2.Address( + first_name=payment_methods_pb2.SecretString(value="John"), # Personal Information. + last_name=payment_methods_pb2.SecretString(value="Doe"), + country_alpha2_code=payment_methods_pb2.CountryAlpha2.Value("US"), + ), + ), + capture_method=payment_pb2.CaptureMethod.Value("AUTOMATIC"), + auth_type=payment_pb2.AuthenticationType.Value("NO_THREE_DS"), + return_url="https://example.com/return", + ) + +def _build_refund_request(connector_transaction_id: str): + return payment_pb2.PaymentServiceRefundRequest( + merchant_refund_id="probe_refund_001", # Identification. + connector_transaction_id=connector_transaction_id, + payment_amount=1000, # Amount Information. + refund_amount=payment_pb2.Money( + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + reason="customer_request", # Reason for the refund. + connector_order_id="connector_order_id", # Connector-side identifier for the original payment that this refund targets. + ) + +def _build_void_request(connector_transaction_id: str): + return payment_pb2.PaymentServiceVoidRequest( + merchant_void_id="probe_void_001", # Identification. + connector_transaction_id=connector_transaction_id, + amount=payment_pb2.Money( # Amount Information. + minor_amount=1000, # Amount in minor units (e.g., 1000 = $10.00). + currency=payment_pb2.Currency.Value("USD"), # ISO 4217 currency code (e.g., "USD", "EUR"). + ), + ) +async def process_checkout_autocapture(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """One-step Payment (Authorize + Capture) + + Simple payment that authorizes and captures in one call. Use for immediate charges. + """ + payment_client = PaymentClient(config) + + # Step 1: Authorize — reserve funds on the payment method + authorize_response = await payment_client.authorize(_build_authorize_request("AUTOMATIC")) + + if authorize_response.status == "FAILED": + raise RuntimeError(f"Payment failed: {authorize_response.error}") + if authorize_response.status == "PENDING": + # Awaiting async confirmation — handle via webhook + return {"status": "pending", "transaction_id": authorize_response.connector_transaction_id} + + return {"status": getattr(authorize_response, "status", ""), "transaction_id": getattr(authorize_response, "connector_transaction_id", ""), "error": getattr(authorize_response, "error", None)} + + +async def process_checkout_card(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Card Payment (Authorize + Capture) + + Two-step card payment. First authorize, then capture. Use when you need to verify funds before finalizing. + """ + payment_client = PaymentClient(config) + + # Step 1: Authorize — reserve funds on the payment method + authorize_response = await payment_client.authorize(_build_authorize_request("MANUAL")) + + if authorize_response.status == "FAILED": + raise RuntimeError(f"Payment failed: {authorize_response.error}") + if authorize_response.status == "PENDING": + # Awaiting async confirmation — handle via webhook + return {"status": "pending", "transaction_id": authorize_response.connector_transaction_id} + + # Step 2: Capture — settle the reserved funds + capture_response = await payment_client.capture(_build_capture_request(authorize_response.connector_transaction_id)) + + if capture_response.status == "FAILED": + raise RuntimeError(f"Capture failed: {capture_response.error}") + + return {"status": getattr(capture_response, "status", ""), "transaction_id": getattr(authorize_response, "connector_transaction_id", ""), "error": getattr(capture_response, "error", None)} + + +async def process_refund(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Refund + + Return funds to the customer for a completed payment. + """ + payment_client = PaymentClient(config) + + # Step 1: Authorize — reserve funds on the payment method + authorize_response = await payment_client.authorize(_build_authorize_request("AUTOMATIC")) + + if authorize_response.status == "FAILED": + raise RuntimeError(f"Payment failed: {authorize_response.error}") + if authorize_response.status == "PENDING": + # Awaiting async confirmation — handle via webhook + return {"status": "pending", "transaction_id": authorize_response.connector_transaction_id} + + # Step 2: Refund — return funds to the customer + refund_response = await payment_client.refund(_build_refund_request(authorize_response.connector_transaction_id)) + + if refund_response.status == "FAILED": + raise RuntimeError(f"Refund failed: {refund_response.error}") + + return {"status": getattr(refund_response, "status", ""), "error": getattr(refund_response, "error", None)} + + +async def process_void_payment(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Void Payment + + Cancel an authorized but not-yet-captured payment. + """ + payment_client = PaymentClient(config) + + # Step 1: Authorize — reserve funds on the payment method + authorize_response = await payment_client.authorize(_build_authorize_request("MANUAL")) + + if authorize_response.status == "FAILED": + raise RuntimeError(f"Payment failed: {authorize_response.error}") + if authorize_response.status == "PENDING": + # Awaiting async confirmation — handle via webhook + return {"status": "pending", "transaction_id": authorize_response.connector_transaction_id} + + # Step 2: Void — release reserved funds (cancel authorization) + void_response = await payment_client.void(_build_void_request(authorize_response.connector_transaction_id)) + + return {"status": getattr(void_response, "status", ""), "transaction_id": getattr(authorize_response, "connector_transaction_id", ""), "error": getattr(void_response, "error", None)} + + +async def process_get_payment(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Get Payment Status + + Retrieve current payment status from the connector. + """ + payment_client = PaymentClient(config) + + # Step 1: Authorize — reserve funds on the payment method + authorize_response = await payment_client.authorize(_build_authorize_request("MANUAL")) + + if authorize_response.status == "FAILED": + raise RuntimeError(f"Payment failed: {authorize_response.error}") + if authorize_response.status == "PENDING": + # Awaiting async confirmation — handle via webhook + return {"status": "pending", "transaction_id": authorize_response.connector_transaction_id} + + # Step 2: Get — retrieve current payment status from the connector + get_response = await payment_client.get(_build_get_request(authorize_response.connector_transaction_id)) + + return {"status": getattr(get_response, "status", ""), "transaction_id": getattr(get_response, "connector_transaction_id", ""), "error": getattr(get_response, "error", None)} + + +async def process_authorize(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Flow: PaymentService.Authorize (Card)""" + payment_client = PaymentClient(config) + + authorize_response = await payment_client.authorize(_build_authorize_request("AUTOMATIC")) + + return {"status": authorize_response.status, "transaction_id": authorize_response.connector_transaction_id} + + +async def process_capture(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Flow: PaymentService.Capture""" + payment_client = PaymentClient(config) + + capture_response = await payment_client.capture(_build_capture_request("probe_connector_txn_001")) + + return {"status": capture_response.status} + + +async def process_get(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Flow: PaymentService.Get""" + payment_client = PaymentClient(config) + + get_response = await payment_client.get(_build_get_request("probe_connector_txn_001")) + + return {"status": get_response.status} + + +async def process_proxy_authorize(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Flow: PaymentService.ProxyAuthorize""" + payment_client = PaymentClient(config) + + proxy_response = await payment_client.proxy_authorize(_build_proxy_authorize_request()) + + return {"status": proxy_response.status} + + +async def process_void(merchant_transaction_id: str, config: sdk_config_pb2.ConnectorConfig = _default_config): + """Flow: PaymentService.Void""" + payment_client = PaymentClient(config) + + void_response = await payment_client.void(_build_void_request("probe_connector_txn_001")) + + return {"status": void_response.status} + +if __name__ == "__main__": + scenario = sys.argv[1] if len(sys.argv) > 1 else "checkout_autocapture" + fn = globals().get(f"process_{scenario}") + if not fn: + available = [k[8:] for k in globals() if k.startswith("process_")] + print(f"Unknown scenario: {scenario}. Available: {available}", file=sys.stderr) + sys.exit(1) + asyncio.run(fn("order_001")) diff --git a/examples/ilixium/ilixium.rs b/examples/ilixium/ilixium.rs new file mode 100644 index 0000000000..37c4f43878 --- /dev/null +++ b/examples/ilixium/ilixium.rs @@ -0,0 +1,485 @@ +// This file is auto-generated. Do not edit manually. +// Replace YOUR_API_KEY and placeholder values with real data. +// Regenerate: python3 scripts/generate-connector-docs.py ilixium +// +// Ilixium — all scenarios and flows in one file. +// Run a scenario: cargo run --example ilixium -- process_checkout_card +use cards::CardNumber; +use grpc_api_types::payments::connector_specific_config; +use grpc_api_types::payments::payment_method; +use grpc_api_types::payments::*; +use hyperswitch_masking::Secret; +use hyperswitch_payments_client::ConnectorClient; +use std::collections::HashMap; +use std::str::FromStr; + +#[allow(dead_code)] +pub const SUPPORTED_FLOWS: &[&str] = &[ + "authorize", + "capture", + "get", + "proxy_authorize", + "refund", + "void", +]; + +#[allow(dead_code)] +fn build_client() -> ConnectorClient { + // Configure the connector with authentication + let config = ConnectorConfig { + connector_config: Some(ConnectorSpecificConfig { + config: Some(connector_specific_config::Config::Ilixium(IlixiumConfig { + api_key: Some(hyperswitch_masking::Secret::new("YOUR_API_KEY".to_string())), // Authentication credential + key1: Some(hyperswitch_masking::Secret::new("YOUR_KEY1".to_string())), // Authentication credential + api_secret: Some(hyperswitch_masking::Secret::new( + "YOUR_API_SECRET".to_string(), + )), // Authentication credential + base_url: Some("https://sandbox.example.com".to_string()), // Base URL for API calls + ..Default::default() + })), + }), + options: Some(SdkOptions { + environment: Environment::Sandbox.into(), + }), + }; + ConnectorClient::new(config, None).unwrap() +} + +pub fn build_authorize_request(capture_method: &str) -> PaymentServiceAuthorizeRequest { + PaymentServiceAuthorizeRequest { + merchant_transaction_id: Some("probe_txn_001".to_string()), // Identification. + amount: Some(Money { + // The amount for the payment. + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + payment_method: Some(PaymentMethod { + // Payment method to be used. + payment_method: Some(payment_method::PaymentMethod::Card(CardDetails { + card_number: Some(CardNumber::from_str("4111111111111111").unwrap()), // Card Identification. + card_exp_month: Some(Secret::new("03".to_string())), + card_exp_year: Some(Secret::new("2030".to_string())), + card_cvc: Some(Secret::new("737".to_string())), + card_holder_name: Some(Secret::new("John Doe".to_string())), // Cardholder Information. + ..Default::default() + })), + ..Default::default() + }), + capture_method: Some( + CaptureMethod::from_str_name(capture_method) + .unwrap_or_default() + .into(), + ), // Method for capturing the payment. + customer: Some(Customer { + // Customer Information. + email: Some(Secret::new("test@example.com".to_string())), // Customer's email address. + ..Default::default() + }), + address: Some(PaymentAddress { + // Address Information. + billing_address: Some(Address { + first_name: Some(Secret::new("John".to_string())), // Personal Information. + last_name: Some(Secret::new("Doe".to_string())), + country_alpha2_code: Some(CountryAlpha2::Us.into()), + ..Default::default() + }), + ..Default::default() + }), + auth_type: AuthenticationType::NoThreeDs.into(), // Authentication Details. + return_url: Some("https://example.com/return".to_string()), // URLs for Redirection and Webhooks. + ..Default::default() + } +} + +pub fn build_capture_request(connector_transaction_id: &str) -> PaymentServiceCaptureRequest { + PaymentServiceCaptureRequest { + merchant_capture_id: Some("probe_capture_001".to_string()), // Identification. + connector_transaction_id: connector_transaction_id.to_string(), + amount_to_capture: Some(Money { + // Capture Details. + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + ..Default::default() + } +} + +pub fn build_get_request(connector_transaction_id: &str) -> PaymentServiceGetRequest { + PaymentServiceGetRequest { + merchant_transaction_id: Some("probe_merchant_txn_001".to_string()), // Identification. + connector_transaction_id: connector_transaction_id.to_string(), + amount: Some(Money { + // Amount Information. + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + ..Default::default() + } +} + +pub fn build_proxy_authorize_request() -> PaymentServiceProxyAuthorizeRequest { + PaymentServiceProxyAuthorizeRequest { + merchant_transaction_id: Some("probe_proxy_txn_001".to_string()), + amount: Some(Money { + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + card_proxy: Some(ProxyCardDetails { + // Card proxy for vault-aliased payments (VGS, Basis Theory, Spreedly). Real card values are substituted by the proxy before reaching the connector. + card_number: Some(Secret::new("4111111111111111".to_string())), // Card Identification. + card_exp_month: Some(Secret::new("03".to_string())), + card_exp_year: Some(Secret::new("2030".to_string())), + card_cvc: Some(Secret::new("123".to_string())), + card_holder_name: Some(Secret::new("John Doe".to_string())), // Cardholder Information. + card_network: Some(CardNetwork::Visa.into()), + ..Default::default() + }), + customer: Some(Customer { + email: Some(Secret::new("test@example.com".to_string())), // Customer's email address. + ..Default::default() + }), + address: Some(PaymentAddress { + billing_address: Some(Address { + first_name: Some(Secret::new("John".to_string())), // Personal Information. + last_name: Some(Secret::new("Doe".to_string())), + country_alpha2_code: Some(CountryAlpha2::Us.into()), + ..Default::default() + }), + ..Default::default() + }), + capture_method: Some(CaptureMethod::Automatic.into()), + auth_type: AuthenticationType::NoThreeDs.into(), + return_url: Some("https://example.com/return".to_string()), + ..Default::default() + } +} + +pub fn build_refund_request(connector_transaction_id: &str) -> PaymentServiceRefundRequest { + PaymentServiceRefundRequest { + merchant_refund_id: Some("probe_refund_001".to_string()), // Identification. + connector_transaction_id: connector_transaction_id.to_string(), + payment_amount: 1000, // Amount Information. + refund_amount: Some(Money { + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + reason: Some("customer_request".to_string()), // Reason for the refund. + connector_order_id: Some("connector_order_id".to_string()), // Connector-side identifier for the original payment that this refund targets. + ..Default::default() + } +} + +pub fn build_void_request(connector_transaction_id: &str) -> PaymentServiceVoidRequest { + PaymentServiceVoidRequest { + merchant_void_id: Some("probe_void_001".to_string()), // Identification. + connector_transaction_id: connector_transaction_id.to_string(), + amount: Some(Money { + // Amount Information. + minor_amount: 1000, // Amount in minor units (e.g., 1000 = $10.00). + currency: Currency::Usd.into(), // ISO 4217 currency code (e.g., "USD", "EUR"). + }), + ..Default::default() + } +} + +// Scenario: One-step Payment (Authorize + Capture) +// Simple payment that authorizes and captures in one call. Use for immediate charges. +#[allow(dead_code)] +pub async fn process_checkout_autocapture( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + // Step 1: Authorize — reserve funds on the payment method + let authorize_response = client + .authorize(build_authorize_request("AUTOMATIC"), &HashMap::new(), None) + .await?; + + match authorize_response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + return Err(format!("Payment failed: {:?}", authorize_response.error).into()) + } + PaymentStatus::Pending => return Ok("pending — awaiting webhook".to_string()), + _ => {} + } + + Ok(format!( + "Payment: {:?} — {}", + authorize_response.status(), + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or("") + )) +} + +// Scenario: Card Payment (Authorize + Capture) +// Two-step card payment. First authorize, then capture. Use when you need to verify funds before finalizing. +#[allow(dead_code)] +pub async fn process_checkout_card( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + // Step 1: Authorize — reserve funds on the payment method + let authorize_response = client + .authorize(build_authorize_request("MANUAL"), &HashMap::new(), None) + .await?; + + match authorize_response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + return Err(format!("Payment failed: {:?}", authorize_response.error).into()) + } + PaymentStatus::Pending => return Ok("pending — awaiting webhook".to_string()), + _ => {} + } + + // Step 2: Capture — settle the reserved funds + let capture_response = client + .capture( + build_capture_request( + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or(""), + ), + &HashMap::new(), + None, + ) + .await?; + + if capture_response.status() == PaymentStatus::Failure { + return Err(format!("Capture failed: {:?}", capture_response.error).into()); + } + + Ok(format!( + "Payment completed: {}", + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or("") + )) +} + +// Scenario: Refund +// Return funds to the customer for a completed payment. +#[allow(dead_code)] +pub async fn process_refund( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + // Step 1: Authorize — reserve funds on the payment method + let authorize_response = client + .authorize(build_authorize_request("AUTOMATIC"), &HashMap::new(), None) + .await?; + + match authorize_response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + return Err(format!("Payment failed: {:?}", authorize_response.error).into()) + } + PaymentStatus::Pending => return Ok("pending — awaiting webhook".to_string()), + _ => {} + } + + // Step 2: Refund — return funds to the customer + let refund_response = client + .refund( + build_refund_request( + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or(""), + ), + &HashMap::new(), + None, + ) + .await?; + + if refund_response.status() == RefundStatus::RefundFailure { + return Err(format!("Refund failed: {:?}", refund_response.error).into()); + } + + Ok(format!("Refunded: {:?}", refund_response.status())) +} + +// Scenario: Void Payment +// Cancel an authorized but not-yet-captured payment. +#[allow(dead_code)] +pub async fn process_void_payment( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + // Step 1: Authorize — reserve funds on the payment method + let authorize_response = client + .authorize(build_authorize_request("MANUAL"), &HashMap::new(), None) + .await?; + + match authorize_response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + return Err(format!("Payment failed: {:?}", authorize_response.error).into()) + } + PaymentStatus::Pending => return Ok("pending — awaiting webhook".to_string()), + _ => {} + } + + // Step 2: Void — release reserved funds (cancel authorization) + let void_response = client + .void( + build_void_request( + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or(""), + ), + &HashMap::new(), + None, + ) + .await?; + + Ok(format!("Voided: {:?}", void_response.status())) +} + +// Scenario: Get Payment Status +// Retrieve current payment status from the connector. +#[allow(dead_code)] +pub async fn process_get_payment( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + // Step 1: Authorize — reserve funds on the payment method + let authorize_response = client + .authorize(build_authorize_request("MANUAL"), &HashMap::new(), None) + .await?; + + match authorize_response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + return Err(format!("Payment failed: {:?}", authorize_response.error).into()) + } + PaymentStatus::Pending => return Ok("pending — awaiting webhook".to_string()), + _ => {} + } + + // Step 2: Get — retrieve current payment status from the connector + let get_response = client + .get( + build_get_request( + authorize_response + .connector_transaction_id + .as_deref() + .unwrap_or(""), + ), + &HashMap::new(), + None, + ) + .await?; + + Ok(format!("Status: {:?}", get_response.status())) +} + +// Flow: PaymentService.Authorize (Card) +#[allow(dead_code)] +pub async fn process_authorize( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + let response = client + .authorize(build_authorize_request("AUTOMATIC"), &HashMap::new(), None) + .await?; + match response.status() { + PaymentStatus::Failure | PaymentStatus::AuthorizationFailed => { + Err(format!("Authorize failed: {:?}", response.error).into()) + } + PaymentStatus::Pending => Ok("pending — await webhook".to_string()), + _ => Ok(format!( + "Authorized: {}", + response.connector_transaction_id.as_deref().unwrap_or("") + )), + } +} + +// Flow: PaymentService.Capture +#[allow(dead_code)] +pub async fn process_capture( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + let response = client + .capture( + build_capture_request("probe_connector_txn_001"), + &HashMap::new(), + None, + ) + .await?; + Ok(format!("status: {:?}", response.status())) +} + +// Flow: PaymentService.Get +#[allow(dead_code)] +pub async fn process_get( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + let response = client + .get( + build_get_request("probe_connector_txn_001"), + &HashMap::new(), + None, + ) + .await?; + Ok(format!("status: {:?}", response.status())) +} + +// Flow: PaymentService.ProxyAuthorize +#[allow(dead_code)] +pub async fn process_proxy_authorize( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + let response = client + .proxy_authorize(build_proxy_authorize_request(), &HashMap::new(), None) + .await?; + Ok(format!("status: {:?}", response.status())) +} + +// Flow: PaymentService.Void +#[allow(dead_code)] +pub async fn process_void( + client: &ConnectorClient, + _merchant_transaction_id: &str, +) -> Result> { + let response = client + .void( + build_void_request("probe_connector_txn_001"), + &HashMap::new(), + None, + ) + .await?; + Ok(format!("status: {:?}", response.status())) +} + +#[allow(dead_code)] +#[tokio::main] +async fn main() { + let client = build_client(); + let flow = std::env::args() + .nth(1) + .unwrap_or_else(|| "process_checkout_autocapture".to_string()); + let result: Result> = match flow.as_str() { + "process_checkout_autocapture" => process_checkout_autocapture(&client, "order_001").await, + "process_checkout_card" => process_checkout_card(&client, "order_001").await, + "process_refund" => process_refund(&client, "order_001").await, + "process_void_payment" => process_void_payment(&client, "order_001").await, + "process_get_payment" => process_get_payment(&client, "order_001").await, + "process_authorize" => process_authorize(&client, "txn_001").await, + "process_capture" => process_capture(&client, "txn_001").await, + "process_get" => process_get(&client, "txn_001").await, + "process_proxy_authorize" => process_proxy_authorize(&client, "txn_001").await, + "process_void" => process_void(&client, "txn_001").await, + _ => { + eprintln!("Unknown flow: {}. Available: process_checkout_autocapture, process_checkout_card, process_refund, process_void_payment, process_get_payment, process_authorize, process_capture, process_get, process_proxy_authorize, process_void", flow); + return; + } + }; + match result { + Ok(msg) => println!("✓ {msg}"), + Err(e) => eprintln!("✗ {e}"), + } +} diff --git a/examples/ilixium/ilixium.ts b/examples/ilixium/ilixium.ts new file mode 100644 index 0000000000..a16dc50dce --- /dev/null +++ b/examples/ilixium/ilixium.ts @@ -0,0 +1,327 @@ +// This file is auto-generated. Do not edit manually. +// Replace YOUR_API_KEY and placeholder values with real data. +// Regenerate: python3 scripts/generate-connector-docs.py ilixium +// +// Ilixium — all integration scenarios and flows in one file. +// Run a scenario: npx tsx ilixium.ts checkout_autocapture + +import { PaymentClient, types } from 'hyperswitch-prism'; +const { Environment, AuthenticationType, CaptureMethod, CardNetwork, CountryAlpha2, Currency } = types; +export const SUPPORTED_FLOWS = ["authorize", "capture", "get", "proxy_authorize", "refund", "void"]; + +const _defaultConfig: types.IConnectorConfig = { + options: { + environment: Environment.SANDBOX, + }, + connectorConfig: { + ilixium: { + apiKey: { value: 'YOUR_API_KEY' }, + key1: { value: 'YOUR_KEY1' }, + apiSecret: { value: 'YOUR_API_SECRET' }, + baseUrl: 'YOUR_BASE_URL', + } + }, +}; + + +function _buildAuthorizeRequest(captureMethod: types.CaptureMethod): types.IPaymentServiceAuthorizeRequest { + return { + "merchantTransactionId": "probe_txn_001", // Identification. + "amount": { // The amount for the payment. + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + }, + "paymentMethod": { // Payment method to be used. + "card": { // Generic card payment. + "cardNumber": {"value": "4111111111111111"}, // Card Identification. + "cardExpMonth": {"value": "03"}, + "cardExpYear": {"value": "2030"}, + "cardCvc": {"value": "737"}, + "cardHolderName": {"value": "John Doe"} // Cardholder Information. + } + }, + "captureMethod": captureMethod, // Method for capturing the payment. + "customer": { // Customer Information. + "email": {"value": "test@example.com"} // Customer's email address. + }, + "address": { // Address Information. + "billingAddress": { + "firstName": {"value": "John"}, // Personal Information. + "lastName": {"value": "Doe"}, + "countryAlpha2Code": CountryAlpha2.US + } + }, + "authType": AuthenticationType.NO_THREE_DS, // Authentication Details. + "returnUrl": "https://example.com/return" // URLs for Redirection and Webhooks. + }; +} + +function _buildCaptureRequest(connectorTransactionId: string): types.IPaymentServiceCaptureRequest { + return { + "merchantCaptureId": "probe_capture_001", // Identification. + "connectorTransactionId": connectorTransactionId, + "amountToCapture": { // Capture Details. + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }; +} + +function _buildGetRequest(connectorTransactionId: string): types.IPaymentServiceGetRequest { + return { + "merchantTransactionId": "probe_merchant_txn_001", // Identification. + "connectorTransactionId": connectorTransactionId, + "amount": { // Amount Information. + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }; +} + +function _buildProxyAuthorizeRequest(): types.IPaymentServiceProxyAuthorizeRequest { + return { + "merchantTransactionId": "probe_proxy_txn_001", + "amount": { + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + }, + "cardProxy": { // Card proxy for vault-aliased payments (VGS, Basis Theory, Spreedly). Real card values are substituted by the proxy before reaching the connector. + "cardNumber": {"value": "4111111111111111"}, // Card Identification. + "cardExpMonth": {"value": "03"}, + "cardExpYear": {"value": "2030"}, + "cardCvc": {"value": "123"}, + "cardHolderName": {"value": "John Doe"}, // Cardholder Information. + "cardNetwork": CardNetwork.VISA + }, + "customer": { + "email": {"value": "test@example.com"} // Customer's email address. + }, + "address": { + "billingAddress": { + "firstName": {"value": "John"}, // Personal Information. + "lastName": {"value": "Doe"}, + "countryAlpha2Code": CountryAlpha2.US + } + }, + "captureMethod": CaptureMethod.AUTOMATIC, + "authType": AuthenticationType.NO_THREE_DS, + "returnUrl": "https://example.com/return" + }; +} + +function _buildRefundRequest(connectorTransactionId: string): types.IPaymentServiceRefundRequest { + return { + "merchantRefundId": "probe_refund_001", // Identification. + "connectorTransactionId": connectorTransactionId, + "paymentAmount": 1000, // Amount Information. + "refundAmount": { + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + }, + "reason": "customer_request", // Reason for the refund. + "connectorOrderId": "connector_order_id" // Connector-side identifier for the original payment that this refund targets. + }; +} + +function _buildVoidRequest(connectorTransactionId: string): types.IPaymentServiceVoidRequest { + return { + "merchantVoidId": "probe_void_001", // Identification. + "connectorTransactionId": connectorTransactionId, + "amount": { // Amount Information. + "minorAmount": 1000, // Amount in minor units (e.g., 1000 = $10.00). + "currency": Currency.USD // ISO 4217 currency code (e.g., "USD", "EUR"). + } + }; +} + + +// ANCHOR: scenario_functions +// One-step Payment (Authorize + Capture) +// Simple payment that authorizes and captures in one call. Use for immediate charges. +async function processCheckoutAutocapture(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + // Step 1: Authorize — reserve funds on the payment method + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.AUTOMATIC)); + + if (authorizeResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Payment failed: ${JSON.stringify(authorizeResponse.error)}`); + } + if (authorizeResponse.status === types.PaymentStatus.PENDING) { + // Awaiting async confirmation — handle via webhook + return { status: 'pending', connectorTransactionId: authorizeResponse.connectorTransactionId }; + } + + return { status: authorizeResponse.status, transactionId: authorizeResponse.connectorTransactionId!, error: authorizeResponse.error } as any; +} + +// Card Payment (Authorize + Capture) +// Two-step card payment. First authorize, then capture. Use when you need to verify funds before finalizing. +async function processCheckoutCard(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + // Step 1: Authorize — reserve funds on the payment method + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.MANUAL)); + + if (authorizeResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Payment failed: ${JSON.stringify(authorizeResponse.error)}`); + } + if (authorizeResponse.status === types.PaymentStatus.PENDING) { + // Awaiting async confirmation — handle via webhook + return { status: 'pending', connectorTransactionId: authorizeResponse.connectorTransactionId }; + } + + // Step 2: Capture — settle the reserved funds + const captureResponse = await paymentClient.capture(_buildCaptureRequest(authorizeResponse.connectorTransactionId!)); + + if (captureResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Capture failed: ${JSON.stringify(captureResponse.error)}`); + } + + return { status: captureResponse.status, transactionId: authorizeResponse.connectorTransactionId!, error: authorizeResponse.error } as any; +} + +// Refund +// Return funds to the customer for a completed payment. +async function processRefund(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + // Step 1: Authorize — reserve funds on the payment method + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.AUTOMATIC)); + + if (authorizeResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Payment failed: ${JSON.stringify(authorizeResponse.error)}`); + } + if (authorizeResponse.status === types.PaymentStatus.PENDING) { + // Awaiting async confirmation — handle via webhook + return { status: 'pending', connectorTransactionId: authorizeResponse.connectorTransactionId }; + } + + // Step 2: Refund — return funds to the customer + const refundResponse = await paymentClient.refund(_buildRefundRequest(authorizeResponse.connectorTransactionId!)); + + if (refundResponse.status === types.RefundStatus.REFUND_FAILURE) { + throw new Error(`Refund failed: ${JSON.stringify(refundResponse.error)}`); + } + + return { status: refundResponse.status, error: refundResponse.error } as any; +} + +// Void Payment +// Cancel an authorized but not-yet-captured payment. +async function processVoidPayment(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + // Step 1: Authorize — reserve funds on the payment method + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.MANUAL)); + + if (authorizeResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Payment failed: ${JSON.stringify(authorizeResponse.error)}`); + } + if (authorizeResponse.status === types.PaymentStatus.PENDING) { + // Awaiting async confirmation — handle via webhook + return { status: 'pending', connectorTransactionId: authorizeResponse.connectorTransactionId }; + } + + // Step 2: Void — release reserved funds (cancel authorization) + const voidResponse = await paymentClient.void(_buildVoidRequest(authorizeResponse.connectorTransactionId!)); + + return { status: voidResponse.status, transactionId: authorizeResponse.connectorTransactionId!, error: voidResponse.error } as any; +} + +// Get Payment Status +// Retrieve current payment status from the connector. +async function processGetPayment(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + // Step 1: Authorize — reserve funds on the payment method + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.MANUAL)); + + if (authorizeResponse.status === types.PaymentStatus.FAILURE) { + throw new Error(`Payment failed: ${JSON.stringify(authorizeResponse.error)}`); + } + if (authorizeResponse.status === types.PaymentStatus.PENDING) { + // Awaiting async confirmation — handle via webhook + return { status: 'pending', connectorTransactionId: authorizeResponse.connectorTransactionId }; + } + + // Step 2: Get — retrieve current payment status from the connector + const getResponse = await paymentClient.get(_buildGetRequest(authorizeResponse.connectorTransactionId!)); + + return { status: getResponse.status, transactionId: getResponse.connectorTransactionId!, error: getResponse.error } as any; +} + +// Flow: PaymentService.Authorize (Card) +async function authorize(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const authorizeResponse = await paymentClient.authorize(_buildAuthorizeRequest(CaptureMethod.AUTOMATIC)); + + return authorizeResponse; +} + +// Flow: PaymentService.Capture +async function capture(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const captureResponse = await paymentClient.capture(_buildCaptureRequest('probe_connector_txn_001')); + + return captureResponse; +} + +// Flow: PaymentService.Get +async function get(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const getResponse = await paymentClient.get(_buildGetRequest('probe_connector_txn_001')); + + return getResponse; +} + +// Flow: PaymentService.ProxyAuthorize +async function proxyAuthorize(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const proxyResponse = await paymentClient.proxyAuthorize(_buildProxyAuthorizeRequest()); + + return proxyResponse; +} + +// Flow: PaymentService.Refund +async function refund(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const refundResponse = await paymentClient.refund(_buildRefundRequest('probe_connector_txn_001')); + + return refundResponse; +} + +// Flow: PaymentService.Void +async function voidPayment(merchantTransactionId: string, config: types.IConnectorConfig = _defaultConfig) { + const paymentClient = new PaymentClient(config); + + const voidResponse = await paymentClient.void(_buildVoidRequest('probe_connector_txn_001')); + + return voidResponse; +} + + +// Export all process* functions for the smoke test +export { + processCheckoutAutocapture, processCheckoutCard, processRefund, processVoidPayment, processGetPayment, authorize, capture, get, proxyAuthorize, refund, voidPayment, _buildAuthorizeRequest, _buildCaptureRequest, _buildGetRequest, _buildProxyAuthorizeRequest, _buildRefundRequest, _buildVoidRequest +}; + +// CLI runner +if (require.main === module) { + const scenario = process.argv[2] || 'checkout_autocapture'; + const key = 'process' + scenario.replace(/_([a-z])/g, (_, l) => l.toUpperCase()).replace(/^(.)/, c => c.toUpperCase()); + const fn = (globalThis as any)[key] || (exports as any)[key]; + if (!fn) { + const available = Object.keys(exports).map(k => + k.replace(/^process/, '').replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '') + ); + console.error(`Unknown scenario: ${scenario}. Available: ${available.join(', ')}`); + process.exit(1); + } + fn('order_001').catch(console.error); +} diff --git a/sdk/javascript/src/payments/_generated_grpc_client.ts b/sdk/javascript/src/payments/_generated_grpc_client.ts index c80079b807..74d67bae5c 100644 --- a/sdk/javascript/src/payments/_generated_grpc_client.ts +++ b/sdk/javascript/src/payments/_generated_grpc_client.ts @@ -324,6 +324,7 @@ const _SECRET_STRING_FIELDS: Record = { GrabpayConfig: ["partnerId", "partnerSecret", "clientId", "clientSecret", "merchantId"], TesouroConfig: ["apiKey", "key1", "apiSecret"], BoostConfig: ["clientId", "merchantSecret"], + IlixiumConfig: ["apiKey", "key1", "apiSecret"], PaymentServiceTokenAuthorizeRequest: ["connectorToken", "metadata", "connectorFeatureData"], PaymentServiceTokenSetupRecurringRequest: ["connectorToken", "metadata", "connectorFeatureData"], PaymentServiceProxyAuthorizeRequest: ["metadata", "connectorFeatureData"], @@ -515,7 +516,7 @@ const _MSG_FIELD_TYPES: Record> = { CashtocodeConfig: { "authKeyMap": "AuthKeyMapEntry" }, AuthKeyMapEntry: { "value": "PayloadCurrencyAuthData" }, PayloadConfig: { "authKeyMap": "AuthKeyMapEntry" }, - ConnectorSpecificConfig: { "adyen": "AdyenConfig", "airwallex": "AirwallexConfig", "bambora": "BamboraConfig", "bankofamerica": "BankOfAmericaConfig", "billwerk": "BillwerkConfig", "bluesnap": "BluesnapConfig", "braintree": "BraintreeConfig", "cashtocode": "CashtocodeConfig", "cryptopay": "CryptopayConfig", "cybersource": "CybersourceConfig", "datatrans": "DatatransConfig", "dlocal": "DlocalConfig", "elavon": "ElavonConfig", "fiserv": "FiservConfig", "fiservemea": "FiservemeaConfig", "forte": "ForteConfig", "getnet": "GetnetConfig", "globalpay": "GlobalpayConfig", "hipay": "HipayConfig", "helcim": "HelcimConfig", "iatapay": "IatapayConfig", "jpmorgan": "JpmorganConfig", "mifinity": "MifinityConfig", "mollie": "MollieConfig", "multisafepay": "MultisafepayConfig", "nexinets": "NexinetsConfig", "nexixpay": "NexixpayConfig", "nmi": "NmiConfig", "noon": "NoonConfig", "novalnet": "NovalnetConfig", "nuvei": "NuveiConfig", "paybox": "PayboxConfig", "payme": "PaymeConfig", "payu": "PayuConfig", "powertranz": "PowertranzConfig", "rapyd": "RapydConfig", "redsys": "RedsysConfig", "shift4": "Shift4Config", "stax": "StaxConfig", "stripe": "StripeConfig", "trustpay": "TrustpayConfig", "tsys": "TsysConfig", "volt": "VoltConfig", "wellsfargo": "WellsfargoConfig", "worldpay": "WorldpayConfig", "worldpayvantiv": "WorldpayvantivConfig", "xendit": "XenditConfig", "phonepe": "PhonepeConfig", "cashfree": "CashfreeConfig", "paytm": "PaytmConfig", "calida": "CalidaConfig", "payload": "PayloadConfig", "authipay": "AuthipayConfig", "silverflow": "SilverflowConfig", "celero": "CeleroConfig", "trustpayments": "TrustpaymentsConfig", "paysafe": "PaysafeConfig", "barclaycard": "BarclaycardConfig", "worldpayxml": "WorldpayxmlConfig", "revolut": "RevolutConfig", "loonio": "LoonioConfig", "gigadat": "GigadatConfig", "hyperpg": "HyperpgConfig", "zift": "ZiftConfig", "screenstream": "ScreenstreamConfig", "ebanx": "EbanxConfig", "fiuu": "FiuuConfig", "globepay": "GlobepayConfig", "coinbase": "CoinbaseConfig", "coingate": "CoingateConfig", "revolv3": "Revolv3Config", "authorizedotnet": "AuthorizedotnetConfig", "peachpayments": "PeachpaymentsConfig", "paypal": "PaypalConfig", "truelayer": "TruelayerConfig", "fiservcommercehub": "FiservcommercehubConfig", "itaubank": "ItaubankConfig", "ppro": "PproConfig", "trustly": "TrustlyConfig", "absaSanlam": "AbsaSanlamConfig", "pinelabsOnline": "PinelabsOnlineConfig", "imerchantsolutions": "ImerchantsolutionsConfig", "axisbank": "AxisbankConfig", "easebuzz": "EasebuzzConfig", "twocTwopPaco": "TwocTwopPacoConfig", "bamboraapac": "BamboraapacConfig", "placetopay": "PlacetopayConfig", "finix": "FinixConfig", "aci": "AciConfig", "interpayments": "InterpaymentsConfig", "juspay": "JuspayConfig", "tamara": "TamaraConfig", "payconex": "PayconexConfig", "qwikcilver": "QwikcilverConfig", "checkout": "CheckoutConfig", "hyperswitch": "HyperswitchConfig", "tsysTransit": "TsysTransitConfig", "kount": "KountConfig", "affirm": "AffirmConfig", "flywire": "FlywireConfig", "glomopay": "GlomopayConfig", "givepayments": "GivepaymentsConfig", "tesouro": "TesouroConfig", "deutschebank": "DeutschebankConfig", "plaid": "PlaidConfig", "santander": "SantanderConfig", "maya": "MayaConfig", "grabpay": "GrabpayConfig", "boost": "BoostConfig" }, + ConnectorSpecificConfig: { "adyen": "AdyenConfig", "airwallex": "AirwallexConfig", "bambora": "BamboraConfig", "bankofamerica": "BankOfAmericaConfig", "billwerk": "BillwerkConfig", "bluesnap": "BluesnapConfig", "braintree": "BraintreeConfig", "cashtocode": "CashtocodeConfig", "cryptopay": "CryptopayConfig", "cybersource": "CybersourceConfig", "datatrans": "DatatransConfig", "dlocal": "DlocalConfig", "elavon": "ElavonConfig", "fiserv": "FiservConfig", "fiservemea": "FiservemeaConfig", "forte": "ForteConfig", "getnet": "GetnetConfig", "globalpay": "GlobalpayConfig", "hipay": "HipayConfig", "helcim": "HelcimConfig", "iatapay": "IatapayConfig", "jpmorgan": "JpmorganConfig", "mifinity": "MifinityConfig", "mollie": "MollieConfig", "multisafepay": "MultisafepayConfig", "nexinets": "NexinetsConfig", "nexixpay": "NexixpayConfig", "nmi": "NmiConfig", "noon": "NoonConfig", "novalnet": "NovalnetConfig", "nuvei": "NuveiConfig", "paybox": "PayboxConfig", "payme": "PaymeConfig", "payu": "PayuConfig", "powertranz": "PowertranzConfig", "rapyd": "RapydConfig", "redsys": "RedsysConfig", "shift4": "Shift4Config", "stax": "StaxConfig", "stripe": "StripeConfig", "trustpay": "TrustpayConfig", "tsys": "TsysConfig", "volt": "VoltConfig", "wellsfargo": "WellsfargoConfig", "worldpay": "WorldpayConfig", "worldpayvantiv": "WorldpayvantivConfig", "xendit": "XenditConfig", "phonepe": "PhonepeConfig", "cashfree": "CashfreeConfig", "paytm": "PaytmConfig", "calida": "CalidaConfig", "payload": "PayloadConfig", "authipay": "AuthipayConfig", "silverflow": "SilverflowConfig", "celero": "CeleroConfig", "trustpayments": "TrustpaymentsConfig", "paysafe": "PaysafeConfig", "barclaycard": "BarclaycardConfig", "worldpayxml": "WorldpayxmlConfig", "revolut": "RevolutConfig", "loonio": "LoonioConfig", "gigadat": "GigadatConfig", "hyperpg": "HyperpgConfig", "zift": "ZiftConfig", "screenstream": "ScreenstreamConfig", "ebanx": "EbanxConfig", "fiuu": "FiuuConfig", "globepay": "GlobepayConfig", "coinbase": "CoinbaseConfig", "coingate": "CoingateConfig", "revolv3": "Revolv3Config", "authorizedotnet": "AuthorizedotnetConfig", "peachpayments": "PeachpaymentsConfig", "paypal": "PaypalConfig", "truelayer": "TruelayerConfig", "fiservcommercehub": "FiservcommercehubConfig", "itaubank": "ItaubankConfig", "ppro": "PproConfig", "trustly": "TrustlyConfig", "absaSanlam": "AbsaSanlamConfig", "pinelabsOnline": "PinelabsOnlineConfig", "imerchantsolutions": "ImerchantsolutionsConfig", "axisbank": "AxisbankConfig", "easebuzz": "EasebuzzConfig", "twocTwopPaco": "TwocTwopPacoConfig", "bamboraapac": "BamboraapacConfig", "placetopay": "PlacetopayConfig", "finix": "FinixConfig", "aci": "AciConfig", "interpayments": "InterpaymentsConfig", "juspay": "JuspayConfig", "tamara": "TamaraConfig", "payconex": "PayconexConfig", "qwikcilver": "QwikcilverConfig", "checkout": "CheckoutConfig", "hyperswitch": "HyperswitchConfig", "tsysTransit": "TsysTransitConfig", "kount": "KountConfig", "affirm": "AffirmConfig", "flywire": "FlywireConfig", "glomopay": "GlomopayConfig", "givepayments": "GivepaymentsConfig", "tesouro": "TesouroConfig", "deutschebank": "DeutschebankConfig", "plaid": "PlaidConfig", "santander": "SantanderConfig", "maya": "MayaConfig", "grabpay": "GrabpayConfig", "boost": "BoostConfig", "ilixium": "IlixiumConfig" }, PaymentServiceTokenAuthorizeRequest: { "amount": "Money", "customer": "Customer", "address": "PaymentAddress", "browserInfo": "BrowserInformation", "state": "ConnectorState", "billingDescriptor": "BillingDescriptor", "l2L3Data": "L2L3Data", "customerAcceptance": "CustomerAcceptance" }, PaymentServiceTokenSetupRecurringRequest: { "amount": "Money", "customer": "Customer", "address": "PaymentAddress", "state": "ConnectorState", "customerAcceptance": "CustomerAcceptance", "setupMandateDetails": "SetupMandateDetails", "billingDescriptor": "BillingDescriptor" }, PaymentServiceProxyAuthorizeRequest: { "amount": "Money", "cardProxy": "ProxyCardDetails", "customer": "Customer", "address": "PaymentAddress", "authenticationData": "AuthenticationData", "browserInfo": "BrowserInformation", "state": "ConnectorState", "setupMandateDetails": "SetupMandateDetails", "billingDescriptor": "BillingDescriptor", "redirectionResponse": "RedirectionResponse", "l2L3Data": "L2L3Data", "customerAcceptance": "CustomerAcceptance", "domainData": "DomainData" },