Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.10.0
4.11.0
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plugins {
}

group = "com.chargebee"
version = "4.10.0"
version = "4.11.0"
description = "Java client library for ChargeBee"

// Project metadata
Expand Down
69 changes: 32 additions & 37 deletions src/main/java/com/chargebee/v4/internal/JsonUtil.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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:
* <ul>
* <li>{@link Timestamp} → Unix seconds (JSON number)</li>
* <li>{@link Date} → {@code "yyyy-MM-dd"} JSON string</li>
* <li>{@link Enum} → lowercase {@code name()} JSON string</li>
* </ul>
* 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<Timestamp>) (src, t, c) -> new JsonPrimitive(src.getTime()))
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize Timestamp in Unix seconds, not milliseconds.

Line 41 currently writes src.getTime(), which is epoch milliseconds. The new contract, getTimestamp(...), and the added regression tests all expect epoch seconds, so JSON requests will still send expires_at 1000x too large and miss the release’s bug fix.

Proposed fix
             .registerTypeAdapter(Timestamp.class,
-                    (JsonSerializer<Timestamp>) (src, t, c) -> new JsonPrimitive(src.getTime()))
+                    (JsonSerializer<Timestamp>) (src, t, c) -> new JsonPrimitive(src.getTime() / 1000L))

This contradicts the PR objective and the added regression assertions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.registerTypeAdapter(Timestamp.class,
(JsonSerializer<Timestamp>) (src, t, c) -> new JsonPrimitive(src.getTime()))
.registerTypeAdapter(Timestamp.class,
(JsonSerializer<Timestamp>) (src, t, c) -> new JsonPrimitive(src.getTime() / 1000L))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/chargebee/v4/internal/JsonUtil.java` around lines 40 - 41,
The Timestamp serializer in JsonUtil is still emitting epoch milliseconds via
the registered TypeAdapter, but the contract now expects Unix seconds. Update
the Timestamp serialization used by the Gson builder in JsonUtil so it converts
the value from src.getTime() to seconds before creating the JsonPrimitive, and
make sure the behavior matches getTimestamp(...) and the regression tests for
expires_at.

.registerTypeAdapter(Date.class,
(JsonSerializer<Date>) (src, t, c) ->
new JsonPrimitive(new SimpleDateFormat("yyyy-MM-dd").format(src)))
.registerTypeHierarchyAdapter(Enum.class,
(JsonSerializer<Enum<?>>) (src, t, c) -> new JsonPrimitive(src.name().toLowerCase()))
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does Java String.toLowerCase() use the default locale, and is Locale.ROOT recommended for protocol/API values?

💡 Result:

Yes, the parameterless String.toLowerCase method uses the default locale of the Java Virtual Machine (JVM), which is determined by Locale.getDefault [1][2][3]. Because the default locale depends on the environment where the code runs, using the parameterless toLowerCase or toUpperCase methods for strings that are intended to be interpreted in a locale-independent manner—such as protocol keys, programming language identifiers, filenames, or HTML tags—is strongly discouraged [2][3][4]. These methods can produce unexpected results depending on the host system's configuration. A classic example is the Turkish locale, where the character 'I' is converted to a lowercase dotless 'ı' instead of the standard 'i' [2][3][5][4]. For protocol/API values and other machine-facing identifiers, using Locale.ROOT is the recommended practice [2][3][6]. Locale.ROOT is the language- and country-neutral locale, ensuring that case conversion behaves deterministically regardless of the JVM's default locale settings [7][6][8][9]. Using Locale.ROOT (e.g., string.toLowerCase(Locale.ROOT)) provides a stable, predictable outcome suitable for canonicalization, storage, and comparison of technical identifiers [6][10][11].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- JsonUtil.java ---\n'
sed -n '1,140p' src/main/java/com/chargebee/v4/internal/JsonUtil.java | nl -ba

printf '\n--- Search toLowerCase/Locale in repo ---\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- JsonUtil.java ---\n'
sed -n '1,140p' src/main/java/com/chargebee/v4/internal/JsonUtil.java | nl -ba

printf '\n--- Search toLowerCase/Locale in repo ---\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JsonUtil.java ---'
sed -n '1,140p' src/main/java/com/chargebee/v4/internal/JsonUtil.java | nl -ba

echo
echo '--- Search toLowerCase/Locale in repo ---'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JsonUtil.java ---'
sed -n '1,140p' src/main/java/com/chargebee/v4/internal/JsonUtil.java | nl -ba

echo
echo '--- Search toLowerCase/Locale in repo ---'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JsonUtil.java ---'
awk 'NR>=1 && NR<=140 { printf("%4d %s\n", NR, $0) }' src/main/java/com/chargebee/v4/internal/JsonUtil.java

echo
echo '--- Search toLowerCase/Locale in repo ---'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 8062


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JsonUtil.java ---'
awk 'NR>=1 && NR<=140 { printf("%4d %s\n", NR, $0) }' src/main/java/com/chargebee/v4/internal/JsonUtil.java

echo
echo '--- Search toLowerCase/Locale in repo ---'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' --glob '!**/dist/**' 'toLowerCase\(|Locale\.ROOT|Locale\.getDefault|registerTypeHierarchyAdapter\(Enum\.class' src test .

Repository: chargebee/chargebee-java

Length of output: 8062


Use Locale.ROOT for enum JSON casing. src.name().toLowerCase() here follows the JVM default locale, so wire-format values can change on Turkish/default-locale systems (IN_TRIALın_trial). Use src.name().toLowerCase(Locale.ROOT) instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/chargebee/v4/internal/JsonUtil.java` around lines 45 - 46,
The enum JSON serializer in JsonUtil currently lowercases names using the JVM
default locale, which can change wire-format values on locale-sensitive systems.
Update the serializer registered in JsonUtil’s type hierarchy adapter to use
Locale.ROOT when calling toLowerCase so enum serialization stays stable
regardless of default locale.

.create();

private JsonUtil() {}

// --- Parse entry points ---
Expand Down Expand Up @@ -320,25 +347,16 @@ public static Map<String, Object> extractConsentFields(JsonObject obj, Set<Strin

// --- Serialization ---

/** Serializes a Map to a JSON string. */
@SuppressWarnings("unchecked")
/** Serializes a Map to a JSON string using the configured {@link #GSON} instance. */
public static String toJson(Map<String, Object> map) {
if (map == null || map.isEmpty()) return "{}";
JsonObject obj = new JsonObject();
for (Map.Entry<String, Object> 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 ---
Expand All @@ -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<String, Object> entry : ((Map<String, Object>) 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());
}
}
139 changes: 139 additions & 0 deletions src/test/java/com/chargebee/v4/internal/JsonUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,8 @@
@DisplayName("JsonUtil Tests")
class JsonUtilTest {

private enum Status { ACTIVE, IN_TRIAL, NON_RENEWING }

// ========== parse / parseToArray ==========
@Nested
@DisplayName("parse Tests")
Expand Down Expand Up @@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> inner = new java.util.LinkedHashMap<>();
inner.put("seen_at", ts);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> inner = new java.util.HashMap<>();
inner.put("at", ts);
inner.put("status", Status.ACTIVE);

Map<String, Object> 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<String, Object> 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")
Expand Down
Loading
Loading