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: + * + * 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; + } +}