From 43c901c7f961bc7ce042fcf3da68aa0e7d1d456f Mon Sep 17 00:00:00 2001 From: Yogesh Kamble Date: Fri, 20 Mar 2026 14:50:45 +0530 Subject: [PATCH 1/4] feat: add PCI host routing for Card/Token instruments, expand MetaInfo to udf1-15, and add filter pattern - Add PciHostFilter and DeviceOsHeaderFilter via InstrumentRequestFilter interface - Route Card/Token instruments to cards.phonepe.com via PayContext filter chain - Expand MetaInfo from udf1-5 to udf1-15 with @Size/@Pattern constraints - Add requestViaAuthRefresh overload with explicit hostUrl in BaseClient - Add pciPgHostUrl to Env; TEST env uses TESTING_URL for WireMock compatibility - Add 26 new tests (PciHostFilter, DeviceOsHeaderFilter, integration, MetaInfo) - Bump version 2.2.2 -> 2.2.3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pom.xml | 2 +- src/main/java/com/phonepe/sdk/pg/Env.java | 12 +- .../com/phonepe/sdk/pg/common/BaseClient.java | 16 +- .../sdk/pg/common/constants/BaseUrl.java | 1 + .../sdk/pg/common/models/MetaInfo.java | 39 ++-- .../pg/payments/v2/CustomCheckoutClient.java | 36 ++-- .../filters/DeviceOsHeaderFilter.java | 45 +++++ .../filters/InstrumentRequestFilter.java | 36 ++++ .../v2/customcheckout/filters/PayContext.java | 40 ++++ .../customcheckout/filters/PciHostFilter.java | 43 +++++ .../CustomCheckoutPciAndDeviceOsTest.java | 180 ++++++++++++++++++ .../filters/DeviceOsHeaderFilterTest.java | 98 ++++++++++ .../filters/PciHostFilterTest.java | 98 ++++++++++ .../standardCheckoutTests/MetaInfoTest.java | 138 ++++++++++++++ 14 files changed, 752 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/DeviceOsHeaderFilter.java create mode 100644 src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/InstrumentRequestFilter.java create mode 100644 src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PayContext.java create mode 100644 src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PciHostFilter.java create mode 100644 src/test/customCheckoutTests/CustomCheckoutPciAndDeviceOsTest.java create mode 100644 src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java create mode 100644 src/test/customCheckoutTests/filters/PciHostFilterTest.java create mode 100644 src/test/standardCheckoutTests/MetaInfoTest.java 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..78eb5c3 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.PRODUCTION_PG_HOST_URL_PCI), 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..c9b94ff 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,22 +18,31 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; -import lombok.AllArgsConstructor; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -@Data @Builder -@AllArgsConstructor -@NoArgsConstructor -@JsonInclude(Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) -public class MetaInfo { - - private String udf1; - private String udf2; - private String udf3; - private String udf4; - private String udf5; -} +@JsonInclude(Include.NON_NULL) +public record MetaInfo( + @Size(max = 256, message = "Max allowed size is 256") String udf1, + @Size(max = 256, message = "Max allowed size is 256") String udf2, + @Size(max = 256, message = "Max allowed size is 256") String udf3, + @Size(max = 256, message = "Max allowed size is 256") String udf4, + @Size(max = 256, message = "Max allowed size is 256") String udf5, + @Size(max = 256, message = "Max allowed size is 256") String udf6, + @Size(max = 256, message = "Max allowed size is 256") String udf7, + @Size(max = 256, message = "Max allowed size is 256") String udf8, + @Size(max = 256, message = "Max allowed size is 256") String udf9, + @Size(max = 256, message = "Max allowed size is 256") String udf10, + @Pattern( + regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf11 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf11, + @Pattern( + regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf12 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf12, + @Pattern( + regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf13 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf13, + @Pattern( + regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf14 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf14, + @Pattern( + regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf15 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf15) {} 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..e33470f 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,7 @@ private CustomCheckoutClient( BaseEvent.buildInitClientEvent( FlowType.PG, EventType.CUSTOM_CHECKOUT_CLIENT_INITIALIZED)); this.prepareHeaders(); + this.instrumentFilters = List.of(new PciHostFilter(env), new DeviceOsHeaderFilter()); } /** @@ -110,23 +118,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 -> instrument != null && 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 +150,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..fe6de86 --- /dev/null +++ b/src/main/java/com/phonepe/sdk/pg/payments/v2/customcheckout/filters/PciHostFilter.java @@ -0,0 +1,43 @@ +/* + * 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) { + 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..288ed59 --- /dev/null +++ b/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java @@ -0,0 +1,98 @@ +/* + * 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 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..8e2727d --- /dev/null +++ b/src/test/customCheckoutTests/filters/PciHostFilterTest.java @@ -0,0 +1,98 @@ +/* + * 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 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..bd405bc --- /dev/null +++ b/src/test/standardCheckoutTests/MetaInfoTest.java @@ -0,0 +1,138 @@ +/* + * 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.RecordComponent; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class MetaInfoTest { + + // ── Builder / record construction ───────────────────────────────────── + + @Test + void testBuilderCreatesRecordWithAllUdfs() { + 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.udf1()); + Assertions.assertEquals("val10", metaInfo.udf10()); + Assertions.assertEquals("val15", metaInfo.udf15()); + } + + @Test + void testBuilderAllowsNullUdfs() { + MetaInfo metaInfo = MetaInfo.builder().udf1("only-one").build(); + Assertions.assertEquals("only-one", metaInfo.udf1()); + Assertions.assertNull(metaInfo.udf2()); + Assertions.assertNull(metaInfo.udf11()); + } + + // ── @Size constraints via reflection ────────────────────────────────── + + @Test + void testUdf1To10HaveMaxSize256() throws NoSuchMethodException { + String[] freeTextFields = {"udf1", "udf2", "udf3", "udf4", "udf5", "udf6", "udf7", "udf8", + "udf9", "udf10"}; + for (String fieldName : freeTextFields) { + RecordComponent component = findComponent(fieldName); + Assertions.assertNotNull(component, + "Record component not found: " + fieldName); + Size size = component.getAccessor().getAnnotation(Size.class); + Assertions.assertNotNull(size, + "@Size missing on " + fieldName); + Assertions.assertEquals(256, size.max(), + "@Size max should be 256 for " + fieldName); + } + } + + @Test + void testUdf11To15HaveMaxSize50() { + String[] restrictedFields = {"udf11", "udf12", "udf13", "udf14", "udf15"}; + for (String fieldName : restrictedFields) { + RecordComponent component = findComponent(fieldName); + Assertions.assertNotNull(component); + Size size = component.getAccessor().getAnnotation(Size.class); + Assertions.assertNotNull(size, "@Size missing on " + fieldName); + Assertions.assertEquals(50, size.max(), + "@Size max should be 50 for " + fieldName); + } + } + + // ── @Pattern constraints via reflection ─────────────────────────────── + + @Test + void testUdf11To15HavePatternAnnotation() { + String[] restrictedFields = {"udf11", "udf12", "udf13", "udf14", "udf15"}; + String expectedRegexp = "^[a-zA-Z0-9_\\- @.+]+$"; + for (String fieldName : restrictedFields) { + RecordComponent component = findComponent(fieldName); + Assertions.assertNotNull(component); + Pattern pattern = component.getAccessor().getAnnotation(Pattern.class); + Assertions.assertNotNull(pattern, "@Pattern missing on " + fieldName); + Assertions.assertEquals(expectedRegexp, pattern.regexp(), + "@Pattern regexp mismatch on " + fieldName); + } + } + + @Test + void testUdf1To10DoNotHavePatternAnnotation() { + String[] freeTextFields = {"udf1", "udf2", "udf3", "udf4", "udf5", "udf6", "udf7", "udf8", + "udf9", "udf10"}; + for (String fieldName : freeTextFields) { + RecordComponent component = findComponent(fieldName); + Assertions.assertNotNull(component); + Assertions.assertNull(component.getAccessor().getAnnotation(Pattern.class), + "udf1-10 should not have @Pattern: " + fieldName); + } + } + + // ── Expanded fields (udf6-15 are new) ──────────────────────────────── + + @Test + void testMetaInfoHas15UdfFields() { + RecordComponent[] components = MetaInfo.class.getRecordComponents(); + Assertions.assertEquals(15, components.length, + "MetaInfo should have exactly 15 udf fields"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private RecordComponent findComponent(String name) { + for (RecordComponent c : MetaInfo.class.getRecordComponents()) { + if (c.getName().equals(name)) return c; + } + return null; + } +} From 463a6157530d60ae8ddd6c824a1606b97fecc130 Mon Sep 17 00:00:00 2001 From: Yogesh Kamble Date: Tue, 24 Mar 2026 11:35:05 +0530 Subject: [PATCH 2/4] feat: MetaInfo validation and PCI routing fixes - udf1-10: max 256 chars validation - udf11-15: max 50 chars + alphanumeric pattern validation - Null/empty values always allowed across all SDKs - Java: fixed SANDBOX PCI URL; MetaInfo converted record->@Data class - Node: fixed 401 retry in requestViaAuthRefreshPci - Go: PciPgHostURL defaults to pgHostURL when empty; MetaInfo validated in Pay() - Python: null udf fields excluded from JSON serialization - PHP: separate request MetaInfo class with validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/java/com/phonepe/sdk/pg/Env.java | 2 +- .../sdk/pg/common/models/MetaInfo.java | 126 +++++++++-- .../standardCheckoutTests/MetaInfoTest.java | 198 +++++++++++------- 3 files changed, 222 insertions(+), 104 deletions(-) diff --git a/src/main/java/com/phonepe/sdk/pg/Env.java b/src/main/java/com/phonepe/sdk/pg/Env.java index 78eb5c3..beef7c0 100644 --- a/src/main/java/com/phonepe/sdk/pg/Env.java +++ b/src/main/java/com/phonepe/sdk/pg/Env.java @@ -25,7 +25,7 @@ public enum Env { BaseUrl.SANDBOX_PG_HOST_URL, BaseUrl.SANDBOX_OAUTH_HOST_URL, BaseUrl.SANDBOX_EVENTS_HOST_URL, - BaseUrl.PRODUCTION_PG_HOST_URL_PCI), PRODUCTION( + BaseUrl.SANDBOX_PG_HOST_URL), PRODUCTION( BaseUrl.PRODUCTION_PG_HOST_URL, BaseUrl.PRODUCTION_OAUTH_HOST_URL, BaseUrl.PRODUCTION_EVENTS_HOST_URL, 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 c9b94ff..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,31 +18,111 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; +import java.util.regex.Pattern; +import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +@Data @Builder +@NoArgsConstructor +@AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(Include.NON_NULL) -public record MetaInfo( - @Size(max = 256, message = "Max allowed size is 256") String udf1, - @Size(max = 256, message = "Max allowed size is 256") String udf2, - @Size(max = 256, message = "Max allowed size is 256") String udf3, - @Size(max = 256, message = "Max allowed size is 256") String udf4, - @Size(max = 256, message = "Max allowed size is 256") String udf5, - @Size(max = 256, message = "Max allowed size is 256") String udf6, - @Size(max = 256, message = "Max allowed size is 256") String udf7, - @Size(max = 256, message = "Max allowed size is 256") String udf8, - @Size(max = 256, message = "Max allowed size is 256") String udf9, - @Size(max = 256, message = "Max allowed size is 256") String udf10, - @Pattern( - regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf11 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf11, - @Pattern( - regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf12 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf12, - @Pattern( - regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf13 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf13, - @Pattern( - regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf14 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf14, - @Pattern( - regexp = "^[a-zA-Z0-9_\\- @.+]+$", message = "udf15 should only contain alphanumeric characters, underscores, hyphens, spaces, @, ., and +") @Size(max = 50, message = "Max allowed size is 50") String udf15) {} +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/test/standardCheckoutTests/MetaInfoTest.java b/src/test/standardCheckoutTests/MetaInfoTest.java index bd405bc..4fbdc1f 100644 --- a/src/test/standardCheckoutTests/MetaInfoTest.java +++ b/src/test/standardCheckoutTests/MetaInfoTest.java @@ -16,123 +16,161 @@ package standardCheckoutTests; import com.phonepe.sdk.pg.common.models.MetaInfo; -import java.lang.reflect.RecordComponent; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; +import java.lang.reflect.Field; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class MetaInfoTest { - // ── Builder / record construction ───────────────────────────────────── + // ── Builder construction ────────────────────────────────────────────── @Test - void testBuilderCreatesRecordWithAllUdfs() { + 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") + .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.udf1()); - Assertions.assertEquals("val10", metaInfo.udf10()); - Assertions.assertEquals("val15", metaInfo.udf15()); + 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.udf1()); - Assertions.assertNull(metaInfo.udf2()); - Assertions.assertNull(metaInfo.udf11()); + Assertions.assertEquals("only-one", metaInfo.getUdf1()); + Assertions.assertNull(metaInfo.getUdf2()); + Assertions.assertNull(metaInfo.getUdf11()); } - // ── @Size constraints via reflection ────────────────────────────────── + @Test + void testNoArgConstructorCreatesInstanceWithNullFields() { + MetaInfo metaInfo = new MetaInfo(); + Assertions.assertNull(metaInfo.getUdf1()); + Assertions.assertNull(metaInfo.getUdf5()); + Assertions.assertNull(metaInfo.getUdf15()); + } @Test - void testUdf1To10HaveMaxSize256() throws NoSuchMethodException { - String[] freeTextFields = {"udf1", "udf2", "udf3", "udf4", "udf5", "udf6", "udf7", "udf8", - "udf9", "udf10"}; - for (String fieldName : freeTextFields) { - RecordComponent component = findComponent(fieldName); - Assertions.assertNotNull(component, - "Record component not found: " + fieldName); - Size size = component.getAccessor().getAnnotation(Size.class); - Assertions.assertNotNull(size, - "@Size missing on " + fieldName); - Assertions.assertEquals(256, size.max(), - "@Size max should be 256 for " + fieldName); - } + 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 testUdf11To15HaveMaxSize50() { - String[] restrictedFields = {"udf11", "udf12", "udf13", "udf14", "udf15"}; - for (String fieldName : restrictedFields) { - RecordComponent component = findComponent(fieldName); - Assertions.assertNotNull(component); - Size size = component.getAccessor().getAnnotation(Size.class); - Assertions.assertNotNull(size, "@Size missing on " + fieldName); - Assertions.assertEquals(50, size.max(), - "@Size max should be 50 for " + fieldName); + 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()); } } - // ── @Pattern constraints via reflection ─────────────────────────────── + // ── udf1-10: @Size(max=256) validation ─────────────────────────────── @Test - void testUdf11To15HavePatternAnnotation() { - String[] restrictedFields = {"udf11", "udf12", "udf13", "udf14", "udf15"}; - String expectedRegexp = "^[a-zA-Z0-9_\\- @.+]+$"; - for (String fieldName : restrictedFields) { - RecordComponent component = findComponent(fieldName); - Assertions.assertNotNull(component); - Pattern pattern = component.getAccessor().getAnnotation(Pattern.class); - Assertions.assertNotNull(pattern, "@Pattern missing on " + fieldName); - Assertions.assertEquals(expectedRegexp, pattern.regexp(), - "@Pattern regexp mismatch on " + fieldName); - } + 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 testUdf1To10DoNotHavePatternAnnotation() { - String[] freeTextFields = {"udf1", "udf2", "udf3", "udf4", "udf5", "udf6", "udf7", "udf8", - "udf9", "udf10"}; - for (String fieldName : freeTextFields) { - RecordComponent component = findComponent(fieldName); - Assertions.assertNotNull(component); - Assertions.assertNull(component.getAccessor().getAnnotation(Pattern.class), - "udf1-10 should not have @Pattern: " + fieldName); - } + 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")); } - // ── Expanded fields (udf6-15 are new) ──────────────────────────────── + // ── udf11-15: @Size(max=50) + @Pattern validation ──────────────────── @Test - void testMetaInfoHas15UdfFields() { - RecordComponent[] components = MetaInfo.class.getRecordComponents(); - Assertions.assertEquals(15, components.length, - "MetaInfo should have exactly 15 udf fields"); + 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")); } - // ── Helpers ─────────────────────────────────────────────────────────── + @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 ─────────────────────── - private RecordComponent findComponent(String name) { - for (RecordComponent c : MetaInfo.class.getRecordComponents()) { - if (c.getName().equals(name)) return c; + @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)); } - return null; + } + + @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()); } } + From 973f3c7c8f1de26bc9fc076bd48f098b3655415f Mon Sep 17 00:00:00 2001 From: Yogesh Kamble1 Date: Wed, 8 Apr 2026 16:23:20 +0530 Subject: [PATCH 3/4] Added ignore logic for jvm and mvn configs in gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 0e1885ae8e0cda24a3341b57eb6f4961a00402ce Mon Sep 17 00:00:00 2001 From: "yogesh.kamble1" Date: Mon, 20 Apr 2026 15:07:08 +0530 Subject: [PATCH 4/4] Fix as per review comments --- .../com/phonepe/sdk/pg/payments/v2/CustomCheckoutClient.java | 4 +++- .../pg/payments/v2/customcheckout/filters/PciHostFilter.java | 3 +++ .../filters/DeviceOsHeaderFilterTest.java | 5 +++++ src/test/customCheckoutTests/filters/PciHostFilterTest.java | 5 +++++ 4 files changed, 16 insertions(+), 1 deletion(-) 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 e33470f..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 @@ -66,6 +66,8 @@ 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()); } @@ -124,7 +126,7 @@ public PgPaymentResponse pay(PgPaymentRequest pgPaymentRequest) { .getPgHostUrl(), new ArrayList<>(headers), pgPaymentRequest.getDeviceOS()); instrumentFilters.stream() - .filter(f -> instrument != null && f.supports(instrument)) + .filter(f -> f.supports(instrument)) .forEach(f -> f.apply(ctx)); PgPaymentResponse pgPaymentResponse = requestViaAuthRefresh( HttpMethodType.POST, 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 index fe6de86..f7fe85e 100644 --- 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 @@ -32,6 +32,9 @@ public class PciHostFilter implements InstrumentRequestFilter { @Override public boolean supports(PaymentV2Instrument instrument) { + if (instrument == null) { + return false; + } return instrument instanceof CardPaymentV2Instrument || instrument instanceof TokenPaymentV2Instrument; } diff --git a/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java b/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java index 288ed59..8795f6e 100644 --- a/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java +++ b/src/test/customCheckoutTests/filters/DeviceOsHeaderFilterTest.java @@ -43,6 +43,11 @@ private PayContext ctx(String deviceOS) { // ── supports() ──────────────────────────────────────────────────────── + @Test + void testSupportsNullInstrument() { + Assertions.assertTrue(filter.supports(null)); + } + @Test void testSupportsCardInstrument() { Assertions.assertTrue( diff --git a/src/test/customCheckoutTests/filters/PciHostFilterTest.java b/src/test/customCheckoutTests/filters/PciHostFilterTest.java index 8e2727d..5c67aa9 100644 --- a/src/test/customCheckoutTests/filters/PciHostFilterTest.java +++ b/src/test/customCheckoutTests/filters/PciHostFilterTest.java @@ -46,6 +46,11 @@ private PayContext ctx() { // ── supports() ──────────────────────────────────────────────────────── + @Test + void testDoesNotSupportNullInstrument() { + Assertions.assertFalse(filter.supports(null)); + } + @Test void testSupportsCardInstrument() { CardPaymentV2Instrument card = (CardPaymentV2Instrument) CardPaymentV2Instrument.builder()