diff --git a/CHANGELOG.md b/CHANGELOG.md
index 342a8c13..53120116 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+### v4.11.0 (2026-06-23)
+* * *
+### Bug Fixes:
+- Fixed JSON request body serialization where `Timestamp` parameters were sent in a human-readable format (e.g. `"expires_at":"2026-06-23 09:54:44.513"`) instead of Unix seconds. They are now serialized as numeric Unix seconds (e.g. `"expires_at":1782189229`), matching the form-url-encoded path and the format expected by the API. This affects all JSON content-type endpoints, such as [`create_promotional_grant`](https://apidocs.chargebee.com/docs/api/promotional_grants/create-promotional-grant) in [`PromotionalGrant`](https://apidocs.chargebee.com/docs/api/promotional_grants).
+
+
+
### v4.10.0 (2026-06-12)
* * *
### New Resources:
diff --git a/VERSION b/VERSION
index 2da43162..a162ea75 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.10.0
+4.11.0
diff --git a/build.gradle.kts b/build.gradle.kts
index 82e550bd..7406918e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
}
group = "com.chargebee"
-version = "4.10.0"
+version = "4.11.0"
description = "Java client library for ChargeBee"
// Project metadata
diff --git a/src/main/java/com/chargebee/v4/internal/JsonUtil.java b/src/main/java/com/chargebee/v4/internal/JsonUtil.java
index 055a2dd2..9d50bc13 100644
--- a/src/main/java/com/chargebee/v4/internal/JsonUtil.java
+++ b/src/main/java/com/chargebee/v4/internal/JsonUtil.java
@@ -1,15 +1,19 @@
package com.chargebee.v4.internal;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
-import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializer;
import java.math.BigDecimal;
import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
import java.util.ArrayList;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -19,6 +23,29 @@
/** Gson-backed JSON parsing utility. */
public class JsonUtil {
+ /**
+ * Gson instance configured with Chargebee-specific serializers so that
+ * non-trivial leaf types are emitted in the shape the Chargebee API expects:
+ *
+ * - {@link Timestamp} → Unix seconds (JSON number)
+ * - {@link Date} → {@code "yyyy-MM-dd"} JSON string
+ * - {@link Enum} → lowercase {@code name()} JSON string
+ *
+ * HTML escaping is disabled so characters like {@code <}, {@code >}, {@code &}
+ * pass through verbatim (matches the previous {@code JsonElement.toString()} behavior).
+ */
+ private static final Gson GSON = new GsonBuilder()
+ .disableHtmlEscaping()
+ .serializeNulls()
+ .registerTypeAdapter(Timestamp.class,
+ (JsonSerializer) (src, t, c) -> new JsonPrimitive(src.getTime()))
+ .registerTypeAdapter(Date.class,
+ (JsonSerializer) (src, t, c) ->
+ new JsonPrimitive(new SimpleDateFormat("yyyy-MM-dd").format(src)))
+ .registerTypeHierarchyAdapter(Enum.class,
+ (JsonSerializer>) (src, t, c) -> new JsonPrimitive(src.name().toLowerCase()))
+ .create();
+
private JsonUtil() {}
// --- Parse entry points ---
@@ -320,25 +347,16 @@ public static Map extractConsentFields(JsonObject obj, Set map) {
if (map == null || map.isEmpty()) return "{}";
- JsonObject obj = new JsonObject();
- for (Map.Entry entry : map.entrySet()) {
- obj.add(entry.getKey(), toJsonElement(entry.getValue()));
- }
- return obj.toString();
+ return GSON.toJson(map);
}
- /** Serializes a List to a JSON string. */
+ /** Serializes a List to a JSON string using the configured {@link #GSON} instance. */
public static String toJson(List> list) {
if (list == null || list.isEmpty()) return "[]";
- JsonArray array = new JsonArray();
- for (Object item : list) {
- array.add(toJsonElement(item));
- }
- return array.toString();
+ return GSON.toJson(list);
}
// --- Internal helpers ---
@@ -365,27 +383,4 @@ private static Object toJavaValue(JsonElement value) {
}
return null;
}
-
- @SuppressWarnings("unchecked")
- private static JsonElement toJsonElement(Object value) {
- if (value == null) return JsonNull.INSTANCE;
- if (value instanceof String) return new JsonPrimitive((String) value);
- if (value instanceof Number) return new JsonPrimitive((Number) value);
- if (value instanceof Boolean) return new JsonPrimitive((Boolean) value);
- if (value instanceof Map) {
- JsonObject obj = new JsonObject();
- for (Map.Entry entry : ((Map) value).entrySet()) {
- obj.add(entry.getKey(), toJsonElement(entry.getValue()));
- }
- return obj;
- }
- if (value instanceof List) {
- JsonArray array = new JsonArray();
- for (Object item : (List>) value) {
- array.add(toJsonElement(item));
- }
- return array;
- }
- return new JsonPrimitive(value.toString());
- }
}
diff --git a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java
index b2111b81..743d2970 100644
--- a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java
+++ b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java
@@ -6,6 +6,7 @@
import java.math.BigDecimal;
import java.sql.Timestamp;
+import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -16,6 +17,8 @@
@DisplayName("JsonUtil Tests")
class JsonUtilTest {
+ private enum Status { ACTIVE, IN_TRIAL, NON_RENEWING }
+
// ========== parse / parseToArray ==========
@Nested
@DisplayName("parse Tests")
@@ -644,6 +647,142 @@ class ToJsonTests {
}
}
+ // ========== Timestamp / Date / Enum serialization ==========
+ // Regression coverage for a bug where java.sql.Timestamp values were emitted
+ // in human-readable form (e.g. "2026-06-23 09:54:44.513") because they fell
+ // through to the default `value.toString()` branch in toJsonElement(...).
+ // The JSON path emits Timestamp as Unix milliseconds (Timestamp.getTime()).
+ @Nested
+ @DisplayName("Timestamp / Date / Enum serialization")
+ class TimestampDateEnumSerialization {
+
+ @Test void timestampIsEmittedAsUnixMillisNumber() {
+ Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-06-23T09:54:44Z"));
+ long expected = ts.getTime();
+ Map map = new java.util.LinkedHashMap<>();
+ map.put("expires_at", ts);
+
+ String json = JsonUtil.toJson(map);
+ JsonObject parsed = JsonUtil.parse(json);
+
+ assertEquals(expected, JsonUtil.getLong(parsed, "expires_at"),
+ "Timestamp must be serialized as Unix-millis number");
+ assertTrue(json.contains("\"expires_at\":" + expected),
+ "JSON should contain numeric expires_at. Got: " + json);
+ // And must NOT be a quoted string of any shape.
+ assertFalse(json.matches(".*\"expires_at\"\\s*:\\s*\"[^\"]+\".*"),
+ "Timestamp must not be quoted. Got: " + json);
+ assertFalse(json.contains(ts.toString()),
+ "JSON must not contain Timestamp.toString() output. Got: " + json);
+ }
+
+ @Test void dateIsEmittedAsYyyyMmDdString() {
+ java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getDefault());
+ cal.clear();
+ cal.set(2025, java.util.Calendar.DECEMBER, 31, 10, 0, 0);
+ Date d = cal.getTime();
+ String expected = new java.text.SimpleDateFormat("yyyy-MM-dd").format(d);
+
+ Map map = new java.util.HashMap<>();
+ map.put("trial_end_date", d);
+
+ String json = JsonUtil.toJson(map);
+ assertEquals(expected, JsonUtil.getString(JsonUtil.parse(json), "trial_end_date"));
+ }
+
+ @Test void enumIsEmittedAsLowercaseString() {
+ Map map = new java.util.HashMap<>();
+ map.put("status", Status.IN_TRIAL);
+
+ String json = JsonUtil.toJson(map);
+ assertEquals("in_trial", JsonUtil.getString(JsonUtil.parse(json), "status"));
+ }
+
+ @Test void timestampNestedInsideMapIsConverted() {
+ Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-01-01T00:00:00Z"));
+ long expected = ts.getTime();
+
+ Map inner = new java.util.LinkedHashMap<>();
+ inner.put("seen_at", ts);
+ Map outer = new java.util.LinkedHashMap<>();
+ outer.put("metadata", inner);
+
+ JsonObject parsed = JsonUtil.parse(JsonUtil.toJson(outer));
+ JsonObject got = JsonUtil.getJsonObject(parsed, "metadata");
+ assertNotNull(got);
+ assertEquals(expected, JsonUtil.getLong(got, "seen_at"));
+ }
+
+ @Test void timestampInsideListIsConvertedPerElement() {
+ Timestamp t1 = Timestamp.from(java.time.Instant.parse("2026-01-01T00:00:00Z"));
+ Timestamp t2 = Timestamp.from(java.time.Instant.parse("2026-02-01T00:00:00Z"));
+
+ Map map = new java.util.HashMap<>();
+ map.put("checkpoints", java.util.Arrays.asList(t1, t2));
+
+ String json = JsonUtil.toJson(map);
+ JsonArray arr = JsonUtil.getJsonArray(JsonUtil.parse(json), "checkpoints");
+ assertNotNull(arr);
+ assertEquals(2, arr.size());
+ assertEquals(t1.getTime(), arr.get(0).getAsLong());
+ assertEquals(t2.getTime(), arr.get(1).getAsLong());
+ }
+
+ @Test void objectArrayIsConvertedRecursively() {
+ Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-03-01T00:00:00Z"));
+
+ Map map = new java.util.HashMap<>();
+ map.put("mixed", new Object[] { ts, "hello", 7 });
+
+ JsonArray arr = JsonUtil.getJsonArray(JsonUtil.parse(JsonUtil.toJson(map)), "mixed");
+ assertNotNull(arr);
+ assertEquals(3, arr.size());
+ assertEquals(ts.getTime(), arr.get(0).getAsLong());
+ assertEquals("hello", arr.get(1).getAsString());
+ assertEquals(7, arr.get(2).getAsInt());
+ }
+
+ @Test void deeplyNestedMapAndListAreFullyTraversed() {
+ Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-04-15T12:00:00Z"));
+ long expected = ts.getTime();
+
+ Map inner = new java.util.HashMap<>();
+ inner.put("at", ts);
+ inner.put("status", Status.ACTIVE);
+
+ Map outer = new java.util.HashMap<>();
+ outer.put("events", java.util.Arrays.asList(inner, java.util.Arrays.asList(ts, "x")));
+
+ JsonObject parsed = JsonUtil.parse(JsonUtil.toJson(outer));
+ JsonArray events = JsonUtil.getJsonArray(parsed, "events");
+ assertNotNull(events);
+
+ JsonObject first = events.get(0).getAsJsonObject();
+ assertEquals(expected, JsonUtil.getLong(first, "at"));
+ assertEquals("active", JsonUtil.getString(first, "status"));
+
+ JsonArray second = events.get(1).getAsJsonArray();
+ assertEquals(expected, second.get(0).getAsLong());
+ assertEquals("x", second.get(1).getAsString());
+ }
+
+ @Test void numericTypesAreStillEmittedAsJsonNumbers() {
+ Map map = new java.util.LinkedHashMap<>();
+ map.put("int_val", 42);
+ map.put("long_val", 1234567890123L);
+ map.put("double_val", 3.14);
+ map.put("decimal_val", new BigDecimal("19.99"));
+ map.put("bool_val", true);
+
+ JsonObject parsed = JsonUtil.parse(JsonUtil.toJson(map));
+ assertEquals(42, JsonUtil.getInteger(parsed, "int_val"));
+ assertEquals(1234567890123L, JsonUtil.getLong(parsed, "long_val"));
+ assertEquals(3.14, JsonUtil.getDouble(parsed, "double_val"), 0.0001);
+ assertEquals(new BigDecimal("19.99"), JsonUtil.getBigDecimal(parsed, "decimal_val"));
+ assertTrue(JsonUtil.getBoolean(parsed, "bool_val"));
+ }
+ }
+
// ========== Edge Cases ==========
@Nested
@DisplayName("Edge Cases")
diff --git a/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java b/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java
new file mode 100644
index 00000000..7611738e
--- /dev/null
+++ b/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java
@@ -0,0 +1,181 @@
+package com.chargebee.v4.models.promotionalGrant.params;
+
+import com.chargebee.v4.internal.JsonUtil;
+import com.chargebee.v4.transport.RequestBody;
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * End-to-end serialization test for {@link PromotionalGrantsParams}.
+ *
+ * This param class is exercised by {@code PromotionalGrantService.promotionalGrants(...)} via
+ * {@code postJson("/promotional_grants", params.toJsonString())} — i.e. the JSON content-type
+ * path. The class puts a raw {@link Timestamp} into the param map
+ * ({@code formData.put("expires_at", expiresAt)}), which was previously serialized by
+ * {@code JsonUtil.toJson(...)} as a human-readable string (e.g. "2026-06-23 09:54:44.513")
+ * — the regression scenario the Chargebee API rejected.
+ *
+ * The form-url-encoded path was always correct (handled by
+ * {@code FormRequestBody.valueToString}); these tests pin both transports.
+ */
+@DisplayName("PromotionalGrantsParams JSON/form body serialization")
+class PromotionalGrantsParamsJsonTest {
+
+ @Test
+ @DisplayName("JSON body: expires_at must be Unix-millis number, not human-readable")
+ void jsonBodyEmitsExpiresAtAsUnixMillisNumber() {
+ Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z"));
+ long expectedUnixMillis = expiresAt.getTime();
+
+ PromotionalGrantsParams params = PromotionalGrantsParams.builder()
+ .subscriptionId("1mGETgZVF2umUZq")
+ .unitId("ai_credits")
+ .amount("500")
+ .expiresAt(expiresAt)
+ .build();
+
+ String json = params.toJsonString();
+ JsonObject parsed = JsonUtil.parse(json);
+
+ assertEquals("1mGETgZVF2umUZq", JsonUtil.getString(parsed, "subscription_id"));
+ assertEquals("ai_credits", JsonUtil.getString(parsed, "unit_id"));
+ assertEquals("500", JsonUtil.getString(parsed, "amount"));
+
+ // Core regression assertion: must be a JSON number equal to Unix millis.
+ assertEquals(expectedUnixMillis, JsonUtil.getLong(parsed, "expires_at"),
+ "expires_at must be Unix millis (number) in the JSON body");
+
+ // Belt-and-braces: the raw JSON string must not embed a human-readable timestamp.
+ assertFalse(json.matches(".*\"expires_at\"\\s*:\\s*\"[^\"]+\".*"),
+ "expires_at must not be a quoted string. JSON: " + json);
+ assertFalse(json.contains(expiresAt.toString()),
+ "JSON must not contain Timestamp.toString() output. JSON: " + json);
+ }
+
+ @Test
+ @DisplayName("JSON body mirrors the user-reported snippet (Instant.now() + 1 day)")
+ void jsonBodyMatchesUserExampleShape() {
+ Timestamp expiresAt = Timestamp.from(Instant.now().plus(1, ChronoUnit.DAYS));
+ long expectedUnixMillis = expiresAt.getTime();
+
+ PromotionalGrantsParams params = PromotionalGrantsParams.builder()
+ .subscriptionId("1mGETgZVF2umUZq")
+ .unitId("ai_credits")
+ .amount("500")
+ .expiresAt(expiresAt)
+ .build();
+
+ JsonObject parsed = JsonUtil.parse(params.toJsonString());
+ assertEquals(expectedUnixMillis, JsonUtil.getLong(parsed, "expires_at"));
+ }
+
+ @Test
+ @DisplayName("JSON body: metadata Map is preserved verbatim as a JSON string field")
+ void jsonBodyPreservesMetadataJsonString() {
+ // PromotionalGrantsParams.toFormData() puts metadata as a pre-serialized JSON string:
+ // formData.put("metadata", JsonUtil.toJson(this.metadata));
+ // The outer toJsonString() must therefore emit it as a string, not double-serialize it.
+ Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z"));
+ Map meta = new LinkedHashMap<>();
+ meta.put("source", "ui");
+ meta.put("tier", 1);
+
+ PromotionalGrantsParams params = PromotionalGrantsParams.builder()
+ .subscriptionId("sub_x")
+ .unitId("ai_credits")
+ .amount("500")
+ .expiresAt(expiresAt)
+ .metadata(meta)
+ .build();
+
+ JsonObject parsed = JsonUtil.parse(params.toJsonString());
+ // expires_at still a number.
+ assertEquals(expiresAt.getTime(), JsonUtil.getLong(parsed, "expires_at"));
+ // metadata is a JSON-encoded string.
+ String metaStr = JsonUtil.getString(parsed, "metadata");
+ assertNotNull(metaStr);
+ JsonObject reparsed = JsonUtil.parse(metaStr);
+ assertEquals("ui", JsonUtil.getString(reparsed, "source"));
+ assertEquals(1L, JsonUtil.getLong(reparsed, "tier"));
+ }
+
+ @Test
+ @DisplayName("Form body: expires_at continues to be encoded as Unix seconds (unchanged behavior)")
+ void formBodyStillEncodesExpiresAtAsUnixSeconds() throws IOException {
+ Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z"));
+ long expectedUnixSeconds = expiresAt.getTime() / 1000L;
+
+ PromotionalGrantsParams params = PromotionalGrantsParams.builder()
+ .subscriptionId("1mGETgZVF2umUZq")
+ .unitId("ai_credits")
+ .amount("500")
+ .expiresAt(expiresAt)
+ .build();
+
+ RequestBody body = RequestBody.form(params.toFormData());
+ String encoded = new String(body.getBytes(), StandardCharsets.UTF_8);
+ Map form = parseFormBody(encoded);
+
+ assertEquals("1mGETgZVF2umUZq", form.get("subscription_id"));
+ assertEquals("ai_credits", form.get("unit_id"));
+ assertEquals("500", form.get("amount"));
+ assertEquals(String.valueOf(expectedUnixSeconds), form.get("expires_at"),
+ "Form path must keep emitting Unix-seconds string for Timestamp");
+ // No human-readable date fragment.
+ assertFalse(form.get("expires_at").contains(" "),
+ "Form-encoded expires_at must not contain spaces (would indicate Timestamp.toString() leak)");
+ }
+
+ @Test
+ @DisplayName("Content-Type: form and JSON transports each retain their own content-type")
+ void contentTypesAreNotSwapped() {
+ Timestamp expiresAt = Timestamp.from(Instant.parse("2026-06-23T09:54:44Z"));
+ PromotionalGrantsParams params = PromotionalGrantsParams.builder()
+ .subscriptionId("sub_x")
+ .unitId("ai_credits")
+ .amount("500")
+ .expiresAt(expiresAt)
+ .build();
+
+ RequestBody form = RequestBody.form(params.toFormData());
+ RequestBody json = RequestBody.json(params.toJsonString());
+
+ assertTrue(form.getContentType().startsWith("application/x-www-form-urlencoded"),
+ "Form content-type unchanged");
+ assertTrue(json.getContentType().startsWith("application/json"),
+ "JSON content-type unchanged");
+ }
+
+ // ---------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------
+
+ private static Map parseFormBody(String body) {
+ Map out = new LinkedHashMap<>();
+ if (body == null || body.isEmpty()) return out;
+ for (String pair : body.split("&")) {
+ int eq = pair.indexOf('=');
+ String rawKey = eq < 0 ? pair : pair.substring(0, eq);
+ String rawVal = eq < 0 ? "" : pair.substring(eq + 1);
+ try {
+ String key = java.net.URLDecoder.decode(rawKey, StandardCharsets.UTF_8.name());
+ String val = java.net.URLDecoder.decode(rawVal, StandardCharsets.UTF_8.name());
+ out.put(key, val);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return out;
+ }
+}