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());
+ }
+}
+