diff --git a/.gitignore b/.gitignore index f75941e..daeb2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ dependency-reduced-pom.xml .DS_Store **/.DS_Store .run -checkstyle.xml \ No newline at end of file +checkstyle.xml +.jvm.config +.mvn* \ No newline at end of file diff --git a/pom.xml b/pom.xml index 11ceac7..8863f86 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.phonepe pg-sdk-java - 2.2.2 + 2.2.3 B2B JAVA SDK https://github.com/PhonePe/phonepe-pg-sdk-java Phonepe PG JAVA SDK diff --git a/src/main/java/com/phonepe/sdk/pg/Env.java b/src/main/java/com/phonepe/sdk/pg/Env.java index 30dd11a..beef7c0 100644 --- a/src/main/java/com/phonepe/sdk/pg/Env.java +++ b/src/main/java/com/phonepe/sdk/pg/Env.java @@ -24,18 +24,22 @@ public enum Env { SANDBOX( BaseUrl.SANDBOX_PG_HOST_URL, BaseUrl.SANDBOX_OAUTH_HOST_URL, - BaseUrl.SANDBOX_EVENTS_HOST_URL), PRODUCTION( + BaseUrl.SANDBOX_EVENTS_HOST_URL, + BaseUrl.SANDBOX_PG_HOST_URL), PRODUCTION( BaseUrl.PRODUCTION_PG_HOST_URL, BaseUrl.PRODUCTION_OAUTH_HOST_URL, - BaseUrl.PRODUCTION_EVENTS_HOST_URL), TEST(BaseUrl.TESTING_URL, BaseUrl.TESTING_URL, - BaseUrl.TESTING_URL); + BaseUrl.PRODUCTION_EVENTS_HOST_URL, + BaseUrl.PRODUCTION_PG_HOST_URL_PCI), TEST(BaseUrl.TESTING_URL, BaseUrl.TESTING_URL, + BaseUrl.TESTING_URL, BaseUrl.TESTING_URL); private final String pgHostUrl; private final String oAuthHostUrl; private final String eventsHostUrl; + private final String pciPgHostUrl; - Env(String pgHostUrl, String oAuthHostUrl, String eventsHostUrl) { + Env(String pgHostUrl, String oAuthHostUrl, String eventsHostUrl, String pciPgHostUrl) { this.pgHostUrl = pgHostUrl; + this.pciPgHostUrl = pciPgHostUrl; this.oAuthHostUrl = oAuthHostUrl; this.eventsHostUrl = eventsHostUrl; } diff --git a/src/main/java/com/phonepe/sdk/pg/common/BaseClient.java b/src/main/java/com/phonepe/sdk/pg/common/BaseClient.java index 3299fce..3e7cb7d 100644 --- a/src/main/java/com/phonepe/sdk/pg/common/BaseClient.java +++ b/src/main/java/com/phonepe/sdk/pg/common/BaseClient.java @@ -83,6 +83,20 @@ protected T requestViaAuthRefresh( Map queryParams, TypeReference responseTypeReference, List headers) { + return requestViaAuthRefresh( + methodName, requestData, url, queryParams, responseTypeReference, headers, + this.env.getPgHostUrl()); + } + + @SneakyThrows + protected T requestViaAuthRefresh( + HttpMethodType methodName, + R requestData, + String url, + Map queryParams, + TypeReference responseTypeReference, + List headers, + String hostUrl) { List httpHeaders = new ArrayList<>(headers); HttpCommand httpCommand = HttpCommand.builder() .client(this.okHttpClient) @@ -91,7 +105,7 @@ protected T requestViaAuthRefresh( .methodName(methodName) .headers(addAuthHeader(httpHeaders)) .requestData(requestData) - .hostURL(this.env.getPgHostUrl()) + .hostURL(hostUrl) .encodingType(APPLICATION_JSON) .queryParams(queryParams) .url(url) diff --git a/src/main/java/com/phonepe/sdk/pg/common/constants/BaseUrl.java b/src/main/java/com/phonepe/sdk/pg/common/constants/BaseUrl.java index 97a603b..269a36b 100644 --- a/src/main/java/com/phonepe/sdk/pg/common/constants/BaseUrl.java +++ b/src/main/java/com/phonepe/sdk/pg/common/constants/BaseUrl.java @@ -21,6 +21,7 @@ public class BaseUrl { public static final String PRODUCTION_PG_HOST_URL = "https://api.phonepe.com/apis/pg"; + public static final String PRODUCTION_PG_HOST_URL_PCI = "https://cards.phonepe.com/apis/pg"; public static final String SANDBOX_PG_HOST_URL = "https://api-preprod.phonepe.com/apis/pg-sandbox"; public static final String PRODUCTION_OAUTH_HOST_URL = "https://api.phonepe.com/apis/identity-manager"; diff --git a/src/main/java/com/phonepe/sdk/pg/common/models/MetaInfo.java b/src/main/java/com/phonepe/sdk/pg/common/models/MetaInfo.java index 72babb2..52feff9 100644 --- a/src/main/java/com/phonepe/sdk/pg/common/models/MetaInfo.java +++ b/src/main/java/com/phonepe/sdk/pg/common/models/MetaInfo.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; +import java.util.regex.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -25,15 +26,103 @@ @Data @Builder -@AllArgsConstructor @NoArgsConstructor -@JsonInclude(Include.NON_NULL) +@AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(Include.NON_NULL) public class MetaInfo { + /** Free-text field. Max 256 characters. */ private String udf1; + + /** Free-text field. Max 256 characters. */ private String udf2; + + /** Free-text field. Max 256 characters. */ private String udf3; + + /** Free-text field. Max 256 characters. */ private String udf4; + + /** Free-text field. Max 256 characters. */ private String udf5; + + /** Free-text field. Max 256 characters. */ + private String udf6; + + /** Free-text field. Max 256 characters. */ + private String udf7; + + /** Free-text field. Max 256 characters. */ + private String udf8; + + /** Free-text field. Max 256 characters. */ + private String udf9; + + /** Free-text field. Max 256 characters. */ + private String udf10; + + /** Alphanumeric + [_ - @ . +] only. Max 50 characters. */ + private String udf11; + + /** Alphanumeric + [_ - @ . +] only. Max 50 characters. */ + private String udf12; + + /** Alphanumeric + [_ - @ . +] only. Max 50 characters. */ + private String udf13; + + /** Alphanumeric + [_ - @ . +] only. Max 50 characters. */ + private String udf14; + + /** Alphanumeric + [_ - @ . +] only. Max 50 characters. */ + private String udf15; + + // Lombok fills in all field setters; only build() is customised here. + public static class MetaInfoBuilder { + + private static final Pattern RESTRICTED_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\- @.+]*$"); + + public MetaInfo build() { + validateSize("udf1", udf1, 256); + validateSize("udf2", udf2, 256); + validateSize("udf3", udf3, 256); + validateSize("udf4", udf4, 256); + validateSize("udf5", udf5, 256); + validateSize("udf6", udf6, 256); + validateSize("udf7", udf7, 256); + validateSize("udf8", udf8, 256); + validateSize("udf9", udf9, 256); + validateSize("udf10", udf10, 256); + validateSizeAndPattern("udf11", udf11, 50); + validateSizeAndPattern("udf12", udf12, 50); + validateSizeAndPattern("udf13", udf13, 50); + validateSizeAndPattern("udf14", udf14, 50); + validateSizeAndPattern("udf15", udf15, 50); + return new MetaInfo(udf1, udf2, udf3, udf4, udf5, + udf6, udf7, udf8, udf9, udf10, + udf11, udf12, udf13, udf14, udf15); + } + + private static void validateSize(String field, String value, int max) { + if (value != null && value.length() > max) { + throw new IllegalArgumentException( + field + " exceeds maximum allowed size of " + max + " characters"); + } + } + + private static void validateSizeAndPattern(String field, String value, int max) { + if (value == null) { + return; + } + if (value.length() > max) { + throw new IllegalArgumentException( + field + " exceeds maximum allowed size of " + max + " characters"); + } + if (!RESTRICTED_PATTERN.matcher(value) + .matches()) { + throw new IllegalArgumentException(field + + " should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +"); + } + } + } } diff --git a/src/main/java/com/phonepe/sdk/pg/payments/v2/CustomCheckoutClient.java b/src/main/java/com/phonepe/sdk/pg/payments/v2/CustomCheckoutClient.java index 05e44c6..ef5bd16 100644 --- a/src/main/java/com/phonepe/sdk/pg/payments/v2/CustomCheckoutClient.java +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/CustomCheckoutClient.java @@ -31,13 +31,19 @@ import com.phonepe.sdk.pg.common.http.HttpMethodType; import com.phonepe.sdk.pg.common.models.request.PgPaymentRequest; import com.phonepe.sdk.pg.common.models.request.RefundRequest; +import com.phonepe.sdk.pg.common.models.request.instruments.PaymentV2Instrument; import com.phonepe.sdk.pg.common.models.response.CallbackResponse; import com.phonepe.sdk.pg.common.models.response.OrderStatusResponse; import com.phonepe.sdk.pg.common.models.response.PgPaymentResponse; import com.phonepe.sdk.pg.common.models.response.RefundResponse; import com.phonepe.sdk.pg.common.models.response.RefundStatusResponse; import com.phonepe.sdk.pg.payments.v2.customcheckout.CustomCheckoutConstants; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.DeviceOsHeaderFilter; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.InstrumentRequestFilter; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.PayContext; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.PciHostFilter; import com.phonepe.sdk.pg.payments.v2.models.request.CreateSdkOrderRequest; +import com.phonepe.sdk.pg.payments.v2.models.request.PgPaymentFlow; import com.phonepe.sdk.pg.payments.v2.models.response.CreateSdkOrderResponse; import java.util.ArrayList; import java.util.Collections; @@ -47,6 +53,7 @@ public class CustomCheckoutClient extends BaseClient { private List headers; + private final List instrumentFilters; private CustomCheckoutClient( final String clientId, @@ -59,6 +66,9 @@ private CustomCheckoutClient( BaseEvent.buildInitClientEvent( FlowType.PG, EventType.CUSTOM_CHECKOUT_CLIENT_INITIALIZED)); this.prepareHeaders(); + // PciHostFilter must be listed before header-injecting filters (e.g. DeviceOsHeaderFilter) + // so that any future filter which reads ctx.getHostUrl() sees the PCI host already set. + this.instrumentFilters = List.of(new PciHostFilter(env), new DeviceOsHeaderFilter()); } /** @@ -110,23 +120,22 @@ public static CustomCheckoutClient getInstance( public PgPaymentResponse pay(PgPaymentRequest pgPaymentRequest) { String url = CustomCheckoutConstants.PAY_API; try { - List requestHeaders = headers; - if (pgPaymentRequest.getDeviceOS() != null && !pgPaymentRequest.getDeviceOS() - .isBlank()) { - requestHeaders = new ArrayList<>(headers); - requestHeaders.add( - HttpHeaderPair.builder() - .key(Headers.X_DEVICE_OS) - .value(pgPaymentRequest.getDeviceOS()) - .build()); - } + PaymentV2Instrument instrument = resolveInstrument(pgPaymentRequest); + PayContext ctx = new PayContext( + this.getEnv() + .getPgHostUrl(), new ArrayList<>(headers), + pgPaymentRequest.getDeviceOS()); + instrumentFilters.stream() + .filter(f -> f.supports(instrument)) + .forEach(f -> f.apply(ctx)); PgPaymentResponse pgPaymentResponse = requestViaAuthRefresh( HttpMethodType.POST, pgPaymentRequest, url, null, new TypeReference() {}, - requestHeaders); + ctx.getHeaders(), + ctx.getHostUrl()); this.eventPublisher.send( BaseEvent.buildCustomCheckoutPayEvent( EventState.SUCCESS, pgPaymentRequest, url, EventType.PAY_SUCCESS)); @@ -143,6 +152,13 @@ public PgPaymentResponse pay(PgPaymentRequest pgPaymentRequest) { } } + private PaymentV2Instrument resolveInstrument(PgPaymentRequest pgPaymentRequest) { + if (!(pgPaymentRequest.getPaymentFlow() instanceof PgPaymentFlow)) { + return null; + } + return ((PgPaymentFlow) pgPaymentRequest.getPaymentFlow()).getPaymentMode(); + } + /** * Gets the status of the order * diff --git a/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/DeviceOsHeaderFilter.java b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/DeviceOsHeaderFilter.java new file mode 100644 index 0000000..e05b854 --- /dev/null +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/DeviceOsHeaderFilter.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.phonepe.sdk.pg.payments.v2.customcheckout.filters; + +import com.phonepe.sdk.pg.common.constants.Headers; +import com.phonepe.sdk.pg.common.http.HttpHeaderPair; +import com.phonepe.sdk.pg.common.models.request.instruments.PaymentV2Instrument; + +/** + * Injects the x-device-os header when deviceOS is present on the request. + * Applies to all instrument types. + */ +public class DeviceOsHeaderFilter implements InstrumentRequestFilter { + + @Override + public boolean supports(PaymentV2Instrument instrument) { + return true; + } + + @Override + public void apply(PayContext ctx) { + if (ctx.getDeviceOS() != null && !ctx.getDeviceOS() + .isBlank()) { + ctx.getHeaders() + .add( + HttpHeaderPair.builder() + .key(Headers.X_DEVICE_OS) + .value(ctx.getDeviceOS()) + .build()); + } + } +} diff --git a/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/InstrumentRequestFilter.java b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/InstrumentRequestFilter.java new file mode 100644 index 0000000..25ee139 --- /dev/null +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/InstrumentRequestFilter.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.phonepe.sdk.pg.payments.v2.customcheckout.filters; + +import com.phonepe.sdk.pg.common.models.request.instruments.PaymentV2Instrument; + +/** + * Strategy interface for modifying pay request context (host URL, headers) + * based on the payment instrument. Implement this to add new routing or + * header injection rules without modifying CustomCheckoutClient. + */ +public interface InstrumentRequestFilter { + + /** + * Returns true if this filter applies to the given instrument. + */ + boolean supports(PaymentV2Instrument instrument); + + /** + * Applies modifications to the PayContext (host URL and/or headers). + */ + void apply(PayContext ctx); +} diff --git a/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PayContext.java b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PayContext.java new file mode 100644 index 0000000..de551bf --- /dev/null +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PayContext.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.phonepe.sdk.pg.payments.v2.customcheckout.filters; + +import com.phonepe.sdk.pg.common.http.HttpHeaderPair; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +/** + * Mutable context passed through the filter chain before a pay request is dispatched. + * Filters may update the hostUrl or add to the headers list. + */ +@Getter +@Setter +public class PayContext { + + private String hostUrl; + private List headers; + private String deviceOS; + + public PayContext(String hostUrl, List headers, String deviceOS) { + this.hostUrl = hostUrl; + this.headers = headers; + this.deviceOS = deviceOS; + } +} diff --git a/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PciHostFilter.java b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PciHostFilter.java new file mode 100644 index 0000000..f7fe85e --- /dev/null +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PciHostFilter.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.phonepe.sdk.pg.payments.v2.customcheckout.filters; + +import com.phonepe.sdk.pg.Env; +import com.phonepe.sdk.pg.common.models.request.instruments.CardPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.PaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.TokenPaymentV2Instrument; +import lombok.RequiredArgsConstructor; + +/** + * Routes PCI-scoped instruments (Card, Token) to the dedicated PCI host URL. + * Add new PCI instrument types to {@link #supports} as they are introduced. + */ +@RequiredArgsConstructor +public class PciHostFilter implements InstrumentRequestFilter { + + private final Env env; + + @Override + public boolean supports(PaymentV2Instrument instrument) { + if (instrument == null) { + return false; + } + return instrument instanceof CardPaymentV2Instrument + || instrument instanceof TokenPaymentV2Instrument; + } + + @Override + public void apply(PayContext ctx) { + ctx.setHostUrl(env.getPciPgHostUrl()); + } +} diff --git a/src/test/customCheckoutTests/CustomCheckoutPciAndDeviceOsTest.java b/src/test/customCheckoutTests/CustomCheckoutPciAndDeviceOsTest.java new file mode 100644 index 0000000..f4e89d4 --- /dev/null +++ b/src/test/customCheckoutTests/CustomCheckoutPciAndDeviceOsTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package customCheckoutTests; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.phonepe.sdk.pg.common.constants.Headers; +import com.phonepe.sdk.pg.common.models.request.PgPaymentRequest; +import com.phonepe.sdk.pg.common.models.response.PgPaymentResponse; +import com.phonepe.sdk.pg.payments.v2.customcheckout.CustomCheckoutConstants; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import wiremock.org.apache.http.HttpStatus; + +/** + * Tests for PCI host routing and DeviceOS header injection in CustomCheckoutClient.pay(). + */ +class CustomCheckoutPciAndDeviceOsTest extends CustomCheckoutBaseSetup { + + // ── PCI host routing — Card ─────────────────────────────────────────── + + @Test + void testCardPayRoutesToPciHost() { + final String url = CustomCheckoutConstants.PAY_API; + + PgPaymentRequest request = PgPaymentRequest.CardPayRequestBuilder() + .merchantOrderId("ORDER_PCI_CARD_001") + .amount(5000L) + .encryptionKeyId(1L) + .authMode("H2H") + .encryptedCardNumber("encCard123") + .encryptedCvv("encCvv456") + .expiryMonth("12") + .expiryYear("2028") + .cardHolderName("Test User") + .redirectUrl("https://merchant.com/redirect") + .build(); + + PgPaymentResponse response = PgPaymentResponse.builder() + .orderId("OMO_PCI_CARD_001") + .state("PENDING") + .expireAt(java.time.Instant.now().getEpochSecond() + 600) + .build(); + + // WireMock is on localhost:30419 = Env.TEST pciPgHostUrl — request must arrive here + addStubForPostRequest(url, getHeaders(), request, HttpStatus.SC_OK, Maps.newHashMap(), + response); + + PgPaymentResponse actual = customCheckoutClient.pay(request); + Assertions.assertEquals(response.getOrderId(), actual.getOrderId()); + } + + // ── PCI host routing — Token ────────────────────────────────────────── + + @Test + void testTokenPayRoutesToPciHost() { + final String url = CustomCheckoutConstants.PAY_API; + + PgPaymentRequest request = PgPaymentRequest.TokenPayRequestBuilder() + .merchantOrderId("ORDER_PCI_TOKEN_001") + .amount(3000L) + .encryptionKeyId(2L) + .authMode("H2H") + .cryptogram("cryptogram123") + .encryptedToken("encToken789") + .encryptedCvv("encCvv456") + .panSuffix("1234") + .expiryMonth("06") + .expiryYear("2026") + .cardHolderName("Token User") + .redirectUrl("https://merchant.com/redirect") + .build(); + + PgPaymentResponse response = PgPaymentResponse.builder() + .orderId("OMO_PCI_TOKEN_001") + .state("PENDING") + .expireAt(java.time.Instant.now().getEpochSecond() + 600) + .build(); + + addStubForPostRequest(url, getHeaders(), request, HttpStatus.SC_OK, Maps.newHashMap(), + response); + + PgPaymentResponse actual = customCheckoutClient.pay(request); + Assertions.assertEquals(response.getOrderId(), actual.getOrderId()); + } + + // ── Non-PCI instrument uses default host ────────────────────────────── + + @Test + void testUpiCollectUsesDefaultHost() { + final String url = CustomCheckoutConstants.PAY_API; + + PgPaymentRequest request = PgPaymentRequest.UpiCollectPayViaVpaRequestBuilder() + .merchantOrderId("ORDER_NON_PCI_001") + .amount(1000L) + .vpa("user@upi") + .build(); + + PgPaymentResponse response = PgPaymentResponse.builder() + .orderId("OMO_NON_PCI_001") + .state("PENDING") + .expireAt(java.time.Instant.now().getEpochSecond() + 600) + .build(); + + addStubForPostRequest(url, getHeaders(), request, HttpStatus.SC_OK, Maps.newHashMap(), + response); + + PgPaymentResponse actual = customCheckoutClient.pay(request); + Assertions.assertEquals(response.getOrderId(), actual.getOrderId()); + } + + // ── DeviceOS header injection ───────────────────────────────────────── + + @Test + void testPayInjectsDeviceOsHeaderWhenPresent() { + final String url = CustomCheckoutConstants.PAY_API; + + PgPaymentRequest request = PgPaymentRequest.UpiCollectPayViaVpaRequestBuilder() + .merchantOrderId("ORDER_DEVICE_OS_001") + .amount(1000L) + .vpa("user@upi") + .deviceOS("ANDROID") + .build(); + + PgPaymentResponse response = PgPaymentResponse.builder() + .orderId("OMO_DEVICE_OS_001") + .state("PENDING") + .expireAt(java.time.Instant.now().getEpochSecond() + 600) + .build(); + + Map headersWithDeviceOs = ImmutableMap.builder() + .putAll(getHeaders()) + .put(Headers.X_DEVICE_OS, "ANDROID") + .build(); + + addStubForPostRequest(url, headersWithDeviceOs, request, HttpStatus.SC_OK, + Maps.newHashMap(), response); + + PgPaymentResponse actual = customCheckoutClient.pay(request); + Assertions.assertEquals(response.getOrderId(), actual.getOrderId()); + } + + @Test + void testPayDoesNotInjectDeviceOsHeaderWhenAbsent() { + final String url = CustomCheckoutConstants.PAY_API; + + PgPaymentRequest request = PgPaymentRequest.UpiCollectPayViaVpaRequestBuilder() + .merchantOrderId("ORDER_NO_DEVICE_OS_001") + .amount(1000L) + .vpa("user@upi") + .build(); + + PgPaymentResponse response = PgPaymentResponse.builder() + .orderId("OMO_NO_DEVICE_OS_001") + .state("PENDING") + .expireAt(java.time.Instant.now().getEpochSecond() + 600) + .build(); + + // Stub only with base headers — if x-device-os were sent, WireMock would not match + addStubForPostRequest(url, getHeaders(), request, HttpStatus.SC_OK, Maps.newHashMap(), + response); + + PgPaymentResponse actual = customCheckoutClient.pay(request); + Assertions.assertEquals(response.getOrderId(), actual.getOrderId()); + } +} diff --git a/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java b/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java new file mode 100644 index 0000000..8795f6e --- /dev/null +++ b/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package customCheckoutTests.filters; + +import com.phonepe.sdk.pg.common.constants.Headers; +import com.phonepe.sdk.pg.common.http.HttpHeaderPair; +import com.phonepe.sdk.pg.common.models.request.instruments.CardPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.CollectPaymentV2Instrument; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.DeviceOsHeaderFilter; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.PayContext; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DeviceOsHeaderFilterTest { + + private DeviceOsHeaderFilter filter; + private final String host = "http://localhost:30419"; + + @BeforeEach + void setUp() { + filter = new DeviceOsHeaderFilter(); + } + + private PayContext ctx(String deviceOS) { + return new PayContext(host, new ArrayList(), deviceOS); + } + + // ── supports() ──────────────────────────────────────────────────────── + + @Test + void testSupportsNullInstrument() { + Assertions.assertTrue(filter.supports(null)); + } + + @Test + void testSupportsCardInstrument() { + Assertions.assertTrue( + filter.supports((CardPaymentV2Instrument) CardPaymentV2Instrument.builder().build())); + } + + @Test + void testSupportsUpiCollectInstrument() { + Assertions.assertTrue(filter.supports(CollectPaymentV2Instrument.builder().build())); + } + + // ── apply() – header injection ───────────────────────────────────────── + + @Test + void testApplyAddsDeviceOsHeaderWhenPresent() { + PayContext ctx = ctx("ANDROID"); + filter.apply(ctx); + + List headers = ctx.getHeaders(); + Assertions.assertEquals(1, headers.size()); + Assertions.assertEquals(Headers.X_DEVICE_OS, headers.get(0).getKey()); + Assertions.assertEquals("ANDROID", headers.get(0).getValue()); + } + + @Test + void testApplyAddsIosDeviceOsHeader() { + PayContext ctx = ctx("IOS"); + filter.apply(ctx); + Assertions.assertEquals(1, ctx.getHeaders().size()); + Assertions.assertEquals("IOS", ctx.getHeaders().get(0).getValue()); + } + + @Test + void testApplySkipsHeaderWhenDeviceOsIsNull() { + PayContext ctx = ctx(null); + filter.apply(ctx); + Assertions.assertTrue(ctx.getHeaders().isEmpty()); + } + + @Test + void testApplySkipsHeaderWhenDeviceOsIsBlank() { + PayContext ctx = ctx(" "); + filter.apply(ctx); + Assertions.assertTrue(ctx.getHeaders().isEmpty()); + } + + @Test + void testApplyDoesNotModifyHostUrl() { + PayContext ctx = ctx("ANDROID"); + filter.apply(ctx); + Assertions.assertEquals(host, ctx.getHostUrl()); + } +} diff --git a/src/test/customCheckoutTests/filters/PciHostFilterTest.java b/src/test/customCheckoutTests/filters/PciHostFilterTest.java new file mode 100644 index 0000000..5c67aa9 --- /dev/null +++ b/src/test/customCheckoutTests/filters/PciHostFilterTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package customCheckoutTests.filters; + +import com.phonepe.sdk.pg.Env; +import com.phonepe.sdk.pg.common.http.HttpHeaderPair; +import com.phonepe.sdk.pg.common.models.request.instruments.CardPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.CollectPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.IntentPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.NetBankingPaymentV2Instrument; +import com.phonepe.sdk.pg.common.models.request.instruments.TokenPaymentV2Instrument; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.PayContext; +import com.phonepe.sdk.pg.payments.v2.customcheckout.filters.PciHostFilter; +import java.util.ArrayList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PciHostFilterTest { + + private PciHostFilter filter; + private final String defaultHost = "http://localhost:30419"; + private final String pciHost = Env.TEST.getPciPgHostUrl(); + + @BeforeEach + void setUp() { + filter = new PciHostFilter(Env.TEST); + } + + private PayContext ctx() { + return new PayContext(defaultHost, new ArrayList(), null); + } + + // ── supports() ──────────────────────────────────────────────────────── + + @Test + void testDoesNotSupportNullInstrument() { + Assertions.assertFalse(filter.supports(null)); + } + + @Test + void testSupportsCardInstrument() { + CardPaymentV2Instrument card = (CardPaymentV2Instrument) CardPaymentV2Instrument.builder() + .build(); + Assertions.assertTrue(filter.supports(card)); + } + + @Test + void testSupportsTokenInstrument() { + TokenPaymentV2Instrument token = (TokenPaymentV2Instrument) TokenPaymentV2Instrument + .builder() + .build(); + Assertions.assertTrue(filter.supports(token)); + } + + @Test + void testDoesNotSupportUpiCollect() { + CollectPaymentV2Instrument upiCollect = CollectPaymentV2Instrument.builder().build(); + Assertions.assertFalse(filter.supports(upiCollect)); + } + + @Test + void testDoesNotSupportUpiIntent() { + IntentPaymentV2Instrument upiIntent = IntentPaymentV2Instrument.builder().build(); + Assertions.assertFalse(filter.supports(upiIntent)); + } + + @Test + void testDoesNotSupportNetBanking() { + NetBankingPaymentV2Instrument netBanking = NetBankingPaymentV2Instrument.builder().build(); + Assertions.assertFalse(filter.supports(netBanking)); + } + + // ── apply() ─────────────────────────────────────────────────────────── + + @Test + void testApplySetsHostUrlToPciHost() { + PayContext ctx = ctx(); + Assertions.assertEquals(defaultHost, ctx.getHostUrl()); + filter.apply(ctx); + Assertions.assertEquals(pciHost, ctx.getHostUrl()); + } + + @Test + void testApplyDoesNotModifyHeaders() { + PayContext ctx = ctx(); + filter.apply(ctx); + Assertions.assertTrue(ctx.getHeaders().isEmpty()); + } +} diff --git a/src/test/standardCheckoutTests/MetaInfoTest.java b/src/test/standardCheckoutTests/MetaInfoTest.java new file mode 100644 index 0000000..4fbdc1f --- /dev/null +++ b/src/test/standardCheckoutTests/MetaInfoTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Original Author(s), PhonePe India Pvt. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package standardCheckoutTests; + +import com.phonepe.sdk.pg.common.models.MetaInfo; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class MetaInfoTest { + + // ── Builder construction ────────────────────────────────────────────── + + @Test + void testBuilderCreatesInstanceWithAllUdfs() { + MetaInfo metaInfo = MetaInfo.builder() + .udf1("val1").udf2("val2").udf3("val3").udf4("val4").udf5("val5") + .udf6("val6").udf7("val7").udf8("val8").udf9("val9").udf10("val10") + .udf11("val11").udf12("val12").udf13("val13").udf14("val14").udf15("val15") + .build(); + + Assertions.assertEquals("val1", metaInfo.getUdf1()); + Assertions.assertEquals("val10", metaInfo.getUdf10()); + Assertions.assertEquals("val15", metaInfo.getUdf15()); + } + + @Test + void testBuilderAllowsNullUdfs() { + MetaInfo metaInfo = MetaInfo.builder().udf1("only-one").build(); + Assertions.assertEquals("only-one", metaInfo.getUdf1()); + Assertions.assertNull(metaInfo.getUdf2()); + Assertions.assertNull(metaInfo.getUdf11()); + } + + @Test + void testNoArgConstructorCreatesInstanceWithNullFields() { + MetaInfo metaInfo = new MetaInfo(); + Assertions.assertNull(metaInfo.getUdf1()); + Assertions.assertNull(metaInfo.getUdf5()); + Assertions.assertNull(metaInfo.getUdf15()); + } + + @Test + void testMetaInfoHas15UdfFields() { + long udfFieldCount = java.util.Arrays.stream(MetaInfo.class.getDeclaredFields()) + .filter(f -> f.getName().startsWith("udf")) + .count(); + Assertions.assertEquals(15, udfFieldCount, "MetaInfo should have exactly 15 udf fields"); + } + + @Test + void testUdf6To15FieldsExist() throws NoSuchFieldException { + for (int i = 6; i <= 15; i++) { + Field field = MetaInfo.class.getDeclaredField("udf" + i); + Assertions.assertNotNull(field); + Assertions.assertEquals(String.class, field.getType()); + } + } + + // ── udf1-10: @Size(max=256) validation ─────────────────────────────── + + @Test + void testUdf1To10AcceptsExactly256Chars() { + String val256 = "a".repeat(256); + MetaInfo metaInfo = MetaInfo.builder().udf1(val256).udf10(val256).build(); + Assertions.assertEquals(val256, metaInfo.getUdf1()); + Assertions.assertEquals(val256, metaInfo.getUdf10()); + } + + @Test + void testUdf1To10RejectsOver256Chars() { + String val257 = "a".repeat(257); + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaInfo.builder().udf5(val257).build()); + Assertions.assertTrue(ex.getMessage().contains("udf5")); + Assertions.assertTrue(ex.getMessage().contains("256")); + } + + // ── udf11-15: @Size(max=50) + @Pattern validation ──────────────────── + + @Test + void testUdf11To15AcceptsValidChars() { + MetaInfo metaInfo = MetaInfo.builder() + .udf11("Hello World").udf12("user@email.com") + .udf13("id_123").udf14("ref-456").udf15("val+1") + .build(); + Assertions.assertEquals("Hello World", metaInfo.getUdf11()); + Assertions.assertEquals("user@email.com", metaInfo.getUdf12()); + } + + @Test + void testUdf11To15RejectsOver50Chars() { + String val51 = "a".repeat(51); + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaInfo.builder().udf11(val51).build()); + Assertions.assertTrue(ex.getMessage().contains("udf11")); + Assertions.assertTrue(ex.getMessage().contains("50")); + } + + @Test + void testUdf11To15RejectsInvalidChars() { + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaInfo.builder().udf11("invalid!#$").build()); + Assertions.assertTrue(ex.getMessage().contains("udf11")); + } + + @Test + void testUdf11To15AllowsEmptyString() { + MetaInfo metaInfo = MetaInfo.builder().udf11("").build(); + Assertions.assertEquals("", metaInfo.getUdf11()); + } + + @Test + void testUdf11To15AllowsNull() { + Assertions.assertDoesNotThrow(() -> MetaInfo.builder().udf11(null).build()); + } + + // ── New fields (udf6-10) specifically validated ─────────────────────── + + @Test + void testNewFieldsUdf6To10RejectOver256Chars() { + String val257 = "a".repeat(257); + for (String field : new String[]{"udf6", "udf7", "udf8", "udf9", "udf10"}) { + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaInfo.builder().udf6(field.equals("udf6") ? val257 : "ok") + .udf7(field.equals("udf7") ? val257 : "ok") + .udf8(field.equals("udf8") ? val257 : "ok") + .udf9(field.equals("udf9") ? val257 : "ok") + .udf10(field.equals("udf10") ? val257 : "ok") + .build(), + "Expected rejection for " + field); + Assertions.assertTrue(ex.getMessage().contains(field)); + } + } + + @Test + void testUdf12To15EachRejectInvalidChars() { + String[][] cases = { + {"udf12", "bad!char"}, {"udf13", "no#hash"}, {"udf14", "no*star"}, {"udf15", "no%pct"} + }; + for (String[] tc : cases) { + String fieldName = tc[0]; + String badValue = tc[1]; + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaInfo.builder().udf12(fieldName.equals("udf12") ? badValue : "ok") + .udf13(fieldName.equals("udf13") ? badValue : "ok") + .udf14(fieldName.equals("udf14") ? badValue : "ok") + .udf15(fieldName.equals("udf15") ? badValue : "ok") + .build(), + "Expected rejection for " + fieldName); + Assertions.assertTrue(ex.getMessage().contains(fieldName)); + } + } + + @Test + void testUdf1To10DoNotApplyPatternRestriction() { + // Special chars that would fail udf11-15 pattern — should be fine for udf1-10 + String specialChars = "hello!#$%^&*()"; + Assertions.assertDoesNotThrow(() -> MetaInfo.builder() + .udf1(specialChars).udf6(specialChars).udf10(specialChars).build()); + } +} +