From d11d164d82061ccd65665e4e8afe0498d1c342b5 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 3 Aug 2026 12:00:46 +0200 Subject: [PATCH 1/4] ChallengeIndicator/SessionChallengeIndicator enum alignment --- .../checkout/common/ChallengeIndicator.java | 75 ++++-- .../CreateSessionAcceptedResponse.java | 124 +++++++++- .../checkout/sessions/GetSessionResponse.java | 220 +++++++++++++++++- .../sessions/SessionChallengeIndicator.java | 89 +++++++ .../com/checkout/sessions/SessionRequest.java | 120 +++++++++- .../common/ChallengeIndicatorTest.java | 26 +++ .../sessions/AbstractSessionsTestIT.java | 7 +- .../sessions/RequestAndGetSessionsTestIT.java | 31 ++- .../SessionChallengeIndicatorTest.java | 119 ++++++++++ .../SessionRequestSerializationTest.java | 194 +++++++++++++++ 10 files changed, 963 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/checkout/sessions/SessionChallengeIndicator.java create mode 100644 src/test/java/com/checkout/sessions/SessionChallengeIndicatorTest.java create mode 100644 src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java diff --git a/src/main/java/com/checkout/common/ChallengeIndicator.java b/src/main/java/com/checkout/common/ChallengeIndicator.java index a9f531d6..73dc5f14 100644 --- a/src/main/java/com/checkout/common/ChallengeIndicator.java +++ b/src/main/java/com/checkout/common/ChallengeIndicator.java @@ -3,52 +3,99 @@ import com.google.gson.annotations.SerializedName; /** - * Indicates whether a challenge is requested for an authentication. + * Indicates the preference for whether or not a 3DS challenge should be performed. The customer's + * bank has the final say on whether or not the customer receives the challenge. *

- * The first four values are valid for all endpoints that accept a challenge indicator - * (e.g. {@code POST /payments} {@code three_ds.challenge_indicator}, {@code POST /sessions}, - * and their responses). + * This is the four-value indicator accepted by the {@code 3ds.challenge_indicator} field on + * {@code POST /payments}, {@code POST /hosted-payments}, {@code POST /payment-links} and + * {@code POST /payment-sessions}. *

- * The remaining values ({@link #LOW_VALUE}, {@link #TRUSTED_LISTING}, - * {@link #TRUSTED_LISTING_PROMPT}, {@link #TRANSACTION_RISK_ASSESSMENT}, {@link #DATA_SHARE}) - * represent requests for exemption and are only valid for {@code POST /sessions} - * (3DS Standalone Authentication). If an exemption cannot be applied, the value - * {@link #NO_CHALLENGE_REQUESTED} will be used instead. + * [Optional] + *

+ * Default: {@link #NO_PREFERENCE} + * + * @see com.checkout.sessions.SessionChallengeIndicator the wider nine-value enum accepted by + * {@code POST /sessions}, which additionally supports requests for exemption */ public enum ChallengeIndicator { + /** + * A challenge is requested for this payment. + */ @SerializedName("challenge_requested") CHALLENGE_REQUESTED, + + /** + * A challenge is requested for this payment because it is mandated by local regulation or + * scheme rules. + */ @SerializedName("challenge_requested_mandate") CHALLENGE_REQUESTED_MANDATE, + + /** + * A challenge is not requested for this payment. + */ @SerializedName("no_challenge_requested") NO_CHALLENGE_REQUESTED, + + /** + * No preference as to whether a challenge should be performed. This is the default. + */ @SerializedName("no_preference") NO_PREFERENCE, + /** - * Request a low-value exemption. Only valid for {@code POST /sessions}. + * Request a low-value exemption. + * + * @deprecated only valid for {@code POST /sessions}, which is now modelled by + * {@link com.checkout.sessions.SessionChallengeIndicator#LOW_VALUE}. This value is rejected by + * the {@code 3ds.challenge_indicator} fields that use this enum. */ + @Deprecated @SerializedName("low_value") LOW_VALUE, + /** - * Request a trusted listing exemption. Only valid for {@code POST /sessions}. + * Request a trusted listing exemption. + * + * @deprecated only valid for {@code POST /sessions}, which is now modelled by + * {@link com.checkout.sessions.SessionChallengeIndicator#TRUSTED_LISTING}. This value is + * rejected by the {@code 3ds.challenge_indicator} fields that use this enum. */ + @Deprecated @SerializedName("trusted_listing") TRUSTED_LISTING, + /** * Request a trusted listing prompt to add the merchant to the cardholder's trusted list. - * Only valid for {@code POST /sessions}. + * + * @deprecated only valid for {@code POST /sessions}, which is now modelled by + * {@link com.checkout.sessions.SessionChallengeIndicator#TRUSTED_LISTING_PROMPT}. This value is + * rejected by the {@code 3ds.challenge_indicator} fields that use this enum. */ + @Deprecated @SerializedName("trusted_listing_prompt") TRUSTED_LISTING_PROMPT, + /** - * Request a transaction risk analysis (TRA) exemption. Only valid for {@code POST /sessions}. + * Request a transaction risk analysis (TRA) exemption. + * + * @deprecated only valid for {@code POST /sessions}, which is now modelled by + * {@link com.checkout.sessions.SessionChallengeIndicator#TRANSACTION_RISK_ASSESSMENT}. This + * value is rejected by the {@code 3ds.challenge_indicator} fields that use this enum. */ + @Deprecated @SerializedName("transaction_risk_assessment") TRANSACTION_RISK_ASSESSMENT, + /** - * Indicates a data-share authentication request. Only valid for {@code POST /sessions}. + * Indicates a data-share authentication request. + * + * @deprecated only valid for {@code POST /sessions}, which is now modelled by + * {@link com.checkout.sessions.SessionChallengeIndicator#DATA_SHARE}. This value is rejected by + * the {@code 3ds.challenge_indicator} fields that use this enum. */ + @Deprecated @SerializedName("data_share") DATA_SHARE, diff --git a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java index 35e52149..97aa1192 100644 --- a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java +++ b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java @@ -1,6 +1,5 @@ package com.checkout.sessions; -import com.checkout.common.ChallengeIndicator; import com.checkout.common.Currency; import com.checkout.common.Resource; import lombok.Data; @@ -15,48 +14,167 @@ @ToString(callSuper = true) public final class CreateSessionAcceptedResponse extends Resource { + /** + * Session unique identifier. + * [Required] + * ^(sid)_(\w{26})$ + * min 30 characters + * max 30 characters + */ private String id; + /** + * A base64 encoded value prefixed with {@code sek_} that gives access to client-side operations + * for a single authentication within the Sessions API. + * [Required] + * ^(sek)_(.{44})$ + * min 48 characters + * max 48 characters + */ private String sessionSecret; + /** + * The transaction identifier that needs to be provided when communicating directly with the + * Access Control Server (ACS). + * [Required] + * min 36 characters + * max 36 characters + */ private String transactionId; + /** + * Indicates the scheme this authentication is carried out against. + * [Required] + */ private SessionScheme scheme; + /** + * The amount in the minor currency. + * [Required] + * min 0 + * max 9007199254740991 + */ private Long amount; + /** + * The three-letter ISO currency code. + * [Required] + * min 3 characters + * max 3 characters + */ private Currency currency; + /** + * Indicates the type of payment this session is for. Please note the spelling of + * {@code installment} consists of two {@code l}s. + * [Required] + */ private AuthenticationType authenticationType; + /** + * Indicates the category of the authentication request. + * [Required] + */ private Category authenticationCategory; + /** + * The status of the session. + * [Required] + */ private SessionStatus status; + /** + * When the session is unavailable, this points to the reason why. + *

+ * Note: this field is not present in the {@code CreateSessionAcceptedResponse} schema of the + * Checkout.com API Reference, where it appears only on {@code GET /sessions/{id}}. It is + * retained for backwards compatibility pending confirmation from the API owners. + * [Optional] + */ private StatusReason statusReason; + /** + * Specifies which action to take in order to complete the session. + * The {@code redirect_cardholder} action is only applicable for hosted sessions. + * [Required] + */ private List nextActions; + /** + * The protocol version number of the specification used by the API for authentication. + * [Required] + * max 50 characters + */ private String protocolVersion; + /** + * Additional information about the cardholder's account. + * [Optional] + */ private CardholderAccountInfo accountInfo; + /** + * Additional information about the cardholder's purchase. + * [Optional] + */ private MerchantRiskInfo merchantRiskInfo; + /** + * A reference you can later use to identify this payment, such as an order number. + * [Optional] + * max 100 characters + */ private String reference; + /** + * Details related to the session source. This property should always be in the response, unless + * a {@code card} source was used and communication with Checkout.com's Vault was not possible. + * [Optional] + */ private CardInfo card; + /** + * Details of a recurring authentication. + * [Optional] + */ private Recurring recurring; + /** + * Details of an installment authentication. + * [Optional] + */ private Installment installment; + /** + * Details of a previous transaction. + * [Optional] + */ private InitialTransaction initialTransaction; + /** + * Authentication date and time. + * [Required] + * Format: date-time (RFC 3339) + */ private Instant authenticationDate; - private ChallengeIndicator challengeIndicator; - + /** + * Indicates the preference for whether or not a 3DS challenge should be performed. The + * customer's bank has the final say on whether or not the customer receives the challenge. + *

+ * Note: the API Reference specifies only the four base values for this response field, but the + * request accepts nine. This is typed as {@link SessionChallengeIndicator} so that an exemption + * value echoed back by the API still deserializes; see + * {@link SessionRequest#getChallengeIndicator()}. + * [Required] + * Default: {@link SessionChallengeIndicator#NO_PREFERENCE} + */ + private SessionChallengeIndicator challengeIndicator; + + /** + * The information about the optimization options selected. + * [Optional] + */ private Optimization optimization; } diff --git a/src/main/java/com/checkout/sessions/GetSessionResponse.java b/src/main/java/com/checkout/sessions/GetSessionResponse.java index 64019505..b56e0d10 100644 --- a/src/main/java/com/checkout/sessions/GetSessionResponse.java +++ b/src/main/java/com/checkout/sessions/GetSessionResponse.java @@ -1,6 +1,5 @@ package com.checkout.sessions; -import com.checkout.common.ChallengeIndicator; import com.checkout.common.Currency; import com.checkout.common.Resource; import com.checkout.common.ThreeDSFlowType; @@ -17,85 +16,300 @@ @ToString(callSuper = true) public class GetSessionResponse extends Resource { + /** + * Session unique identifier. + * [Required] + * ^(sid)_(\w{26})$ + * min 30 characters + * max 30 characters + */ private String id; + /** + * A base64 encoded value prefixed with {@code sek_} that gives access to client-side operations + * for a single authentication within the Sessions API. + * [Optional] + * ^(sek)_(.{44})$ + * min 48 characters + * max 48 characters + */ private String sessionSecret; + /** + * The transaction identifier that needs to be provided when communicating directly with the + * Access Control Server (ACS). + * [Required] + * min 36 characters + * max 36 characters + */ private String transactionId; + /** + * Indicates the scheme this authentication is carried out against. + * [Required] + */ private SessionScheme scheme; + /** + * The amount in the minor currency. + * [Required] + * min 0 + * max 9007199254740991 + */ private Long amount; + /** + * The three-letter ISO currency code. + * [Required] + * min 3 characters + * max 3 characters + */ private Currency currency; + /** + * Indicates whether this session has been completed. + * [Optional] + */ private Boolean completed; + /** + * Indicates whether this session involved a challenge. This will only be set after + * communication with the scheme is finished. + * [Optional] + */ private Boolean challenged; + /** + * Indicates the type of payment this session is for. Please note the spelling of + * {@code installment} consists of two {@code l}s. + * [Required] + */ private AuthenticationType authenticationType; + /** + * Indicates the category of the authentication request. + * [Required] + */ private Category authenticationCategory; + /** + * Public certificates specific to a Directory Server (DS) for encrypting device data and + * verifying ACS signed content. Required when the channel is {@code app}. + * [Optional] + */ private DsPublicKeys certificates; + /** + * Indicates the status of the session. + * [Required] + */ private SessionStatus status; + /** + * When the session is unavailable, this points to the reason why. For example, + * {@code ares_error} indicates there was an issue in the authentication response returned by + * the Directory Server, and {@code ares_status} indicates the status was set to the status in + * that authentication response. + * [Optional] + */ private StatusReason statusReason; + /** + * Whether the authentication was successful. This will only be set if the session is in a final + * state. + * [Optional] + */ private Boolean approved; + /** + * The protocol version number of the specification used by the API for authentication. + * [Required] + * max 50 characters + */ private String protocolVersion; + /** + * Additional information about the cardholder's account. + * [Optional] + */ @SerializedName("account_info") private CardholderAccountInfo cardholderAccountInfo; + /** + * Additional information about the cardholder's purchase. + * [Optional] + */ private MerchantRiskInfo merchantRiskInfo; + /** + * A reference you can later use to identify this payment, such as an order number. + * [Optional] + * max 100 characters + */ private String reference; + /** + * Identifies the type of transaction being authenticated. + * [Optional] + * Default: {@link TransactionType#GOODS_SERVICE} + * max 50 characters + */ private TransactionType transactionType; + /** + * Specifies which action to take in order to complete the session. + * The {@code redirect_cardholder} action is only applicable for hosted sessions. + * [Optional] + */ private List nextActions; + /** + * The directory server (DS) information. Can be empty if the session is pending or + * communication with the DS failed. + * [Optional] + */ private Ds ds; + /** + * The access control server (ACS) information. Can be empty if the session is still pending or + * if communication with the ACS failed. This will be available when the channel data and issuer + * fingerprint result have been provided. + * [Optional] + */ private Acs acs; + /** + * Only available as a result of a 3DS2 authentication. The response from the DS or ACS which + * indicates whether a transaction qualifies as an authenticated transaction or account + * verification. Only available if communication with the scheme was successful and the session + * is in a final state. + * [Optional] + */ private ResponseCode responseCode; + /** + * Only available as a result of a 3DS2 authentication. The response from the DS or ACS which + * provides information on why the {@link #responseCode} field has the specified value. Only + * available when {@link #responseCode} is not {@code Y}. + * [Optional] + */ private String responseStatusReason; + /** + * The 3DS1 payer authentication request message. + *

+ * Note: this field is not present in the {@code GetSessionResponse} schema of the Checkout.com + * API Reference. It is retained for backwards compatibility pending confirmation from the API + * owners. + * [Optional] + */ private String pareq; + /** + * Payment system-specific value provided as part of the ACS registration for each supported DS. + * This field is only included in responses when authenticating with a valid OAuth token, and not + * when authenticating with {@link #sessionSecret}. + * [Optional] + * min 28 characters + * max 28 characters + */ private String cryptogram; + /** + * Electronic Commerce Indicator. This field is only included in responses when authenticating + * with a valid OAuth token, and not when authenticating with {@link #sessionSecret}. + * [Optional] + * min 2 characters + * max 2 characters + */ private String eci; + /** + * The xid value to use for authorization. + * [Optional] + */ private String xid; + /** + * May provide cardholder information from the DS to be presented to the cardholder. + * [Optional] + */ private String cardholderInfo; + /** + * Details related to the session source. This property should always be in the response, unless + * a {@code card} source was used and communication with Checkout.com's Vault was not possible. + * [Optional] + */ private CardInfo card; + /** + * Details of a recurring authentication. + * [Optional] + */ private Recurring recurring; + /** + * Details of an installment authentication. + * [Optional] + */ private Installment installment; + /** + * Details of a previous transaction. + * [Optional] + */ private InitialTransaction initialTransaction; + /** + * Indicates the cardholder's IP address. Only available when the scheme selected is Cartes + * Bancaires. + * [Optional] + */ private String customerIp; + /** + * Authentication date and time. + * [Optional] + * Format: date-time (RFC 3339) + */ private Instant authenticationDate; + /** + * Details related to the exemption present in the 3DS flow. + * [Optional] + */ private ThreeDSExemption exemption; + /** + * Indicates whether the 3D Secure 2 authentication was challenged or frictionless. + * [Optional] + */ private ThreeDSFlowType flowType; - private ChallengeIndicator challengeIndicator; - + /** + * Indicates the preference for whether or not a 3DS challenge should be performed. The + * customer's bank has the final say on whether or not the customer receives the challenge. + *

+ * Note: the API Reference specifies only the four base values for this response field, but the + * request accepts nine. This is typed as {@link SessionChallengeIndicator} so that an exemption + * value echoed back by the API still deserializes; see + * {@link SessionRequest#getChallengeIndicator()}. + * [Required] + * Default: {@link SessionChallengeIndicator#NO_PREFERENCE} + */ + private SessionChallengeIndicator challengeIndicator; + + /** + * The information about the optimization options selected. + * [Optional] + */ private Optimization optimization; + /** + * Indicates scheme-specific information. + * [Optional] + */ private SchemeInfo schemeInfo; } diff --git a/src/main/java/com/checkout/sessions/SessionChallengeIndicator.java b/src/main/java/com/checkout/sessions/SessionChallengeIndicator.java new file mode 100644 index 00000000..fe51d55f --- /dev/null +++ b/src/main/java/com/checkout/sessions/SessionChallengeIndicator.java @@ -0,0 +1,89 @@ +package com.checkout.sessions; + +import com.google.gson.annotations.SerializedName; + +/** + * Indicates whether a challenge is requested for this session. + *

+ * Used by {@link SessionRequest#getChallengeIndicator()} for {@code POST /sessions} + * (3DS Standalone Authentication). This is the only field in the API that accepts the + * exemption values below; the {@code 3ds.challenge_indicator} field on payments, hosted + * payments, payment links and payment sessions accepts only the first four values and is + * modelled by {@link com.checkout.common.ChallengeIndicator}. + *

+ * The following are requests for exemption: + * {@link #LOW_VALUE}, {@link #TRUSTED_LISTING}, {@link #TRUSTED_LISTING_PROMPT} and + * {@link #TRANSACTION_RISK_ASSESSMENT}. If an exemption cannot be applied, then the value + * {@link #NO_CHALLENGE_REQUESTED} will be used instead. + *

+ * [Optional] + *

+ * Default: {@link #NO_PREFERENCE} + *

+ * max 50 characters + */ +public enum SessionChallengeIndicator { + + /** + * No preference as to whether a challenge should be performed. This is the default. + */ + @SerializedName("no_preference") + NO_PREFERENCE, + + /** + * A challenge is not requested for this session. + */ + @SerializedName("no_challenge_requested") + NO_CHALLENGE_REQUESTED, + + /** + * A challenge is requested for this session. + */ + @SerializedName("challenge_requested") + CHALLENGE_REQUESTED, + + /** + * A challenge is requested for this session because it is mandated by local regulation + * or scheme rules. + */ + @SerializedName("challenge_requested_mandate") + CHALLENGE_REQUESTED_MANDATE, + + /** + * Request a low-value exemption. If the exemption cannot be applied, the value + * {@link #NO_CHALLENGE_REQUESTED} will be used instead. + */ + @SerializedName("low_value") + LOW_VALUE, + + /** + * Request a trusted listing exemption, applied when the cardholder has already added the + * merchant to their list of trusted beneficiaries. If the exemption cannot be applied, the + * value {@link #NO_CHALLENGE_REQUESTED} will be used instead. + */ + @SerializedName("trusted_listing") + TRUSTED_LISTING, + + /** + * Request a trusted listing exemption and prompt the cardholder to add the merchant to their + * list of trusted beneficiaries. If the exemption cannot be applied, the value + * {@link #NO_CHALLENGE_REQUESTED} will be used instead. + */ + @SerializedName("trusted_listing_prompt") + TRUSTED_LISTING_PROMPT, + + /** + * Request a transaction risk analysis (TRA) exemption. If the exemption cannot be applied, + * the value {@link #NO_CHALLENGE_REQUESTED} will be used instead. + */ + @SerializedName("transaction_risk_assessment") + TRANSACTION_RISK_ASSESSMENT, + + /** + * Request a data-share authentication, where cardholder data is shared with the issuer to + * support their risk assessment without requesting a challenge. + */ + @SerializedName("data_share") + DATA_SHARE, + +} diff --git a/src/main/java/com/checkout/sessions/SessionRequest.java b/src/main/java/com/checkout/sessions/SessionRequest.java index d954ed66..b43fec3c 100644 --- a/src/main/java/com/checkout/sessions/SessionRequest.java +++ b/src/main/java/com/checkout/sessions/SessionRequest.java @@ -1,6 +1,5 @@ package com.checkout.sessions; -import com.checkout.common.ChallengeIndicator; import com.checkout.common.Currency; import com.checkout.sessions.channel.BrowserSession; import com.checkout.sessions.channel.ChannelData; @@ -19,54 +18,171 @@ @NoArgsConstructor public final class SessionRequest { + /** + * The source of the authentication. + * [Required] + */ @Builder.Default private SessionSource source = new SessionCardSource(); + /** + * The payment amount in the minor currency unit. + * For {@code recurring} and {@code installment} payment types, this value is required and must + * be greater than zero. + * Omitting this value will set {@link #authenticationCategory} to {@link Category#NON_PAYMENT}. + * [Optional] + * min 0 + * max 48 characters + */ private Long amount; + /** + * The three-letter ISO currency code. + * [Required] + * min 3 characters + * max 3 characters + */ private Currency currency; + /** + * The processing channel to be used for the session. Required if this was not set in the + * request for the OAuth token. + * [Optional] + * ^(pc)_(\w{26})$ + */ private String processingChannelId; + /** + * Information related to authentication for payfac payments. + * [Optional] + */ private SessionMarketplaceData marketplace; + /** + * Indicates the type of payment this session is for. Please note the spelling of + * {@code installment} consists of two {@code l}s. + * [Optional] + * Default: {@link AuthenticationType#REGULAR} + */ @Builder.Default private AuthenticationType authenticationType = AuthenticationType.REGULAR; + /** + * Indicates the category of the authentication request. + * [Optional] + * Default: {@link Category#PAYMENT} + */ @Builder.Default private Category authenticationCategory = Category.PAYMENT; + /** + * Additional information about the cardholder's account. + * [Optional] + */ @SerializedName("account_info") private CardholderAccountInfo cardholderAccountInfo; + /** + * Indicates whether a challenge is requested for this session. + * The exemption values are accepted only by {@code POST /sessions}; see + * {@link SessionChallengeIndicator}. + * [Optional] + * Default: {@link SessionChallengeIndicator#NO_PREFERENCE} + * max 50 characters + */ @Builder.Default - private ChallengeIndicator challengeIndicator = ChallengeIndicator.NO_PREFERENCE; + private SessionChallengeIndicator challengeIndicator = SessionChallengeIndicator.NO_PREFERENCE; + /** + * An optional dynamic billing descriptor. + * [Optional] + */ private SessionsBillingDescriptor billingDescriptor; + /** + * A reference you can later use to identify this payment, such as an order number. + * Do not pass sensitive information in this field, for example card details. + * [Optional] + * max 100 characters + */ private String reference; + /** + * Additional information about the cardholder's purchase. + * [Optional] + */ private MerchantRiskInfo merchantRiskInfo; + /** + * A reference to a previous transaction for this cardholder. + *

+ * Note: this field is not present in the {@code SessionRequest} schema of the Checkout.com API + * Reference. It is retained for backwards compatibility pending confirmation from the API + * owners. + * [Optional] + */ private String priorTransactionReference; + /** + * Identifies the type of transaction being authenticated. + * [Optional] + * Default: {@link TransactionType#GOODS_SERVICE} + * max 50 characters + */ @Builder.Default private TransactionType transactionType = TransactionType.GOODS_SERVICE; + /** + * The shipping address. Any special characters will be replaced. + * [Optional] + */ private SessionAddress shippingAddress; + /** + * Indicates whether the cardholder shipping address and billing address are the same. + * [Optional] + */ private Boolean shippingAddressMatchesBilling; + /** + * The redirect information needed for callbacks or redirects after the payment is completed. + * [Required] + */ private CompletionInfo completion; + /** + * The information gathered from the environment used to initiate the session. + * See {@link BrowserSession} for the browser channel. + * [Optional] + */ private ChannelData channelData; + /** + * Details of a recurring authentication. This property is needed only for a + * {@link AuthenticationType#RECURRING} authentication type. Value will be ignored in any other + * cases. + * [Optional] + */ private Recurring recurring; + /** + * Details of an installment authentication. This property is needed only for an + * {@link AuthenticationType#INSTALLMENT} authentication type. Value will be ignored in any + * other cases. + * [Optional] + */ private Installment installment; + /** + * Optionally opt into request optimization. + * [Optional] + */ private Optimization optimization; + /** + * Details of a previous transaction. + * [Optional] + */ private InitialTransaction initialTransaction; /** diff --git a/src/test/java/com/checkout/common/ChallengeIndicatorTest.java b/src/test/java/com/checkout/common/ChallengeIndicatorTest.java index 0d01df94..5af91bf6 100644 --- a/src/test/java/com/checkout/common/ChallengeIndicatorTest.java +++ b/src/test/java/com/checkout/common/ChallengeIndicatorTest.java @@ -11,10 +11,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +/** + * The five exemption values are deprecated on this shared enum in favour of + * {@link com.checkout.sessions.SessionChallengeIndicator}, but they are deliberately kept and still + * covered here: removing them would break merchants that already reference them. + */ +@SuppressWarnings("deprecation") class ChallengeIndicatorTest { private final Serializer serializer = new GsonSerializer(); + private static Stream baseChallengeIndicators() { + return Stream.of( + Arguments.of(ChallengeIndicator.NO_PREFERENCE, "\"no_preference\""), + Arguments.of(ChallengeIndicator.NO_CHALLENGE_REQUESTED, "\"no_challenge_requested\""), + Arguments.of(ChallengeIndicator.CHALLENGE_REQUESTED, "\"challenge_requested\""), + Arguments.of(ChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, "\"challenge_requested_mandate\"") + ); + } + + /** + * The four base values are the only ones accepted by the {@code 3ds.challenge_indicator} fields + * that use this enum. + */ + @ParameterizedTest + @MethodSource("baseChallengeIndicators") + void shouldRoundTripTheFourBaseValues(final ChallengeIndicator value, final String expectedJson) { + assertEquals(expectedJson, serializer.toJson(value)); + assertEquals(value, serializer.fromJson(expectedJson, ChallengeIndicator.class)); + } + private static Stream challengeIndicators() { return Stream.of( Arguments.of(ChallengeIndicator.NO_PREFERENCE, "\"no_preference\""), diff --git a/src/test/java/com/checkout/sessions/AbstractSessionsTestIT.java b/src/test/java/com/checkout/sessions/AbstractSessionsTestIT.java index 0580cc98..f0cd52c0 100644 --- a/src/test/java/com/checkout/sessions/AbstractSessionsTestIT.java +++ b/src/test/java/com/checkout/sessions/AbstractSessionsTestIT.java @@ -4,7 +4,6 @@ import com.checkout.PlatformType; import com.checkout.SandboxTestFixture; import com.checkout.TestHelper; -import com.checkout.common.ChallengeIndicator; import com.checkout.common.CountryCode; import com.checkout.common.Currency; import com.checkout.common.Phone; @@ -30,7 +29,7 @@ public AbstractSessionsTestIT() { protected SessionResponse createNonHostedSession(final ChannelData channelData, final Category authenticationCategory, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final SessionRequest sessionRequest = createNonHostedSessionRequest(channelData, authenticationCategory, challengeIndicator, transactionType); @@ -45,7 +44,7 @@ protected SessionResponse createHostedSession() { // Common methods protected SessionRequest createNonHostedSessionRequest(final ChannelData channelData, final Category authenticationCategory, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { return SessionRequest.builder() .source(createSessionCardSource()) @@ -73,7 +72,7 @@ protected SessionRequest createHostedSessionRequest() { .processingChannelId(System.getenv("CHECKOUT_PROCESSING_CHANNEL_ID")) .authenticationType(AuthenticationType.REGULAR) .authenticationCategory(Category.PAYMENT) - .challengeIndicator(ChallengeIndicator.NO_PREFERENCE) + .challengeIndicator(SessionChallengeIndicator.NO_PREFERENCE) .reference("ORD-5023-4E89") .transactionType(TransactionType.GOODS_SERVICE) .shippingAddress(createShippingAddress()) diff --git a/src/test/java/com/checkout/sessions/RequestAndGetSessionsTestIT.java b/src/test/java/com/checkout/sessions/RequestAndGetSessionsTestIT.java index a3ac5285..b2a8eb72 100644 --- a/src/test/java/com/checkout/sessions/RequestAndGetSessionsTestIT.java +++ b/src/test/java/com/checkout/sessions/RequestAndGetSessionsTestIT.java @@ -1,6 +1,5 @@ package com.checkout.sessions; -import com.checkout.common.ChallengeIndicator; import com.checkout.sessions.channel.ChannelData; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.params.ParameterizedTest; @@ -18,32 +17,32 @@ class RequestAndGetSessionsTestIT extends AbstractSessionsTestIT { private static Stream sessionsTypes_browserSession() { return Stream.of( - Arguments.of(Category.PAYMENT, ChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) + Arguments.of(Category.PAYMENT, SessionChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) ); } private static Stream sessionsTypes_appSession() { return Stream.of( - Arguments.of(Category.PAYMENT, ChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) + Arguments.of(Category.PAYMENT, SessionChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) ); } private static Stream sessionsTypes_merchantInitiatedSession() { return Stream.of( - Arguments.of(Category.PAYMENT, ChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), - Arguments.of(Category.NON_PAYMENT, ChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) + Arguments.of(Category.PAYMENT, SessionChallengeIndicator.NO_PREFERENCE, TransactionType.GOODS_SERVICE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED, TransactionType.CHECK_ACCEPTANCE), + Arguments.of(Category.NON_PAYMENT, SessionChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, TransactionType.ACCOUNT_FUNDING) ); } @ParameterizedTest @MethodSource("sessionsTypes_browserSession") void shouldRequestAndGetCardSession_browserSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData browserSession = browserSession(); @@ -64,7 +63,7 @@ void shouldRequestAndGetCardSession_browserSession(final Category category, @ParameterizedTest @MethodSource("sessionsTypes_appSession") void shouldRequestAndGetCardSession_appSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData appSession = appSession(); @@ -83,7 +82,7 @@ void shouldRequestAndGetCardSession_appSession(final Category category, @ParameterizedTest @MethodSource("sessionsTypes_merchantInitiatedSession") void shouldRequestAndGetCardSession_merchantInitiatedSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData merchantInitiatedSession = merchantInitiatedSession(); @@ -102,7 +101,7 @@ void shouldRequestAndGetCardSession_merchantInitiatedSession(final Category cate @ParameterizedTest @MethodSource("sessionsTypes_browserSession") void shouldRequestAndGetCardSessionSync_browserSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData browserSession = browserSession(); @@ -123,7 +122,7 @@ void shouldRequestAndGetCardSessionSync_browserSession(final Category category, @ParameterizedTest @MethodSource("sessionsTypes_appSession") void shouldRequestAndGetCardSessionSync_appSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData appSession = appSession(); @@ -142,7 +141,7 @@ void shouldRequestAndGetCardSessionSync_appSession(final Category category, @ParameterizedTest @MethodSource("sessionsTypes_merchantInitiatedSession") void shouldRequestAndGetCardSessionSync_merchantInitiatedSession(final Category category, - final ChallengeIndicator challengeIndicator, + final SessionChallengeIndicator challengeIndicator, final TransactionType transactionType) { final ChannelData merchantInitiatedSession = merchantInitiatedSession(); diff --git a/src/test/java/com/checkout/sessions/SessionChallengeIndicatorTest.java b/src/test/java/com/checkout/sessions/SessionChallengeIndicatorTest.java new file mode 100644 index 00000000..8f97a97e --- /dev/null +++ b/src/test/java/com/checkout/sessions/SessionChallengeIndicatorTest.java @@ -0,0 +1,119 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SessionChallengeIndicatorTest { + + private final Serializer serializer = new GsonSerializer(); + + private static Stream sessionChallengeIndicators() { + return Stream.of( + Arguments.of(SessionChallengeIndicator.NO_PREFERENCE, "\"no_preference\""), + Arguments.of(SessionChallengeIndicator.NO_CHALLENGE_REQUESTED, "\"no_challenge_requested\""), + Arguments.of(SessionChallengeIndicator.CHALLENGE_REQUESTED, "\"challenge_requested\""), + Arguments.of(SessionChallengeIndicator.CHALLENGE_REQUESTED_MANDATE, "\"challenge_requested_mandate\""), + Arguments.of(SessionChallengeIndicator.LOW_VALUE, "\"low_value\""), + Arguments.of(SessionChallengeIndicator.TRUSTED_LISTING, "\"trusted_listing\""), + Arguments.of(SessionChallengeIndicator.TRUSTED_LISTING_PROMPT, "\"trusted_listing_prompt\""), + Arguments.of(SessionChallengeIndicator.TRANSACTION_RISK_ASSESSMENT, "\"transaction_risk_assessment\""), + Arguments.of(SessionChallengeIndicator.DATA_SHARE, "\"data_share\"") + ); + } + + @ParameterizedTest + @MethodSource("sessionChallengeIndicators") + void shouldSerializeSessionChallengeIndicatorToSnakeCase(final SessionChallengeIndicator value, + final String expectedJson) { + assertEquals(expectedJson, serializer.toJson(value)); + } + + @ParameterizedTest + @MethodSource("sessionChallengeIndicators") + void shouldDeserializeSessionChallengeIndicatorFromSnakeCase(final SessionChallengeIndicator expected, + final String json) { + assertEquals(expected, serializer.fromJson(json, SessionChallengeIndicator.class)); + } + + @Test + void shouldRoundTripAllValues() { + for (final SessionChallengeIndicator value : SessionChallengeIndicator.values()) { + final String json = serializer.toJson(value); + assertEquals(value, serializer.fromJson(json, SessionChallengeIndicator.class)); + } + } + + @Test + void shouldExposeTheNineValuesDefinedBySessionRequest() { + assertEquals(9, SessionChallengeIndicator.values().length); + } + + /** + * {@link SessionRequest} is serialize-only: its {@code source} is an abstract + * {@link com.checkout.sessions.source.SessionSource} with no registered Gson adapter, so the + * request cannot be deserialized. Assert on the emitted JSON instead. + */ + @ParameterizedTest + @MethodSource("sessionChallengeIndicators") + void shouldSerializeEveryValueOnSessionRequest(final SessionChallengeIndicator value, + final String expectedJson) { + final SessionRequest request = SessionRequest.builder() + .challengeIndicator(value) + .build(); + + final String json = serializer.toJson(request); + + assertNotNull(json); + assertTrue(json.contains("\"challenge_indicator\":" + expectedJson), + "expected challenge_indicator " + expectedJson + " in " + json); + } + + @Test + void shouldDefaultSessionRequestChallengeIndicatorToNoPreference() { + final SessionRequest request = SessionRequest.builder().build(); + + assertEquals(SessionChallengeIndicator.NO_PREFERENCE, request.getChallengeIndicator()); + assertTrue(serializer.toJson(request).contains("\"challenge_indicator\":\"no_preference\"")); + } + + /** + * The API Reference specifies only the four base values on the session responses, but the + * request accepts all nine. This guards against a deserialization failure if the API echoes an + * exemption value back. + */ + @ParameterizedTest + @MethodSource("sessionChallengeIndicators") + void shouldDeserializeEveryValueOnGetSessionResponse(final SessionChallengeIndicator expected, + final String json) { + final String responseJson = "{\"challenge_indicator\":" + json + "}"; + + final GetSessionResponse response = serializer.fromJson(responseJson, GetSessionResponse.class); + + assertNotNull(response); + assertEquals(expected, response.getChallengeIndicator()); + } + + @ParameterizedTest + @MethodSource("sessionChallengeIndicators") + void shouldDeserializeEveryValueOnCreateSessionAcceptedResponse(final SessionChallengeIndicator expected, + final String json) { + final String responseJson = "{\"challenge_indicator\":" + json + "}"; + + final CreateSessionAcceptedResponse response = + serializer.fromJson(responseJson, CreateSessionAcceptedResponse.class); + + assertNotNull(response); + assertEquals(expected, response.getChallengeIndicator()); + } + +} diff --git a/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java new file mode 100644 index 00000000..94b5074e --- /dev/null +++ b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java @@ -0,0 +1,194 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import com.checkout.common.CountryCode; +import com.checkout.common.Currency; +import com.checkout.common.Phone; +import com.checkout.sessions.channel.BrowserSession; +import com.checkout.sessions.channel.ThreeDsMethodCompletion; +import com.checkout.sessions.completion.NonHostedCompletionInfo; +import com.checkout.sessions.source.SessionCardSource; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Serialization coverage for {@link SessionRequest}. The class is serialize-only: {@code source}, + * {@code completion} and {@code channel_data} are abstract types with no registered Gson adapter, so + * a deserialization roundtrip is not possible. Every property is therefore asserted on the emitted + * JSON. + */ +class SessionRequestSerializationTest { + + private final Serializer serializer = new GsonSerializer(); + + private static SessionRequest fullyPopulated() { + return SessionRequest.builder() + .source(SessionCardSource.builder() + .email("bruce@wayne-enterprises.com") + .expiryMonth(1) + .expiryYear(2030) + .number("4485040371536584") + .name("Bruce Wayne") + .billingAddress(SessionAddress.builderSessionAddress() + .addressLine1("Checkout.com") + .city("London") + .country(CountryCode.GB) + .zip("W1T 4TJ") + .build()) + .homePhone(Phone.builder().number("0204567895").countryCode("234").build()) + .build()) + .amount(6540L) + .currency(Currency.USD) + .processingChannelId("pc_q4dbxom5jbgudnjzjpz7iw4d0u") + .marketplace(SessionMarketplaceData.builder() + .subEntityId("ent_ocw5i74vowfg2edpy66izhts2u") + .build()) + .authenticationType(AuthenticationType.REGULAR) + .authenticationCategory(Category.PAYMENT) + .cardholderAccountInfo(CardholderAccountInfo.builder() + .purchaseCount(10L) + .addCardAttempts(5L) + .transactionsToday(3L) + .build()) + .challengeIndicator(SessionChallengeIndicator.TRUSTED_LISTING_PROMPT) + .billingDescriptor(SessionsBillingDescriptor.builder().name("SUPERHEROES.COM").build()) + .reference("ORD-5023-4E89") + .merchantRiskInfo(MerchantRiskInfo.builder() + .deliveryEmail("bruce@wayne-enterprises.com") + .isPreorder(false) + .isReorder(false) + .build()) + .priorTransactionReference("prior-txn-ref") + .transactionType(TransactionType.GOODS_SERVICE) + .shippingAddress(SessionAddress.builderSessionAddress() + .addressLine1("Checkout.com") + .addressLine2("ABC building") + .city("London") + .country(CountryCode.GB) + .state("ENG") + .zip("W1T 4TJ") + .build()) + .shippingAddressMatchesBilling(Boolean.TRUE) + .completion(NonHostedCompletionInfo.builder() + .callbackUrl("https://merchant.com/callback") + .build()) + .channelData(BrowserSession.builder() + .acceptHeader("Accept: *.*, q=0.1") + .javaEnabled(true) + .javascriptEnabled(true) + .language("FR-fr") + .colorDepth("16") + .screenWidth("1920") + .screenHeight("1080") + .timezone("60") + .userAgent("Mozilla/5.0") + .threeDsMethodCompletion(ThreeDsMethodCompletion.Y) + .ipAddress("1.12.123.255") + .build()) + .recurring(Recurring.builder().daysBetweenPayments(30L).expiry("99991231").build()) + .installment(Installment.builder() + .numberOfPayments(3L) + .daysBetweenPayments(30L) + .expiry("99991231") + .build()) + .optimization(Optimization.builder().optimized(true).framework("acceptance_rates").build()) + .initialTransaction(InitialTransaction.builder() + .acsTransactionId("acs-txn-id") + .authenticationMethod("frictionless") + .authenticationTimestamp("2026-08-03T10:11:12Z") + .authenticationData("auth-data") + .initialSessionId("sid_y3oqhf46pyzuxjbcn2giaqnb44") + .build()) + .deviceInformation(DeviceInformation.builder() + .deviceId("device-id") + .deviceSessionId("device-session-id") + .build()) + .build(); + } + + @Test + void shouldSerializeWithRequiredFieldsOnly() { + final SessionRequest request = SessionRequest.builder() + .source(SessionCardSource.builder().number("4485040371536584").build()) + .currency(Currency.USD) + .completion(NonHostedCompletionInfo.builder() + .callbackUrl("https://merchant.com/callback") + .build()) + .build(); + + assertDoesNotThrow(() -> serializer.toJson(request)); + } + + @Test + void shouldSerializeEveryProperty() { + final String json = serializer.toJson(fullyPopulated()); + + assertNotNull(json); + + final String[] expectedKeys = { + "source", + "amount", + "currency", + "processing_channel_id", + "marketplace", + "authentication_type", + "authentication_category", + "account_info", + "challenge_indicator", + "billing_descriptor", + "reference", + "merchant_risk_info", + "prior_transaction_reference", + "transaction_type", + "shipping_address", + "shipping_address_matches_billing", + "completion", + "channel_data", + "recurring", + "installment", + "optimization", + "initial_transaction", + "device_information" + }; + + for (final String key : expectedKeys) { + assertTrue(json.contains("\"" + key + "\""), "missing property " + key + " in " + json); + } + } + + @Test + void shouldSerializeScalarValuesAndEnumsAsSnakeCase() { + final String json = serializer.toJson(fullyPopulated()); + + assertTrue(json.contains("\"amount\":6540"), json); + assertTrue(json.contains("\"currency\":\"USD\""), json); + assertTrue(json.contains("\"processing_channel_id\":\"pc_q4dbxom5jbgudnjzjpz7iw4d0u\""), json); + assertTrue(json.contains("\"authentication_type\":\"regular\""), json); + assertTrue(json.contains("\"authentication_category\":\"payment\""), json); + assertTrue(json.contains("\"challenge_indicator\":\"trusted_listing_prompt\""), json); + assertTrue(json.contains("\"reference\":\"ORD-5023-4E89\""), json); + assertTrue(json.contains("\"prior_transaction_reference\":\"prior-txn-ref\""), json); + assertTrue(json.contains("\"transaction_type\":\"goods_service\""), json); + assertTrue(json.contains("\"shipping_address_matches_billing\":true"), json); + } + + @Test + void shouldSerializeNestedObjectContents() { + final String json = serializer.toJson(fullyPopulated()); + + assertTrue(json.contains("\"sub_entity_id\":\"ent_ocw5i74vowfg2edpy66izhts2u\""), json); + assertTrue(json.contains("\"purchase_count\":10"), json); + assertTrue(json.contains("\"name\":\"SUPERHEROES.COM\""), json); + assertTrue(json.contains("\"delivery_email\":\"bruce@wayne-enterprises.com\""), json); + assertTrue(json.contains("\"callback_url\":\"https://merchant.com/callback\""), json); + assertTrue(json.contains("\"number_of_payments\":3"), json); + assertTrue(json.contains("\"framework\":\"acceptance_rates\""), json); + assertTrue(json.contains("\"acs_transaction_id\":\"acs-txn-id\""), json); + assertTrue(json.contains("\"device_session_id\":\"device-session-id\""), json); + } + +} From d3535acf0db6c86bfaa02f72c029eaa9132f161b Mon Sep 17 00:00:00 2001 From: david ruiz Date: Mon, 3 Aug 2026 13:06:18 +0200 Subject: [PATCH 2/4] Some fixes and more tests --- .../CreateSessionAcceptedResponse.java | 2 - .../checkout/sessions/GetSessionResponse.java | 2 - .../com/checkout/sessions/SessionScheme.java | 46 ++++ .../SessionsCardMetadataResponse.java | 34 ++- .../checkout/sessions/TransactionType.java | 36 ++- ...sionAcceptedResponseSerializationTest.java | 163 +++++++++++ .../GetSessionResponseSerializationTest.java | 254 ++++++++++++++++++ .../checkout/sessions/SessionSchemeTest.java | 81 ++++++ .../sessions/TransactionTypeTest.java | 84 ++++++ 9 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java create mode 100644 src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java create mode 100644 src/test/java/com/checkout/sessions/SessionSchemeTest.java create mode 100644 src/test/java/com/checkout/sessions/TransactionTypeTest.java diff --git a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java index 97aa1192..6aec2253 100644 --- a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java +++ b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java @@ -59,8 +59,6 @@ public final class CreateSessionAcceptedResponse extends Resource { /** * The three-letter ISO currency code. * [Required] - * min 3 characters - * max 3 characters */ private Currency currency; diff --git a/src/main/java/com/checkout/sessions/GetSessionResponse.java b/src/main/java/com/checkout/sessions/GetSessionResponse.java index b56e0d10..3d3364c4 100644 --- a/src/main/java/com/checkout/sessions/GetSessionResponse.java +++ b/src/main/java/com/checkout/sessions/GetSessionResponse.java @@ -61,8 +61,6 @@ public class GetSessionResponse extends Resource { /** * The three-letter ISO currency code. * [Required] - * min 3 characters - * max 3 characters */ private Currency currency; diff --git a/src/main/java/com/checkout/sessions/SessionScheme.java b/src/main/java/com/checkout/sessions/SessionScheme.java index 2c6469c0..5dc575f5 100644 --- a/src/main/java/com/checkout/sessions/SessionScheme.java +++ b/src/main/java/com/checkout/sessions/SessionScheme.java @@ -2,18 +2,64 @@ import com.google.gson.annotations.SerializedName; +/** + * Indicates the scheme this authentication is carried out against. + *

+ * Used by {@link GetSessionResponse#getScheme()}, + * {@link CreateSessionAcceptedResponse#getScheme()}, + * {@link GetSessionResponseAfterChannelDataSupplied#getScheme()}, + * {@link SchemeInfo#getName()} and + * {@link com.checkout.sessions.source.SessionSource#getScheme()}. + *

+ * [Required] on the session responses + */ public enum SessionScheme { + /** + * American Express. + */ @SerializedName("amex") AMEX, + + /** + * Cartes Bancaires. + */ @SerializedName("cartes_bancaires") CARTES_BANCAIRES, + + /** + * Diners Club. + */ @SerializedName("diners") DINERS, + + /** + * Discover. + */ + @SerializedName("discover") + DISCOVER, + + /** + * JCB. + */ @SerializedName("jcb") JCB, + + /** + * Mastercard. + */ @SerializedName("mastercard") MASTERCARD, + + /** + * Unified Payments Interface (UPI). + */ + @SerializedName("upi") + UPI, + + /** + * Visa. + */ @SerializedName("visa") VISA diff --git a/src/main/java/com/checkout/sessions/SessionsCardMetadataResponse.java b/src/main/java/com/checkout/sessions/SessionsCardMetadataResponse.java index fd2b62c7..204aa49d 100644 --- a/src/main/java/com/checkout/sessions/SessionsCardMetadataResponse.java +++ b/src/main/java/com/checkout/sessions/SessionsCardMetadataResponse.java @@ -3,19 +3,51 @@ import com.checkout.common.CardCategory; import com.checkout.common.CardType; import com.checkout.common.CountryCode; - +import lombok.Data; + +/** + * Additional details for this card. + *

+ * Returned as {@link CardInfo#getMetadata()} on the session responses. + */ +@Data public final class SessionsCardMetadataResponse { + /** + * The card type. + * [Optional] + */ private CardType cardType; + /** + * The card category. + * [Optional] + */ private CardCategory cardCategory; + /** + * The card issuer's name. + * [Optional] + */ private String issuerName; + /** + * The two letter alpha country code of the card issuer. + * [Optional] + * ^[A-Z]{2} + */ private CountryCode issuerCountry; + /** + * The issuer/card scheme product identifier. + * [Optional] + */ private String productId; + /** + * The issuer/card scheme product type. + * [Optional] + */ private String productType; } diff --git a/src/main/java/com/checkout/sessions/TransactionType.java b/src/main/java/com/checkout/sessions/TransactionType.java index aeca2127..1802ea19 100644 --- a/src/main/java/com/checkout/sessions/TransactionType.java +++ b/src/main/java/com/checkout/sessions/TransactionType.java @@ -2,17 +2,49 @@ import com.google.gson.annotations.SerializedName; +/** + * Identifies the type of transaction being authenticated. + *

+ * Used by {@link SessionRequest#getTransactionType()} and + * {@link GetSessionResponse#getTransactionType()}. + *

+ * [Optional] + *

+ * Default: {@link #GOODS_SERVICE} + *

+ * max 50 characters + */ public enum TransactionType { + /** + * A transaction that funds an account. + */ @SerializedName("account_funding") ACCOUNT_FUNDING, + + /** + * A transaction that accepts a check. + */ @SerializedName("check_acceptance") CHECK_ACCEPTANCE, + + /** + * A transaction for goods or a service. This is the default. + */ @SerializedName("goods_service") GOODS_SERVICE, + + /** + * A transaction that activates or loads a prepaid card. + */ @SerializedName("prepaid_activation_and_load") PREPAID_ACTIVATION_AND_LOAD, - @SerializedName("quashi_card_transaction") - QUASHI_CARD_TRANSACTION, + + /** + * A quasi-cash transaction, for example the purchase of casino chips, money orders or + * traveller's cheques. + */ + @SerializedName("quasi_card_transaction") + QUASI_CARD_TRANSACTION, } diff --git a/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java b/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java new file mode 100644 index 00000000..811bce9a --- /dev/null +++ b/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java @@ -0,0 +1,163 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import com.checkout.common.Currency; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Full-property deserialization coverage for {@link CreateSessionAcceptedResponse}, the 202 schema + * returned by {@code POST /sessions}. + *

+ * Every one of the class's 22 properties is populated and asserted, plus the {@code _links} it + * inherits from {@link com.checkout.common.Resource}. + */ +class CreateSessionAcceptedResponseSerializationTest { + + private final Serializer serializer = new GsonSerializer(); + + private CreateSessionAcceptedResponse response; + + private static String fullPayload() { + return "{" + + "\"id\":\"sid_y3oqhf46pyzuxjbcn2giaqnb44\"," + + "\"session_secret\":\"sek_Dal7UyiH8rIFXA4PfgiIk2jUyQkVDeEWgVBEL4TsRTE=\"," + + "\"transaction_id\":\"9aea641d-0549-4222-9ca9-d90b43a4f38c\"," + + "\"scheme\":\"mastercard\"," + + "\"amount\":6540," + + "\"currency\":\"USD\"," + + "\"authentication_type\":\"regular\"," + + "\"authentication_category\":\"payment\"," + + "\"status\":\"pending\"," + + "\"status_reason\":\"ares_status\"," + + "\"next_actions\":[\"collect_channel_data\"]," + + "\"protocol_version\":\"2.2.0\"," + + "\"account_info\":{\"purchase_count\":10,\"add_card_attempts\":5,\"transactions_today\":3}," + + "\"merchant_risk_info\":{\"delivery_email\":\"bruce@wayne-enterprises.com\"," + + "\"is_preorder\":false,\"is_reorder\":false}," + + "\"reference\":\"ORD-5023-4E89\"," + + "\"card\":{\"instrument_id\":\"src_w4jelhppmfiufdnatndh3wtsfq\",\"fingerprint\":\"fp-1\"}," + + "\"recurring\":{\"days_between_payments\":30,\"expiry\":\"99991231\"}," + + "\"installment\":{\"number_of_payments\":3,\"days_between_payments\":30,\"expiry\":\"99991231\"}," + + "\"initial_transaction\":{\"acs_transaction_id\":\"acs-txn-id\"," + + "\"authentication_method\":\"frictionless\"," + + "\"authentication_timestamp\":\"2026-08-03T10:11:12Z\"," + + "\"authentication_data\":\"auth-data\"," + + "\"initial_session_id\":\"sid_y3oqhf46pyzuxjbcn2giaqnb44\"}," + + "\"authentication_date\":\"2026-08-03T10:11:12Z\"," + + "\"challenge_indicator\":\"transaction_risk_assessment\"," + + "\"optimization\":{\"optimized\":true,\"framework\":\"acceptance_rates\"," + + "\"optimized_properties\":[{\"field\":\"amount\",\"original_value\":\"1\",\"optimized_value\":\"2\"}]}," + + "\"_links\":{\"self\":{\"href\":\"https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44\"}}" + + "}"; + } + + @BeforeEach + void setUp() { + response = serializer.fromJson(fullPayload(), CreateSessionAcceptedResponse.class); + assertNotNull(response); + } + + @Test + void shouldDeserializeIdentifiersAndAmounts() { + assertEquals("sid_y3oqhf46pyzuxjbcn2giaqnb44", response.getId()); + assertEquals("sek_Dal7UyiH8rIFXA4PfgiIk2jUyQkVDeEWgVBEL4TsRTE=", response.getSessionSecret()); + assertEquals("9aea641d-0549-4222-9ca9-d90b43a4f38c", response.getTransactionId()); + assertEquals(6540L, response.getAmount()); + assertEquals(Currency.USD, response.getCurrency()); + assertEquals("2.2.0", response.getProtocolVersion()); + assertEquals("ORD-5023-4E89", response.getReference()); + } + + @Test + void shouldDeserializeEveryEnumTypedProperty() { + assertEquals(SessionScheme.MASTERCARD, response.getScheme()); + assertEquals(AuthenticationType.REGULAR, response.getAuthenticationType()); + assertEquals(Category.PAYMENT, response.getAuthenticationCategory()); + assertEquals(SessionStatus.PENDING, response.getStatus()); + assertEquals(StatusReason.ARES_STATUS, response.getStatusReason()); + assertEquals(SessionChallengeIndicator.TRANSACTION_RISK_ASSESSMENT, response.getChallengeIndicator()); + assertEquals(Arrays.asList(NextAction.COLLECT_CHANNEL_DATA), response.getNextActions()); + } + + @Test + void shouldDeserializeAuthenticationDateAsInstant() { + assertEquals(Instant.parse("2026-08-03T10:11:12Z"), response.getAuthenticationDate()); + } + + /** + * This class names the field {@code accountInfo} and relies on the global + * {@code LOWER_CASE_WITH_UNDERSCORES} policy, whereas {@link GetSessionResponse} names it + * {@code cardholderAccountInfo} with an explicit {@code @SerializedName}. Both must map the same + * {@code account_info} wire key. + */ + @Test + void shouldDeserializeAccountInfoUnderTheAccountInfoKey() { + assertNotNull(response.getAccountInfo()); + assertEquals(10L, response.getAccountInfo().getPurchaseCount()); + assertEquals(5L, response.getAccountInfo().getAddCardAttempts()); + assertEquals(3L, response.getAccountInfo().getTransactionsToday()); + } + + @Test + void shouldDeserializeNestedObjects() { + assertNotNull(response.getMerchantRiskInfo()); + assertEquals("bruce@wayne-enterprises.com", response.getMerchantRiskInfo().getDeliveryEmail()); + + assertNotNull(response.getCard()); + assertEquals("src_w4jelhppmfiufdnatndh3wtsfq", response.getCard().getInstrumentId()); + assertEquals("fp-1", response.getCard().getFingerprint()); + + assertNotNull(response.getRecurring()); + assertEquals(30L, response.getRecurring().getDaysBetweenPayments()); + + assertNotNull(response.getInstallment()); + assertEquals(3L, response.getInstallment().getNumberOfPayments()); + + assertNotNull(response.getInitialTransaction()); + assertEquals("acs-txn-id", response.getInitialTransaction().getAcsTransactionId()); + + assertNotNull(response.getOptimization()); + assertEquals(Boolean.TRUE, response.getOptimization().getOptimized()); + assertEquals("acceptance_rates", response.getOptimization().getFramework()); + assertNotNull(response.getOptimization().getOptimizedProperties()); + assertEquals(1, response.getOptimization().getOptimizedProperties().size()); + } + + @Test + void shouldDeserializeInheritedLinks() { + assertNotNull(response.getSelfLink()); + assertEquals("https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44", + response.getSelfLink().getHref()); + } + + /** + * Guards against a property being silently dropped: every field declared on the class must be + * non-null after deserializing a payload that populates all of them. + */ + @Test + void shouldLeaveNoDeclaredPropertyNull() { + final java.lang.reflect.Field[] fields = CreateSessionAcceptedResponse.class.getDeclaredFields(); + for (final java.lang.reflect.Field field : fields) { + if (field.isSynthetic()) { + continue; + } + field.setAccessible(true); + try { + assertNotNull(field.get(response), "property was not deserialized: " + field.getName()); + } catch (final IllegalAccessException e) { + throw new AssertionError(e); + } + } + assertTrue(fields.length >= 22, "expected at least 22 declared properties, found " + fields.length); + } + +} diff --git a/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java b/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java new file mode 100644 index 00000000..5118c79f --- /dev/null +++ b/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java @@ -0,0 +1,254 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import com.checkout.common.CardCategory; +import com.checkout.common.CardType; +import com.checkout.common.CountryCode; +import com.checkout.common.Currency; +import com.checkout.common.Exemption; +import com.checkout.common.ThreeDSFlowType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Full-property deserialization coverage for {@link GetSessionResponse}, the schema returned by + * {@code GET /sessions/{id}} and {@code PUT /sessions/{id}/collect-data}. + *

+ * Every one of the class's 40 properties is populated and asserted, plus the {@code _links} it + * inherits from {@link com.checkout.common.Resource}. Nested objects are covered transitively. + */ +class GetSessionResponseSerializationTest { + + private final Serializer serializer = new GsonSerializer(); + + private GetSessionResponse response; + + private static String fullPayload() { + return "{" + + "\"id\":\"sid_y3oqhf46pyzuxjbcn2giaqnb44\"," + + "\"session_secret\":\"sek_Dal7UyiH8rIFXA4PfgiIk2jUyQkVDeEWgVBEL4TsRTE=\"," + + "\"transaction_id\":\"9aea641d-0549-4222-9ca9-d90b43a4f38c\"," + + "\"scheme\":\"visa\"," + + "\"amount\":6540," + + "\"currency\":\"USD\"," + + "\"completed\":true," + + "\"challenged\":true," + + "\"authentication_type\":\"regular\"," + + "\"authentication_category\":\"payment\"," + + "\"certificates\":{\"ds_public\":\"ds-public-key\",\"ca_public\":\"ca-public-key\"}," + + "\"status\":\"challenged\"," + + "\"status_reason\":\"ares_status\"," + + "\"approved\":true," + + "\"protocol_version\":\"2.2.0\"," + + "\"account_info\":{\"purchase_count\":10,\"add_card_attempts\":5,\"transactions_today\":3}," + + "\"merchant_risk_info\":{\"delivery_email\":\"bruce@wayne-enterprises.com\"," + + "\"is_preorder\":false,\"is_reorder\":false}," + + "\"reference\":\"ORD-5023-4E89\"," + + "\"transaction_type\":\"goods_service\"," + + "\"next_actions\":[\"collect_channel_data\",\"challenge_cardholder\"]," + + "\"ds\":{\"ds_id\":\"ds-id\",\"reference_number\":\"ds-ref\",\"transaction_id\":\"ds-txn\"}," + + "\"acs\":{\"reference_number\":\"acs-ref\",\"transaction_id\":\"acs-txn\"," + + "\"operator_id\":\"acs-operator\",\"url\":\"https://acs.example.com/challenge\"," + + "\"signed_content\":\"signed-content\",\"challenge_mandated\":true," + + "\"authentication_type\":\"static\",\"challenge_cancel_reason\":\"cardholder_cancel\"," + + "\"interface\":\"html\",\"ui_template\":\"single_select\"}," + + "\"response_code\":\"Y\"," + + "\"response_status_reason\":\"01\"," + + "\"pareq\":\"pareq-value\"," + + "\"cryptogram\":\"MTIzNDU2Nzg5MDA5ODc2NTQzMjE=\"," + + "\"eci\":\"05\"," + + "\"xid\":\"XSUErNftqkiTdlkpSk8p32GWOFA\"," + + "\"cardholder_info\":\"Card declined. Please contact your issuing bank.\"," + + "\"card\":{\"instrument_id\":\"src_w4jelhppmfiufdnatndh3wtsfq\",\"fingerprint\":\"fp-1\"," + + "\"metadata\":{\"card_type\":\"CREDIT\",\"card_category\":\"CONSUMER\"," + + "\"issuer_name\":\"Checkout\",\"issuer_country\":\"GB\"," + + "\"product_id\":\"MDS\",\"product_type\":\"Debit MasterCard Card\"}}," + + "\"recurring\":{\"days_between_payments\":30,\"expiry\":\"99991231\"}," + + "\"installment\":{\"number_of_payments\":3,\"days_between_payments\":30,\"expiry\":\"99991231\"}," + + "\"initial_transaction\":{\"acs_transaction_id\":\"acs-txn-id\"," + + "\"authentication_method\":\"frictionless\"," + + "\"authentication_timestamp\":\"2026-08-03T10:11:12Z\"," + + "\"authentication_data\":\"auth-data\"," + + "\"initial_session_id\":\"sid_y3oqhf46pyzuxjbcn2giaqnb44\"}," + + "\"customer_ip\":\"192.168.1.1\"," + + "\"authentication_date\":\"2026-08-03T10:11:12Z\"," + + "\"exemption\":{\"requested\":\"none\",\"applied\":\"low_value\",\"code\":\"cb-code\"}," + + "\"flow_type\":\"challenged\"," + + "\"challenge_indicator\":\"trusted_listing\"," + + "\"optimization\":{\"optimized\":true,\"framework\":\"acceptance_rates\"," + + "\"optimized_properties\":[{\"field\":\"amount\",\"original_value\":\"1\",\"optimized_value\":\"2\"}]}," + + "\"scheme_info\":{\"name\":\"visa\",\"score\":\"0.5\",\"avalgo\":\"1\"}," + + "\"_links\":{\"self\":{\"href\":\"https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44\"}}" + + "}"; + } + + @BeforeEach + void setUp() { + response = serializer.fromJson(fullPayload(), GetSessionResponse.class); + assertNotNull(response); + } + + @Test + void shouldDeserializeIdentifiersAndAmounts() { + assertEquals("sid_y3oqhf46pyzuxjbcn2giaqnb44", response.getId()); + assertEquals("sek_Dal7UyiH8rIFXA4PfgiIk2jUyQkVDeEWgVBEL4TsRTE=", response.getSessionSecret()); + assertEquals("9aea641d-0549-4222-9ca9-d90b43a4f38c", response.getTransactionId()); + assertEquals(6540L, response.getAmount()); + assertEquals(Currency.USD, response.getCurrency()); + assertEquals("2.2.0", response.getProtocolVersion()); + assertEquals("ORD-5023-4E89", response.getReference()); + } + + @Test + void shouldDeserializeEveryEnumTypedProperty() { + assertEquals(SessionScheme.VISA, response.getScheme()); + assertEquals(AuthenticationType.REGULAR, response.getAuthenticationType()); + assertEquals(Category.PAYMENT, response.getAuthenticationCategory()); + assertEquals(SessionStatus.CHALLENGED, response.getStatus()); + assertEquals(StatusReason.ARES_STATUS, response.getStatusReason()); + assertEquals(TransactionType.GOODS_SERVICE, response.getTransactionType()); + assertEquals(ResponseCode.Y, response.getResponseCode()); + assertEquals(ThreeDSFlowType.CHALLENGED, response.getFlowType()); + assertEquals(SessionChallengeIndicator.TRUSTED_LISTING, response.getChallengeIndicator()); + assertEquals(Arrays.asList(NextAction.COLLECT_CHANNEL_DATA, NextAction.CHALLENGE_CARDHOLDER), + response.getNextActions()); + } + + @Test + void shouldDeserializeBooleanFlags() { + assertEquals(Boolean.TRUE, response.getCompleted()); + assertEquals(Boolean.TRUE, response.getChallenged()); + assertEquals(Boolean.TRUE, response.getApproved()); + } + + @Test + void shouldDeserializeAuthenticationResultFields() { + assertEquals("01", response.getResponseStatusReason()); + assertEquals("pareq-value", response.getPareq()); + assertEquals("MTIzNDU2Nzg5MDA5ODc2NTQzMjE=", response.getCryptogram()); + assertEquals("05", response.getEci()); + assertEquals("XSUErNftqkiTdlkpSk8p32GWOFA", response.getXid()); + assertEquals("Card declined. Please contact your issuing bank.", response.getCardholderInfo()); + assertEquals("192.168.1.1", response.getCustomerIp()); + } + + @Test + void shouldDeserializeAuthenticationDateAsInstant() { + assertEquals(Instant.parse("2026-08-03T10:11:12Z"), response.getAuthenticationDate()); + } + + @Test + void shouldDeserializeAccountInfoUnderTheAccountInfoKey() { + assertNotNull(response.getCardholderAccountInfo()); + assertEquals(10L, response.getCardholderAccountInfo().getPurchaseCount()); + assertEquals(5L, response.getCardholderAccountInfo().getAddCardAttempts()); + assertEquals(3L, response.getCardholderAccountInfo().getTransactionsToday()); + } + + @Test + void shouldDeserializeNestedObjects() { + assertNotNull(response.getCertificates()); + assertEquals("ds-public-key", response.getCertificates().getDsPublic()); + assertEquals("ca-public-key", response.getCertificates().getCaPublic()); + + assertNotNull(response.getMerchantRiskInfo()); + assertEquals("bruce@wayne-enterprises.com", response.getMerchantRiskInfo().getDeliveryEmail()); + + assertNotNull(response.getDs()); + assertEquals("ds-id", response.getDs().getDsId()); + + assertNotNull(response.getCard()); + assertEquals("src_w4jelhppmfiufdnatndh3wtsfq", response.getCard().getInstrumentId()); + assertEquals("fp-1", response.getCard().getFingerprint()); + + assertNotNull(response.getRecurring()); + assertEquals(30L, response.getRecurring().getDaysBetweenPayments()); + + assertNotNull(response.getInstallment()); + assertEquals(3L, response.getInstallment().getNumberOfPayments()); + + assertNotNull(response.getInitialTransaction()); + assertEquals("acs-txn-id", response.getInitialTransaction().getAcsTransactionId()); + + assertNotNull(response.getSchemeInfo()); + assertEquals(SessionScheme.VISA, response.getSchemeInfo().getName()); + + assertNotNull(response.getOptimization()); + assertEquals(Boolean.TRUE, response.getOptimization().getOptimized()); + assertNotNull(response.getOptimization().getOptimizedProperties()); + assertEquals(1, response.getOptimization().getOptimizedProperties().size()); + } + + @Test + void shouldDeserializeAcsWithReservedInterfaceKey() { + assertNotNull(response.getAcs()); + assertEquals("acs-ref", response.getAcs().getReferenceNumber()); + assertEquals("https://acs.example.com/challenge", response.getAcs().getUrl()); + assertEquals(Boolean.TRUE, response.getAcs().getChallengeMandated()); + assertEquals(ChallengeCancelReason.CARDHOLDER_CANCEL, response.getAcs().getChallengeCancelReason()); + assertEquals(SessionInterface.HTML, response.getAcs().getSessionInterface()); + assertEquals(UIElements.SINGLE_SELECT, response.getAcs().getUiTemplate()); + } + + /** + * Covers all six properties of {@link SessionsCardMetadataResponse}. The spec sends the card + * type and category in upper case, which the shared enums accept via {@code alternate} values. + */ + @Test + void shouldDeserializeEveryCardMetadataProperty() { + final SessionsCardMetadataResponse metadata = response.getCard().getMetadata(); + + assertNotNull(metadata); + assertEquals(CardType.CREDIT, metadata.getCardType()); + assertEquals(CardCategory.CONSUMER, metadata.getCardCategory()); + assertEquals("Checkout", metadata.getIssuerName()); + assertEquals(CountryCode.GB, metadata.getIssuerCountry()); + assertEquals("MDS", metadata.getProductId()); + assertEquals("Debit MasterCard Card", metadata.getProductType()); + } + + @Test + void shouldDeserializeExemption() { + assertNotNull(response.getExemption()); + assertEquals("none", response.getExemption().getRequested()); + assertEquals(Exemption.LOW_VALUE, response.getExemption().getApplied()); + assertEquals("cb-code", response.getExemption().getCode()); + } + + @Test + void shouldDeserializeInheritedLinks() { + assertNotNull(response.getSelfLink()); + assertEquals("https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44", + response.getSelfLink().getHref()); + } + + /** + * Guards against a property being silently dropped: every field declared on the class must be + * non-null after deserializing a payload that populates all of them. + */ + @Test + void shouldLeaveNoDeclaredPropertyNull() { + final java.lang.reflect.Field[] fields = GetSessionResponse.class.getDeclaredFields(); + for (final java.lang.reflect.Field field : fields) { + if (field.isSynthetic()) { + continue; + } + field.setAccessible(true); + try { + assertNotNull(field.get(response), "property was not deserialized: " + field.getName()); + } catch (final IllegalAccessException e) { + throw new AssertionError(e); + } + } + assertTrue(fields.length >= 40, "expected at least 40 declared properties, found " + fields.length); + } + +} diff --git a/src/test/java/com/checkout/sessions/SessionSchemeTest.java b/src/test/java/com/checkout/sessions/SessionSchemeTest.java new file mode 100644 index 00000000..cd897ee0 --- /dev/null +++ b/src/test/java/com/checkout/sessions/SessionSchemeTest.java @@ -0,0 +1,81 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class SessionSchemeTest { + + private final Serializer serializer = new GsonSerializer(); + + private static Stream sessionSchemes() { + return Stream.of( + Arguments.of(SessionScheme.VISA, "\"visa\""), + Arguments.of(SessionScheme.MASTERCARD, "\"mastercard\""), + Arguments.of(SessionScheme.JCB, "\"jcb\""), + Arguments.of(SessionScheme.AMEX, "\"amex\""), + Arguments.of(SessionScheme.DINERS, "\"diners\""), + Arguments.of(SessionScheme.CARTES_BANCAIRES, "\"cartes_bancaires\""), + Arguments.of(SessionScheme.DISCOVER, "\"discover\""), + Arguments.of(SessionScheme.UPI, "\"upi\"") + ); + } + + @ParameterizedTest + @MethodSource("sessionSchemes") + void shouldSerializeSessionSchemeToSnakeCase(final SessionScheme value, final String expectedJson) { + assertEquals(expectedJson, serializer.toJson(value)); + } + + @ParameterizedTest + @MethodSource("sessionSchemes") + void shouldDeserializeSessionSchemeFromSnakeCase(final SessionScheme expected, final String json) { + assertEquals(expected, serializer.fromJson(json, SessionScheme.class)); + } + + @Test + void shouldExposeTheEightSchemesDefinedBySpec() { + assertEquals(8, SessionScheme.values().length); + } + + @Test + void shouldRoundTripAllValues() { + for (final SessionScheme value : SessionScheme.values()) { + assertEquals(value, serializer.fromJson(serializer.toJson(value), SessionScheme.class)); + } + } + + /** + * Gson resolves an unrecognised enum value to null rather than throwing, so a missing scheme + * would silently drop the required {@code scheme} field on a session response. + */ + @ParameterizedTest + @MethodSource("sessionSchemes") + void shouldDeserializeEverySchemeOnGetSessionResponse(final SessionScheme expected, final String json) { + final GetSessionResponse response = + serializer.fromJson("{\"scheme\":" + json + "}", GetSessionResponse.class); + + assertNotNull(response); + assertEquals(expected, response.getScheme()); + } + + @ParameterizedTest + @MethodSource("sessionSchemes") + void shouldDeserializeEverySchemeOnCreateSessionAcceptedResponse(final SessionScheme expected, + final String json) { + final CreateSessionAcceptedResponse response = + serializer.fromJson("{\"scheme\":" + json + "}", CreateSessionAcceptedResponse.class); + + assertNotNull(response); + assertEquals(expected, response.getScheme()); + } + +} diff --git a/src/test/java/com/checkout/sessions/TransactionTypeTest.java b/src/test/java/com/checkout/sessions/TransactionTypeTest.java new file mode 100644 index 00000000..6c1249cb --- /dev/null +++ b/src/test/java/com/checkout/sessions/TransactionTypeTest.java @@ -0,0 +1,84 @@ +package com.checkout.sessions; + +import com.checkout.GsonSerializer; +import com.checkout.Serializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TransactionTypeTest { + + private final Serializer serializer = new GsonSerializer(); + + private static Stream transactionTypes() { + return Stream.of( + Arguments.of(TransactionType.GOODS_SERVICE, "\"goods_service\""), + Arguments.of(TransactionType.CHECK_ACCEPTANCE, "\"check_acceptance\""), + Arguments.of(TransactionType.ACCOUNT_FUNDING, "\"account_funding\""), + Arguments.of(TransactionType.QUASI_CARD_TRANSACTION, "\"quasi_card_transaction\""), + Arguments.of(TransactionType.PREPAID_ACTIVATION_AND_LOAD, "\"prepaid_activation_and_load\"") + ); + } + + @ParameterizedTest + @MethodSource("transactionTypes") + void shouldSerializeTransactionTypeToSnakeCase(final TransactionType value, final String expectedJson) { + assertEquals(expectedJson, serializer.toJson(value)); + } + + @ParameterizedTest + @MethodSource("transactionTypes") + void shouldDeserializeTransactionTypeFromSnakeCase(final TransactionType expected, final String json) { + assertEquals(expected, serializer.fromJson(json, TransactionType.class)); + } + + @Test + void shouldExposeTheFiveTypesDefinedBySpec() { + assertEquals(5, TransactionType.values().length); + } + + @Test + void shouldRoundTripAllValues() { + for (final TransactionType value : TransactionType.values()) { + assertEquals(value, serializer.fromJson(serializer.toJson(value), TransactionType.class)); + } + } + + /** + * The constant name retains a historical misspelling, but the API value is + * {@code quasi_card_transaction}. Guards both directions of the wire contract. + */ + @Test + void shouldUseTheSpecSpellingForQuasiCardTransaction() { + assertEquals("\"quasi_card_transaction\"", serializer.toJson(TransactionType.QUASI_CARD_TRANSACTION)); + assertEquals(TransactionType.QUASI_CARD_TRANSACTION, + serializer.fromJson("\"quasi_card_transaction\"", TransactionType.class)); + } + + @Test + void shouldSerializeQuasiCardTransactionOnSessionRequest() { + final SessionRequest request = SessionRequest.builder() + .transactionType(TransactionType.QUASI_CARD_TRANSACTION) + .build(); + + assertTrue(serializer.toJson(request).contains("\"transaction_type\":\"quasi_card_transaction\"")); + } + + @ParameterizedTest + @MethodSource("transactionTypes") + void shouldDeserializeEveryTypeOnGetSessionResponse(final TransactionType expected, final String json) { + final GetSessionResponse response = + serializer.fromJson("{\"transaction_type\":" + json + "}", GetSessionResponse.class); + + assertNotNull(response); + assertEquals(expected, response.getTransactionType()); + } + +} From 21626fe546ebffa3784530b9a9dbaec42a4cb290 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Wed, 5 Aug 2026 18:03:03 +0200 Subject: [PATCH 3/4] Java fixes --- .../CreateSessionAcceptedResponse.java | 46 +++++++++++++++++++ .../com/checkout/sessions/Experience.java | 27 +++++++++++ .../checkout/sessions/ExperienceOutcome.java | 27 +++++++++++ .../checkout/sessions/ExperienceStatus.java | 38 +++++++++++++++ .../checkout/sessions/GetSessionResponse.java | 25 ++++++++++ .../java/com/checkout/sessions/GoogleSpa.java | 27 +++++++++++ .../checkout/sessions/GoogleSpaIframe.java | 25 ++++++++++ .../com/checkout/sessions/GoogleSpaInfo.java | 46 +++++++++++++++++++ .../com/checkout/sessions/GoogleSpaToken.java | 31 +++++++++++++ .../sessions/SessionPreferredExperiences.java | 29 ++++++++++++ .../com/checkout/sessions/SessionRequest.java | 15 ++++++ .../sessions/ThreeDSErrorDetails.java | 39 ++++++++++++++++ .../com/checkout/sessions/ThreeDSInfo.java | 32 +++++++++++++ ...sionAcceptedResponseSerializationTest.java | 42 ++++++++++++++++- .../GetSessionResponseSerializationTest.java | 46 ++++++++++++++++++- .../SessionRequestSerializationTest.java | 22 +++++++-- 16 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/checkout/sessions/Experience.java create mode 100644 src/main/java/com/checkout/sessions/ExperienceOutcome.java create mode 100644 src/main/java/com/checkout/sessions/ExperienceStatus.java create mode 100644 src/main/java/com/checkout/sessions/GoogleSpa.java create mode 100644 src/main/java/com/checkout/sessions/GoogleSpaIframe.java create mode 100644 src/main/java/com/checkout/sessions/GoogleSpaInfo.java create mode 100644 src/main/java/com/checkout/sessions/GoogleSpaToken.java create mode 100644 src/main/java/com/checkout/sessions/SessionPreferredExperiences.java create mode 100644 src/main/java/com/checkout/sessions/ThreeDSErrorDetails.java create mode 100644 src/main/java/com/checkout/sessions/ThreeDSInfo.java diff --git a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java index 6aec2253..d90cd277 100644 --- a/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java +++ b/src/main/java/com/checkout/sessions/CreateSessionAcceptedResponse.java @@ -175,4 +175,50 @@ public final class CreateSessionAcceptedResponse extends Resource { */ private Optimization optimization; + /** + * Specifies if the session was completed. + * [Optional] + */ + private Boolean completed; + + /** + * Identifies the type of transaction being authenticated. + * [Optional] + * Default: {@link TransactionType#GOODS_SERVICE} + * max 50 characters + */ + private TransactionType transactionType; + + /** + * Public certificates specific to a Directory Server (DS) for encrypting device data and + * verifying ACS signed content. Required when the channel is {@code app}. + * [Optional] + */ + private DsPublicKeys certificates; + + /** + * The directory server (DS) information. Can be empty if the session is pending or communication + * with the DS failed. + * [Optional] + */ + private Ds ds; + + /** + * The authentication experience that was used for processing. + * [Optional] + */ + private Experience experience; + + /** + * Reports the outcome of each experience requested for the session. + * [Optional] + */ + private SessionPreferredExperiences preferredExperiences; + + /** + * Details of Google SPA (Secure Payment Authentication). + * [Optional] + */ + private GoogleSpaInfo googleSpa; + } diff --git a/src/main/java/com/checkout/sessions/Experience.java b/src/main/java/com/checkout/sessions/Experience.java new file mode 100644 index 00000000..36923bb2 --- /dev/null +++ b/src/main/java/com/checkout/sessions/Experience.java @@ -0,0 +1,27 @@ +package com.checkout.sessions; + +import com.google.gson.annotations.SerializedName; + +/** + * The authentication experience used for processing a session. + *

+ * Used by {@link SessionRequest#getPreferredExperiences()} to indicate the chosen experience(s), and + * returned by {@link GetSessionResponse#getExperience()} to report the one that was used. + *

+ * [Optional] + */ +public enum Experience { + + /** + * 3D Secure authentication. + */ + @SerializedName("3ds") + THREE_DS, + + /** + * Google Secure Payment Authentication. + */ + @SerializedName("google_spa") + GOOGLE_SPA + +} diff --git a/src/main/java/com/checkout/sessions/ExperienceOutcome.java b/src/main/java/com/checkout/sessions/ExperienceOutcome.java new file mode 100644 index 00000000..0867328d --- /dev/null +++ b/src/main/java/com/checkout/sessions/ExperienceOutcome.java @@ -0,0 +1,27 @@ +package com.checkout.sessions; + +import lombok.Data; + +import java.util.List; + +/** + * The outcome of a single preferred experience. + *

+ * Used by {@link SessionPreferredExperiences}. + */ +@Data +public final class ExperienceOutcome { + + /** + * The status of the experience. + * [Optional] + */ + private ExperienceStatus status; + + /** + * The reason(s) why processing the experience was unsuccessful. + * [Optional] + */ + private List reason; + +} diff --git a/src/main/java/com/checkout/sessions/ExperienceStatus.java b/src/main/java/com/checkout/sessions/ExperienceStatus.java new file mode 100644 index 00000000..8605e442 --- /dev/null +++ b/src/main/java/com/checkout/sessions/ExperienceStatus.java @@ -0,0 +1,38 @@ +package com.checkout.sessions; + +import com.google.gson.annotations.SerializedName; + +/** + * The status of a preferred experience on a session response. + *

+ * Used by {@link ExperienceOutcome#getStatus()}. + *

+ * [Optional] + */ +public enum ExperienceStatus { + + /** + * The experience is available for this session. + */ + @SerializedName("available") + AVAILABLE, + + /** + * The experience has not been processed. + */ + @SerializedName("unprocessed") + UNPROCESSED, + + /** + * The experience has been processed. + */ + @SerializedName("processed") + PROCESSED, + + /** + * The experience is not available for this session. + */ + @SerializedName("unavailable") + UNAVAILABLE + +} diff --git a/src/main/java/com/checkout/sessions/GetSessionResponse.java b/src/main/java/com/checkout/sessions/GetSessionResponse.java index 3d3364c4..35ceb83a 100644 --- a/src/main/java/com/checkout/sessions/GetSessionResponse.java +++ b/src/main/java/com/checkout/sessions/GetSessionResponse.java @@ -310,4 +310,29 @@ public class GetSessionResponse extends Resource { */ private SchemeInfo schemeInfo; + /** + * This object provides more information about the 3DS experience. + * [Optional] + */ + @SerializedName("3ds") + private ThreeDSInfo threeDs; + + /** + * Reports the outcome of each experience requested for the session. + * [Optional] + */ + private SessionPreferredExperiences preferredExperiences; + + /** + * The authentication experience that was used for processing. + * [Optional] + */ + private Experience experience; + + /** + * Details of Google SPA (Secure Payment Authentication). + * [Optional] + */ + private GoogleSpaInfo googleSpa; + } diff --git a/src/main/java/com/checkout/sessions/GoogleSpa.java b/src/main/java/com/checkout/sessions/GoogleSpa.java new file mode 100644 index 00000000..475284cb --- /dev/null +++ b/src/main/java/com/checkout/sessions/GoogleSpa.java @@ -0,0 +1,27 @@ +package com.checkout.sessions; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * This object contains the Google SPA properties (non-hosted only). + *

+ * Used by {@link SessionRequest#getGoogleSpa()}. + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public final class GoogleSpa { + + /** + * Fully qualified URL for redirecting the user's browser session after authentication. For + * example, this field may be the merchant's website for purchase confirmation once payment is + * complete. Required if in full redirect (not iframe) mode. + * [Optional] + */ + private String continueUrl; + +} diff --git a/src/main/java/com/checkout/sessions/GoogleSpaIframe.java b/src/main/java/com/checkout/sessions/GoogleSpaIframe.java new file mode 100644 index 00000000..438dd6e8 --- /dev/null +++ b/src/main/java/com/checkout/sessions/GoogleSpaIframe.java @@ -0,0 +1,25 @@ +package com.checkout.sessions; + +import lombok.Data; + +/** + * Details of the challenge iframe displayed in the cardholder browser window. + *

+ * Used by {@link GoogleSpaInfo#getIframe()}. + */ +@Data +public final class GoogleSpaIframe { + + /** + * Height of the challenge iframe displayed in the cardholder browser window. + * [Optional] + */ + private String height; + + /** + * Width of the challenge iframe displayed in the cardholder browser window. + * [Optional] + */ + private String width; + +} diff --git a/src/main/java/com/checkout/sessions/GoogleSpaInfo.java b/src/main/java/com/checkout/sessions/GoogleSpaInfo.java new file mode 100644 index 00000000..6ab66914 --- /dev/null +++ b/src/main/java/com/checkout/sessions/GoogleSpaInfo.java @@ -0,0 +1,46 @@ +package com.checkout.sessions; + +import lombok.Data; + +/** + * Details of Google SPA (Secure Payment Authentication). + *

+ * Returned as {@link GetSessionResponse#getGoogleSpa()}. The spec models this as a choice between a + * hosted variant, which returns only {@link #token}, and a non-hosted variant, which also returns the + * challenge details. Both are modelled here with every property optional, so callers read whichever + * the API populated. + */ +@Data +public final class GoogleSpaInfo { + + /** + * The URL used to present the Google SPA challenge. + * [Optional] + */ + private String challengeUrl; + + /** + * The initial timeout, in seconds. + * [Optional] + */ + private String initialTimeout; + + /** + * The maximum timeout, in seconds. + * [Optional] + */ + private String maxTimeout; + + /** + * Details of the challenge iframe displayed in the cardholder browser window. + * [Optional] + */ + private GoogleSpaIframe iframe; + + /** + * Token for the given PAN provisioned and authenticated. + * [Optional] + */ + private GoogleSpaToken token; + +} diff --git a/src/main/java/com/checkout/sessions/GoogleSpaToken.java b/src/main/java/com/checkout/sessions/GoogleSpaToken.java new file mode 100644 index 00000000..33157eee --- /dev/null +++ b/src/main/java/com/checkout/sessions/GoogleSpaToken.java @@ -0,0 +1,31 @@ +package com.checkout.sessions; + +import lombok.Data; + +/** + * Token for the given PAN provisioned and authenticated. + *

+ * Used by {@link GoogleSpaInfo#getToken()}. + */ +@Data +public final class GoogleSpaToken { + + /** + * Value of token, represented as a numerical string. + * [Optional] + */ + private String number; + + /** + * Expiry month of the token. + * [Optional] + */ + private Integer expiryMonth; + + /** + * Expiry year of the token. + * [Optional] + */ + private Integer expiryYear; + +} diff --git a/src/main/java/com/checkout/sessions/SessionPreferredExperiences.java b/src/main/java/com/checkout/sessions/SessionPreferredExperiences.java new file mode 100644 index 00000000..07e3d1a8 --- /dev/null +++ b/src/main/java/com/checkout/sessions/SessionPreferredExperiences.java @@ -0,0 +1,29 @@ +package com.checkout.sessions; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +/** + * Reports the outcome of each experience requested for the session. + *

+ * Returned as {@link GetSessionResponse#getPreferredExperiences()}. Named with the {@code Session} + * prefix to distinguish it from {@link com.checkout.payments.PreferredExperiences}, which is an enum + * in the payments domain. + */ +@Data +public final class SessionPreferredExperiences { + + /** + * The outcome of the Google SPA experience. + * [Optional] + */ + private ExperienceOutcome googleSpa; + + /** + * The outcome of the 3DS experience. + * [Optional] + */ + @SerializedName("3ds") + private ExperienceOutcome threeDs; + +} diff --git a/src/main/java/com/checkout/sessions/SessionRequest.java b/src/main/java/com/checkout/sessions/SessionRequest.java index b43fec3c..244eb15c 100644 --- a/src/main/java/com/checkout/sessions/SessionRequest.java +++ b/src/main/java/com/checkout/sessions/SessionRequest.java @@ -12,6 +12,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @AllArgsConstructor @@ -191,4 +193,17 @@ public final class SessionRequest { */ private DeviceInformation deviceInformation; + /** + * This object contains the Google SPA properties (non-hosted only). + * [Optional] + */ + private GoogleSpa googleSpa; + + /** + * Indicates the chosen experience(s) for this session. + * Available experiences are {@link Experience#THREE_DS} and {@link Experience#GOOGLE_SPA}. + * [Optional] + */ + private List preferredExperiences; + } diff --git a/src/main/java/com/checkout/sessions/ThreeDSErrorDetails.java b/src/main/java/com/checkout/sessions/ThreeDSErrorDetails.java new file mode 100644 index 00000000..f52aada4 --- /dev/null +++ b/src/main/java/com/checkout/sessions/ThreeDSErrorDetails.java @@ -0,0 +1,39 @@ +package com.checkout.sessions; + +import lombok.Data; + +/** + * Provides additional information about the error returned. + *

+ * Used by {@link ThreeDSInfo#getErrorDetails()}. + */ +@Data +public final class ThreeDSErrorDetails { + + /** + * An error code identifying the type of issue. + * [Optional] + */ + private String errorCode; + + /** + * A code that specifies which 3D Secure component identified the error. + * [Optional] + */ + private String errorComponent; + + /** + * Provides additional details about the issue. + * [Optional] + * max 2048 characters + */ + private String errorDetail; + + /** + * A description of the issue identified. + * [Optional] + * max 2048 characters + */ + private String errorDescription; + +} diff --git a/src/main/java/com/checkout/sessions/ThreeDSInfo.java b/src/main/java/com/checkout/sessions/ThreeDSInfo.java new file mode 100644 index 00000000..2987a455 --- /dev/null +++ b/src/main/java/com/checkout/sessions/ThreeDSInfo.java @@ -0,0 +1,32 @@ +package com.checkout.sessions; + +import lombok.Data; + +/** + * This object provides more information about the 3DS experience. + *

+ * Returned as the {@code 3ds} property of {@link GetSessionResponse}. + */ +@Data +public final class ThreeDSInfo { + + /** + * The CReq message, encoded in Base 64. + * [Optional] + */ + private String challengeRequest; + + /** + * The number of authentication attempts performed by the cardholder. + * [Optional] + * max 2 characters + */ + private String interactionCounter; + + /** + * Provides additional information about the error returned. + * [Optional] + */ + private ThreeDSErrorDetails errorDetails; + +} diff --git a/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java b/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java index 811bce9a..1fbedde0 100644 --- a/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java +++ b/src/test/java/com/checkout/sessions/CreateSessionAcceptedResponseSerializationTest.java @@ -56,6 +56,17 @@ private static String fullPayload() { + "\"challenge_indicator\":\"transaction_risk_assessment\"," + "\"optimization\":{\"optimized\":true,\"framework\":\"acceptance_rates\"," + "\"optimized_properties\":[{\"field\":\"amount\",\"original_value\":\"1\",\"optimized_value\":\"2\"}]}," + + "\"completed\":false," + + "\"transaction_type\":\"goods_service\"," + + "\"certificates\":{\"ds_public\":\"ds-public-key\",\"ca_public\":\"ca-public-key\"}," + + "\"ds\":{\"ds_id\":\"ds-id\",\"reference_number\":\"ds-ref\",\"transaction_id\":\"ds-txn\"}," + + "\"experience\":\"3ds\"," + + "\"preferred_experiences\":{\"google_spa\":{\"status\":\"available\"}," + + "\"3ds\":{\"status\":\"processed\",\"reason\":[\"Invalid response\"]}}," + + "\"google_spa\":{\"challenge_url\":\"https://google.example/challenge\"," + + "\"initial_timeout\":\"5\",\"max_timeout\":\"10\"," + + "\"iframe\":{\"height\":\"400\",\"width\":\"250\"}," + + "\"token\":{\"number\":\"4242\",\"expiry_month\":12,\"expiry_year\":2030}}," + "\"_links\":{\"self\":{\"href\":\"https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44\"}}" + "}"; } @@ -132,6 +143,35 @@ void shouldDeserializeNestedObjects() { assertEquals(1, response.getOptimization().getOptimizedProperties().size()); } + /** + * The seven fields below were previously unmodelled on this class, leaving the 202 response of + * POST /sessions inconsistent with GET /sessions/{id}. + */ + @Test + void shouldDeserializeTheFieldsSharedWithGetSessionResponse() { + assertEquals(Boolean.FALSE, response.getCompleted()); + assertEquals(TransactionType.GOODS_SERVICE, response.getTransactionType()); + + assertNotNull(response.getCertificates()); + assertEquals("ds-public-key", response.getCertificates().getDsPublic()); + + assertNotNull(response.getDs()); + assertEquals("ds-id", response.getDs().getDsId()); + + assertEquals(Experience.THREE_DS, response.getExperience()); + + assertNotNull(response.getPreferredExperiences()); + assertEquals(ExperienceStatus.AVAILABLE, + response.getPreferredExperiences().getGoogleSpa().getStatus()); + assertEquals(ExperienceStatus.PROCESSED, + response.getPreferredExperiences().getThreeDs().getStatus()); + + assertNotNull(response.getGoogleSpa()); + assertEquals("https://google.example/challenge", response.getGoogleSpa().getChallengeUrl()); + assertNotNull(response.getGoogleSpa().getToken()); + assertEquals("4242", response.getGoogleSpa().getToken().getNumber()); + } + @Test void shouldDeserializeInheritedLinks() { assertNotNull(response.getSelfLink()); @@ -157,7 +197,7 @@ void shouldLeaveNoDeclaredPropertyNull() { throw new AssertionError(e); } } - assertTrue(fields.length >= 22, "expected at least 22 declared properties, found " + fields.length); + assertTrue(fields.length >= 29, "expected at least 29 declared properties, found " + fields.length); } } diff --git a/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java b/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java index 5118c79f..ab9c2211 100644 --- a/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java +++ b/src/test/java/com/checkout/sessions/GetSessionResponseSerializationTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.Collections; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -86,6 +87,16 @@ private static String fullPayload() { + "\"optimization\":{\"optimized\":true,\"framework\":\"acceptance_rates\"," + "\"optimized_properties\":[{\"field\":\"amount\",\"original_value\":\"1\",\"optimized_value\":\"2\"}]}," + "\"scheme_info\":{\"name\":\"visa\",\"score\":\"0.5\",\"avalgo\":\"1\"}," + + "\"3ds\":{\"challenge_request\":\"creq\",\"interaction_counter\":\"03\"," + + "\"error_details\":{\"error_code\":\"101\",\"error_component\":\"D\"," + + "\"error_detail\":\"acctNumber\",\"error_description\":\"missing\"}}," + + "\"experience\":\"3ds\"," + + "\"preferred_experiences\":{\"google_spa\":{\"status\":\"available\"}," + + "\"3ds\":{\"status\":\"processed\",\"reason\":[\"Invalid response\"]}}," + + "\"google_spa\":{\"challenge_url\":\"https://google.example/challenge\"," + + "\"initial_timeout\":\"5\",\"max_timeout\":\"10\"," + + "\"iframe\":{\"height\":\"400\",\"width\":\"250\"}," + + "\"token\":{\"number\":\"4242\",\"expiry_month\":12,\"expiry_year\":2030}}," + "\"_links\":{\"self\":{\"href\":\"https://api.checkout.com/sessions/sid_y3oqhf46pyzuxjbcn2giaqnb44\"}}" + "}"; } @@ -223,6 +234,39 @@ void shouldDeserializeExemption() { assertEquals("cb-code", response.getExemption().getCode()); } + /** + * The 3ds, experience, preferred_experiences and google_spa fields were previously unmodelled, so + * the Google SPA and preferred-experience data the API returns was dropped. + */ + @Test + void shouldDeserializeTheExperienceFields() { + assertNotNull(response.getThreeDs()); + assertEquals("creq", response.getThreeDs().getChallengeRequest()); + assertEquals("03", response.getThreeDs().getInteractionCounter()); + assertNotNull(response.getThreeDs().getErrorDetails()); + assertEquals("101", response.getThreeDs().getErrorDetails().getErrorCode()); + assertEquals("D", response.getThreeDs().getErrorDetails().getErrorComponent()); + + assertEquals(Experience.THREE_DS, response.getExperience()); + + assertNotNull(response.getPreferredExperiences()); + assertEquals(ExperienceStatus.AVAILABLE, + response.getPreferredExperiences().getGoogleSpa().getStatus()); + assertEquals(ExperienceStatus.PROCESSED, + response.getPreferredExperiences().getThreeDs().getStatus()); + assertEquals(Collections.singletonList("Invalid response"), + response.getPreferredExperiences().getThreeDs().getReason()); + + assertNotNull(response.getGoogleSpa()); + assertEquals("https://google.example/challenge", response.getGoogleSpa().getChallengeUrl()); + assertEquals("5", response.getGoogleSpa().getInitialTimeout()); + assertNotNull(response.getGoogleSpa().getIframe()); + assertEquals("400", response.getGoogleSpa().getIframe().getHeight()); + assertNotNull(response.getGoogleSpa().getToken()); + assertEquals("4242", response.getGoogleSpa().getToken().getNumber()); + assertEquals(12, response.getGoogleSpa().getToken().getExpiryMonth()); + } + @Test void shouldDeserializeInheritedLinks() { assertNotNull(response.getSelfLink()); @@ -248,7 +292,7 @@ void shouldLeaveNoDeclaredPropertyNull() { throw new AssertionError(e); } } - assertTrue(fields.length >= 40, "expected at least 40 declared properties, found " + fields.length); + assertTrue(fields.length >= 44, "expected at least 44 declared properties, found " + fields.length); } } diff --git a/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java index 94b5074e..dce5607d 100644 --- a/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java +++ b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java @@ -11,7 +11,10 @@ import com.checkout.sessions.source.SessionCardSource; import org.junit.jupiter.api.Test; +import java.util.Arrays; + import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -107,6 +110,10 @@ private static SessionRequest fullyPopulated() { .deviceId("device-id") .deviceSessionId("device-session-id") .build()) + .googleSpa(GoogleSpa.builder() + .continueUrl("https://merchant.com/continue") + .build()) + .preferredExperiences(Arrays.asList(Experience.THREE_DS, Experience.GOOGLE_SPA)) .build(); } @@ -142,7 +149,6 @@ void shouldSerializeEveryProperty() { "billing_descriptor", "reference", "merchant_risk_info", - "prior_transaction_reference", "transaction_type", "shipping_address", "shipping_address_matches_billing", @@ -152,12 +158,23 @@ void shouldSerializeEveryProperty() { "installment", "optimization", "initial_transaction", - "device_information" + "device_information", + "google_spa", + "preferred_experiences" }; for (final String key : expectedKeys) { assertTrue(json.contains("\"" + key + "\""), "missing property " + key + " in " + json); } + + // Reflection guard: a field added to SessionRequest without being classified as either a spec + // property or a known non-spec property fails here. Synthetic fields are skipped because + // JaCoCo injects $jacocoData under Gradle. + final long declared = java.util.Arrays.stream(SessionRequest.class.getDeclaredFields()) + .filter(field -> !field.isSynthetic()) + .count(); + assertEquals(expectedKeys.length, declared, + "SessionRequest declares a property this test classifies as neither spec nor known-extra"); } @Test @@ -171,7 +188,6 @@ void shouldSerializeScalarValuesAndEnumsAsSnakeCase() { assertTrue(json.contains("\"authentication_category\":\"payment\""), json); assertTrue(json.contains("\"challenge_indicator\":\"trusted_listing_prompt\""), json); assertTrue(json.contains("\"reference\":\"ORD-5023-4E89\""), json); - assertTrue(json.contains("\"prior_transaction_reference\":\"prior-txn-ref\""), json); assertTrue(json.contains("\"transaction_type\":\"goods_service\""), json); assertTrue(json.contains("\"shipping_address_matches_billing\":true"), json); } From 312ca272a71ccd50d22944d3f963dac18f28453e Mon Sep 17 00:00:00 2001 From: david ruiz Date: Thu, 6 Aug 2026 09:01:00 +0200 Subject: [PATCH 4/4] SessionRequest priorTransactionReference fix --- .../java/com/checkout/sessions/SessionRequest.java | 10 ---------- .../sessions/SessionRequestSerializationTest.java | 8 +++----- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/checkout/sessions/SessionRequest.java b/src/main/java/com/checkout/sessions/SessionRequest.java index 244eb15c..e8604181 100644 --- a/src/main/java/com/checkout/sessions/SessionRequest.java +++ b/src/main/java/com/checkout/sessions/SessionRequest.java @@ -115,16 +115,6 @@ public final class SessionRequest { */ private MerchantRiskInfo merchantRiskInfo; - /** - * A reference to a previous transaction for this cardholder. - *

- * Note: this field is not present in the {@code SessionRequest} schema of the Checkout.com API - * Reference. It is retained for backwards compatibility pending confirmation from the API - * owners. - * [Optional] - */ - private String priorTransactionReference; - /** * Identifies the type of transaction being authenticated. * [Optional] diff --git a/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java index dce5607d..f4c5882d 100644 --- a/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java +++ b/src/test/java/com/checkout/sessions/SessionRequestSerializationTest.java @@ -65,7 +65,6 @@ private static SessionRequest fullyPopulated() { .isPreorder(false) .isReorder(false) .build()) - .priorTransactionReference("prior-txn-ref") .transactionType(TransactionType.GOODS_SERVICE) .shippingAddress(SessionAddress.builderSessionAddress() .addressLine1("Checkout.com") @@ -167,14 +166,13 @@ void shouldSerializeEveryProperty() { assertTrue(json.contains("\"" + key + "\""), "missing property " + key + " in " + json); } - // Reflection guard: a field added to SessionRequest without being classified as either a spec - // property or a known non-spec property fails here. Synthetic fields are skipped because - // JaCoCo injects $jacocoData under Gradle. + // Reflection guard: SessionRequest must declare exactly the spec properties above and nothing + // else. Synthetic fields are skipped because JaCoCo injects $jacocoData under Gradle. final long declared = java.util.Arrays.stream(SessionRequest.class.getDeclaredFields()) .filter(field -> !field.isSynthetic()) .count(); assertEquals(expectedKeys.length, declared, - "SessionRequest declares a property this test classifies as neither spec nor known-extra"); + "SessionRequest declares a property that is not in the SessionRequest spec schema"); } @Test