From d191d5c2cf9cb5a82a5228d4ab631c6ba73582c8 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 23 Jun 2026 12:00:37 +0530 Subject: [PATCH 1/2] Releasing v4.11.0 --- CHANGELOG.md | 7 + VERSION | 2 +- build.gradle.kts | 2 +- .../com/chargebee/v4/internal/JsonUtil.java | 18 ++ .../chargebee/v4/internal/JsonUtilTest.java | 139 ++++++++++++++ .../PromotionalGrantsParamsJsonTest.java | 181 ++++++++++++++++++ 6 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java 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..e40ae85e 100644 --- a/src/main/java/com/chargebee/v4/internal/JsonUtil.java +++ b/src/main/java/com/chargebee/v4/internal/JsonUtil.java @@ -9,7 +9,9 @@ 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; @@ -372,6 +374,15 @@ private static JsonElement toJsonElement(Object value) { 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 Timestamp) { + return new JsonPrimitive(((Timestamp) value).getTime() / 1000L); + } + if (value instanceof Date) { + return new JsonPrimitive(new SimpleDateFormat("yyyy-MM-dd").format((Date) value)); + } + if (value instanceof Enum) { + return new JsonPrimitive(((Enum) value).name().toLowerCase()); + } if (value instanceof Map) { JsonObject obj = new JsonObject(); for (Map.Entry entry : ((Map) value).entrySet()) { @@ -386,6 +397,13 @@ private static JsonElement toJsonElement(Object value) { } return array; } + if (value instanceof Object[]) { + JsonArray array = new JsonArray(); + for (Object item : (Object[]) 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..7d399d2b 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 Chargebee API expects Unix seconds. + @Nested + @DisplayName("Timestamp / Date / Enum serialization") + class TimestampDateEnumSerialization { + + @Test void timestampIsEmittedAsUnixSecondsNumber() { + Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-06-23T09:54:44Z")); + long expected = ts.getTime() / 1000L; + 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-seconds 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() / 1000L; + + 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() / 1000L, arr.get(0).getAsLong()); + assertEquals(t2.getTime() / 1000L, 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() / 1000L, 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() / 1000L; + + 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..0a74c722 --- /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-seconds number, not human-readable") + void jsonBodyEmitsExpiresAtAsUnixSecondsNumber() { + 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(); + + 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 seconds. + assertEquals(expectedUnixSeconds, JsonUtil.getLong(parsed, "expires_at"), + "expires_at must be Unix seconds (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 expectedUnixSeconds = expiresAt.getTime() / 1000L; + + PromotionalGrantsParams params = PromotionalGrantsParams.builder() + .subscriptionId("1mGETgZVF2umUZq") + .unitId("ai_credits") + .amount("500") + .expiresAt(expiresAt) + .build(); + + JsonObject parsed = JsonUtil.parse(params.toJsonString()); + assertEquals(expectedUnixSeconds, 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() / 1000L, 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; + } +} From e8fe429dced87aac13e71b656610f309e0db2bd0 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 23 Jun 2026 14:35:20 +0530 Subject: [PATCH 2/2] Refactor JsonUtil: serialize Timestamp/Date/Enum via Gson type adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled `instanceof`-chain inside `toJsonElement(...)` with a single static `Gson` instance configured via `GsonBuilder`: - `Timestamp` → Unix-seconds JSON number (`JsonSerializer`) - `Date` → `yyyy-MM-dd` JSON string (`JsonSerializer`) - `Enum` → lowercase `name()` string (`registerTypeHierarchyAdapter`) `toJson(Map)` and `toJson(List)` now delegate to `GSON.toJson(...)`; Gson natively walks nested maps, lists, and `Object[]` and dispatches each value through the registered adapters via its `ObjectTypeAdapter`. The builder also calls `disableHtmlEscaping()` and `serializeNulls()` to preserve the prior behavior (`JsonElement.toString()` semantics and the explicit `"key":null` shape). No behavior change for the Chargebee API contract; the full JsonUtil and PromotionalGrantsParams regression suites pass. Co-authored-by: Cursor --- .../com/chargebee/v4/internal/JsonUtil.java | 83 +++++++------------ .../chargebee/v4/internal/JsonUtilTest.java | 18 ++-- .../PromotionalGrantsParamsJsonTest.java | 18 ++-- 3 files changed, 48 insertions(+), 71 deletions(-) diff --git a/src/main/java/com/chargebee/v4/internal/JsonUtil.java b/src/main/java/com/chargebee/v4/internal/JsonUtil.java index e40ae85e..9d50bc13 100644 --- a/src/main/java/com/chargebee/v4/internal/JsonUtil.java +++ b/src/main/java/com/chargebee/v4/internal/JsonUtil.java @@ -1,11 +1,13 @@ 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; @@ -21,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 --- @@ -322,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 --- @@ -367,43 +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 Timestamp) { - return new JsonPrimitive(((Timestamp) value).getTime() / 1000L); - } - if (value instanceof Date) { - return new JsonPrimitive(new SimpleDateFormat("yyyy-MM-dd").format((Date) value)); - } - if (value instanceof Enum) { - return new JsonPrimitive(((Enum) value).name().toLowerCase()); - } - 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; - } - if (value instanceof Object[]) { - JsonArray array = new JsonArray(); - for (Object item : (Object[]) 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 7d399d2b..743d2970 100644 --- a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java +++ b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java @@ -651,14 +651,14 @@ class ToJsonTests { // 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 Chargebee API expects Unix seconds. + // The JSON path emits Timestamp as Unix milliseconds (Timestamp.getTime()). @Nested @DisplayName("Timestamp / Date / Enum serialization") class TimestampDateEnumSerialization { - @Test void timestampIsEmittedAsUnixSecondsNumber() { + @Test void timestampIsEmittedAsUnixMillisNumber() { Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-06-23T09:54:44Z")); - long expected = ts.getTime() / 1000L; + long expected = ts.getTime(); Map map = new java.util.LinkedHashMap<>(); map.put("expires_at", ts); @@ -666,7 +666,7 @@ class TimestampDateEnumSerialization { JsonObject parsed = JsonUtil.parse(json); assertEquals(expected, JsonUtil.getLong(parsed, "expires_at"), - "Timestamp must be serialized as Unix-seconds number"); + "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. @@ -700,7 +700,7 @@ class TimestampDateEnumSerialization { @Test void timestampNestedInsideMapIsConverted() { Timestamp ts = Timestamp.from(java.time.Instant.parse("2026-01-01T00:00:00Z")); - long expected = ts.getTime() / 1000L; + long expected = ts.getTime(); Map inner = new java.util.LinkedHashMap<>(); inner.put("seen_at", ts); @@ -724,8 +724,8 @@ class TimestampDateEnumSerialization { JsonArray arr = JsonUtil.getJsonArray(JsonUtil.parse(json), "checkpoints"); assertNotNull(arr); assertEquals(2, arr.size()); - assertEquals(t1.getTime() / 1000L, arr.get(0).getAsLong()); - assertEquals(t2.getTime() / 1000L, arr.get(1).getAsLong()); + assertEquals(t1.getTime(), arr.get(0).getAsLong()); + assertEquals(t2.getTime(), arr.get(1).getAsLong()); } @Test void objectArrayIsConvertedRecursively() { @@ -737,14 +737,14 @@ class TimestampDateEnumSerialization { JsonArray arr = JsonUtil.getJsonArray(JsonUtil.parse(JsonUtil.toJson(map)), "mixed"); assertNotNull(arr); assertEquals(3, arr.size()); - assertEquals(ts.getTime() / 1000L, arr.get(0).getAsLong()); + 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() / 1000L; + long expected = ts.getTime(); Map inner = new java.util.HashMap<>(); inner.put("at", ts); 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 index 0a74c722..7611738e 100644 --- a/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java +++ b/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java @@ -33,10 +33,10 @@ class PromotionalGrantsParamsJsonTest { @Test - @DisplayName("JSON body: expires_at must be Unix-seconds number, not human-readable") - void jsonBodyEmitsExpiresAtAsUnixSecondsNumber() { + @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 expectedUnixSeconds = expiresAt.getTime() / 1000L; + long expectedUnixMillis = expiresAt.getTime(); PromotionalGrantsParams params = PromotionalGrantsParams.builder() .subscriptionId("1mGETgZVF2umUZq") @@ -52,9 +52,9 @@ void jsonBodyEmitsExpiresAtAsUnixSecondsNumber() { 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 seconds. - assertEquals(expectedUnixSeconds, JsonUtil.getLong(parsed, "expires_at"), - "expires_at must be Unix seconds (number) in the JSON body"); + // 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*\"[^\"]+\".*"), @@ -67,7 +67,7 @@ void jsonBodyEmitsExpiresAtAsUnixSecondsNumber() { @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 expectedUnixSeconds = expiresAt.getTime() / 1000L; + long expectedUnixMillis = expiresAt.getTime(); PromotionalGrantsParams params = PromotionalGrantsParams.builder() .subscriptionId("1mGETgZVF2umUZq") @@ -77,7 +77,7 @@ void jsonBodyMatchesUserExampleShape() { .build(); JsonObject parsed = JsonUtil.parse(params.toJsonString()); - assertEquals(expectedUnixSeconds, JsonUtil.getLong(parsed, "expires_at")); + assertEquals(expectedUnixMillis, JsonUtil.getLong(parsed, "expires_at")); } @Test @@ -101,7 +101,7 @@ void jsonBodyPreservesMetadataJsonString() { JsonObject parsed = JsonUtil.parse(params.toJsonString()); // expires_at still a number. - assertEquals(expiresAt.getTime() / 1000L, JsonUtil.getLong(parsed, "expires_at")); + assertEquals(expiresAt.getTime(), JsonUtil.getLong(parsed, "expires_at")); // metadata is a JSON-encoded string. String metaStr = JsonUtil.getString(parsed, "metadata"); assertNotNull(metaStr);