diff --git a/CHANGELOG.md b/CHANGELOG.md index 342a8c13e..1502792ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### v4.11.0 (2026-06-29) +* * * +### 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). +- Fixed JSON request body serialization for endpoints that accept nested objects and arrays. Previously the JSON body reused the `application/x-www-form-urlencoded` flattening (e.g. `events[deduplication_id][]`) and double-encoded nested objects; the JSON content-type now emits a proper nested structure. This affects JSON endpoints such as [`ingest_usages_in_batch`](https://apidocs.chargebee.com/docs/api/usage_events/ingest-usages-in-batch) and [`create_a_usage_event`](https://apidocs.chargebee.com/docs/api/usage_events/create-a-usage-event) in [`UsageEvent`](https://apidocs.chargebee.com/docs/api/usage_events). +- Fixed `usage_timestamp` for [`UsageEvent`](https://apidocs.chargebee.com/docs/api/usage_events) to be sent as epoch milliseconds. The `usage_timestamp` parameter in [`create_a_usage_event`](https://apidocs.chargebee.com/docs/api/usage_events/create-a-usage-event) and [`ingest_usages_in_batch`](https://apidocs.chargebee.com/docs/api/usage_events/ingest-usages-in-batch) is now typed as `Long` (epoch milliseconds) instead of `Timestamp`, preserving the millisecond precision required by the ingestion API. + + + ### v4.10.0 (2026-06-12) * * * ### New Resources: diff --git a/VERSION b/VERSION index 2da431623..a162ea75a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.10.0 +4.11.0 diff --git a/build.gradle.kts b/build.gradle.kts index 82e550bd2..7406918ef 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 055a2dd23..9d50bc139 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/main/java/com/chargebee/v4/models/entitlement/params/EntitlementListParams.java b/src/main/java/com/chargebee/v4/models/entitlement/params/EntitlementListParams.java index b10179b6d..22469fb15 100644 --- a/src/main/java/com/chargebee/v4/models/entitlement/params/EntitlementListParams.java +++ b/src/main/java/com/chargebee/v4/models/entitlement/params/EntitlementListParams.java @@ -157,7 +157,7 @@ public static final class EntityIdFilter extends StringFilter toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.id != null) { + + jsonData.put("id", this.id); + } + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.unitId != null) { + + jsonData.put("unit_id", this.unitId); + } + + if (this.amount != null) { + + jsonData.put("amount", this.amount); + } + + if (this.ledgerOperationTimestamp != null) { + + jsonData.put("ledger_operation_timestamp", this.ledgerOperationTimestamp); + } + + if (this.autoReleaseTimestamp != null) { + + jsonData.put("auto_release_timestamp", this.autoReleaseTimestamp); + } + + if (this.metadata != null) { + + jsonData.put("metadata", this.metadata); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for LedgerOperationAuthorizeParams. */ diff --git a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureAuthorizationParams.java b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureAuthorizationParams.java index 9c5838838..c3401ced9 100644 --- a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureAuthorizationParams.java +++ b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureAuthorizationParams.java @@ -91,9 +91,49 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.authorizationId != null) { + + jsonData.put("authorization_id", this.authorizationId); + } + + if (this.id != null) { + + jsonData.put("id", this.id); + } + + if (this.amount != null) { + + jsonData.put("amount", this.amount); + } + + if (this.ledgerOperationTimestamp != null) { + + jsonData.put("ledger_operation_timestamp", this.ledgerOperationTimestamp); + } + + if (this.metadata != null) { + + jsonData.put("metadata", this.metadata); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for LedgerOperationCaptureAuthorizationParams. */ diff --git a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureParams.java b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureParams.java index afcbb2982..0defd8f65 100644 --- a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureParams.java +++ b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationCaptureParams.java @@ -103,9 +103,54 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.id != null) { + + jsonData.put("id", this.id); + } + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.unitId != null) { + + jsonData.put("unit_id", this.unitId); + } + + if (this.amount != null) { + + jsonData.put("amount", this.amount); + } + + if (this.ledgerOperationTimestamp != null) { + + jsonData.put("ledger_operation_timestamp", this.ledgerOperationTimestamp); + } + + if (this.metadata != null) { + + jsonData.put("metadata", this.metadata); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for LedgerOperationCaptureParams. */ diff --git a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationReleaseAuthorizationParams.java b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationReleaseAuthorizationParams.java index 75a26187e..5282594ab 100644 --- a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationReleaseAuthorizationParams.java +++ b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/LedgerOperationReleaseAuthorizationParams.java @@ -78,9 +78,44 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.authorizationId != null) { + + jsonData.put("authorization_id", this.authorizationId); + } + + if (this.id != null) { + + jsonData.put("id", this.id); + } + + if (this.ledgerOperationTimestamp != null) { + + jsonData.put("ledger_operation_timestamp", this.ledgerOperationTimestamp); + } + + if (this.metadata != null) { + + jsonData.put("metadata", this.metadata); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for LedgerOperationReleaseAuthorizationParams. */ diff --git a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/ListLedgerOperationsParams.java b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/ListLedgerOperationsParams.java index 5de0838d7..4816665c8 100644 --- a/src/main/java/com/chargebee/v4/models/ledgerOperation/params/ListLedgerOperationsParams.java +++ b/src/main/java/com/chargebee/v4/models/ledgerOperation/params/ListLedgerOperationsParams.java @@ -198,7 +198,7 @@ public ListLedgerOperationsBuilder desc() { } } - public enum TypeIs { + public enum TypeIn { ALLOCATION("allocation"), CAPTURE("capture"), @@ -217,11 +217,11 @@ public enum TypeIs { ADJUSTMENT("adjustment"), - /** An enum member indicating that TypeIs was instantiated with an unknown value. */ + /** An enum member indicating that TypeIn was instantiated with an unknown value. */ _UNKNOWN(null); private final String value; - TypeIs(String value) { + TypeIn(String value) { this.value = value; } @@ -229,9 +229,9 @@ public String getValue() { return value; } - public static TypeIs fromString(String value) { + public static TypeIn fromString(String value) { if (value == null) return _UNKNOWN; - for (TypeIs enumValue : TypeIs.values()) { + for (TypeIn enumValue : TypeIn.values()) { if (enumValue.value != null && enumValue.value.equals(value)) { return enumValue; } @@ -240,7 +240,7 @@ public static TypeIs fromString(String value) { } } - public enum TypeIn { + public enum TypeIs { ALLOCATION("allocation"), CAPTURE("capture"), @@ -259,11 +259,11 @@ public enum TypeIn { ADJUSTMENT("adjustment"), - /** An enum member indicating that TypeIn was instantiated with an unknown value. */ + /** An enum member indicating that TypeIs was instantiated with an unknown value. */ _UNKNOWN(null); private final String value; - TypeIn(String value) { + TypeIs(String value) { this.value = value; } @@ -271,9 +271,9 @@ public String getValue() { return value; } - public static TypeIn fromString(String value) { + public static TypeIs fromString(String value) { if (value == null) return _UNKNOWN; - for (TypeIn enumValue : TypeIn.values()) { + for (TypeIs enumValue : TypeIs.values()) { if (enumValue.value != null && enumValue.value.equals(value)) { return enumValue; } diff --git a/src/main/java/com/chargebee/v4/models/offerEvent/params/OfferEventsParams.java b/src/main/java/com/chargebee/v4/models/offerEvent/params/OfferEventsParams.java index b52f60f9a..8f638c50c 100644 --- a/src/main/java/com/chargebee/v4/models/offerEvent/params/OfferEventsParams.java +++ b/src/main/java/com/chargebee/v4/models/offerEvent/params/OfferEventsParams.java @@ -50,9 +50,34 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.personalizedOfferId != null) { + + jsonData.put("personalized_offer_id", this.personalizedOfferId); + } + + if (this.type != null) { + + jsonData.put("type", this.type); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for OfferEventsParams. */ diff --git a/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsParams.java b/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsParams.java index 38be13bd5..0e639d897 100644 --- a/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsParams.java +++ b/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsParams.java @@ -50,9 +50,34 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.personalizedOfferId != null) { + + jsonData.put("personalized_offer_id", this.personalizedOfferId); + } + + if (this.optionId != null) { + + jsonData.put("option_id", this.optionId); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for OfferFulfillmentsParams. */ diff --git a/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsUpdateParams.java b/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsUpdateParams.java index 20e915ca4..e814873a8 100644 --- a/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsUpdateParams.java +++ b/src/main/java/com/chargebee/v4/models/offerFulfillment/params/OfferFulfillmentsUpdateParams.java @@ -63,9 +63,39 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.id != null) { + + jsonData.put("id", this.id); + } + + if (this.status != null) { + + jsonData.put("status", this.status); + } + + if (this.failureReason != null) { + + jsonData.put("failure_reason", this.failureReason); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for OfferFulfillmentsUpdateParams. */ diff --git a/src/main/java/com/chargebee/v4/models/personalizedOffer/params/PersonalizedOffersParams.java b/src/main/java/com/chargebee/v4/models/personalizedOffer/params/PersonalizedOffersParams.java index 485de130c..4ef964fde 100644 --- a/src/main/java/com/chargebee/v4/models/personalizedOffer/params/PersonalizedOffersParams.java +++ b/src/main/java/com/chargebee/v4/models/personalizedOffer/params/PersonalizedOffersParams.java @@ -147,9 +147,70 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.firstName != null) { + + jsonData.put("first_name", this.firstName); + } + + if (this.lastName != null) { + + jsonData.put("last_name", this.lastName); + } + + if (this.email != null) { + + jsonData.put("email", this.email); + } + + if (this.roles != null) { + + jsonData.put("roles", this.roles); + } + + if (this.externalUserId != null) { + + jsonData.put("external_user_id", this.externalUserId); + } + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.customerId != null) { + + jsonData.put("customer_id", this.customerId); + } + + if (this.custom != null) { + + jsonData.put("custom", this.custom); + } + + if (this.requestContext != null) { + + // Single object -> nested JSON object + jsonData.put("request_context", this.requestContext.toJsonMap()); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for PersonalizedOffersParams. */ @@ -307,9 +368,49 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf + * values (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} + * during serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.userAgent != null) { + + jsonData.put("user_agent", this.userAgent); + } + + if (this.locale != null) { + + jsonData.put("locale", this.locale); + } + + if (this.timezone != null) { + + jsonData.put("timezone", this.timezone); + } + + if (this.url != null) { + + jsonData.put("url", this.url); + } + + if (this.referrerUrl != null) { + + jsonData.put("referrer_url", this.referrerUrl); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for RequestContextParams. */ diff --git a/src/main/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParams.java b/src/main/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParams.java index b60db290a..a15b5c22b 100644 --- a/src/main/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParams.java +++ b/src/main/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParams.java @@ -90,9 +90,49 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.unitId != null) { + + jsonData.put("unit_id", this.unitId); + } + + if (this.amount != null) { + + jsonData.put("amount", this.amount); + } + + if (this.expiresAt != null) { + + jsonData.put("expires_at", this.expiresAt); + } + + if (this.metadata != null) { + + jsonData.put("metadata", this.metadata); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for PromotionalGrantsParams. */ diff --git a/src/main/java/com/chargebee/v4/models/ramp/params/RampListParams.java b/src/main/java/com/chargebee/v4/models/ramp/params/RampListParams.java index 3d48cea1b..2fe4523fb 100644 --- a/src/main/java/com/chargebee/v4/models/ramp/params/RampListParams.java +++ b/src/main/java/com/chargebee/v4/models/ramp/params/RampListParams.java @@ -202,7 +202,7 @@ public RampListBuilder desc() { } } - public enum StatusIs { + public enum StatusIn { SCHEDULED("scheduled"), SUCCEEDED("succeeded"), @@ -211,11 +211,11 @@ public enum StatusIs { DRAFT("draft"), - /** An enum member indicating that StatusIs was instantiated with an unknown value. */ + /** An enum member indicating that StatusIn was instantiated with an unknown value. */ _UNKNOWN(null); private final String value; - StatusIs(String value) { + StatusIn(String value) { this.value = value; } @@ -223,9 +223,9 @@ public String getValue() { return value; } - public static StatusIs fromString(String value) { + public static StatusIn fromString(String value) { if (value == null) return _UNKNOWN; - for (StatusIs enumValue : StatusIs.values()) { + for (StatusIn enumValue : StatusIn.values()) { if (enumValue.value != null && enumValue.value.equals(value)) { return enumValue; } @@ -234,7 +234,7 @@ public static StatusIs fromString(String value) { } } - public enum StatusIn { + public enum StatusIs { SCHEDULED("scheduled"), SUCCEEDED("succeeded"), @@ -243,11 +243,11 @@ public enum StatusIn { DRAFT("draft"), - /** An enum member indicating that StatusIn was instantiated with an unknown value. */ + /** An enum member indicating that StatusIs was instantiated with an unknown value. */ _UNKNOWN(null); private final String value; - StatusIn(String value) { + StatusIs(String value) { this.value = value; } @@ -255,9 +255,9 @@ public String getValue() { return value; } - public static StatusIn fromString(String value) { + public static StatusIs fromString(String value) { if (value == null) return _UNKNOWN; - for (StatusIn enumValue : StatusIn.values()) { + for (StatusIs enumValue : StatusIs.values()) { if (enumValue.value != null && enumValue.value.equals(value)) { return enumValue; } diff --git a/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventBatchIngestParams.java b/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventBatchIngestParams.java index 63ef69c3f..8836fe061 100644 --- a/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventBatchIngestParams.java +++ b/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventBatchIngestParams.java @@ -9,10 +9,10 @@ import com.chargebee.v4.internal.Recommended; import com.chargebee.v4.internal.JsonUtil; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.Map; import java.util.List; -import java.sql.Timestamp; public final class UsageEventBatchIngestParams { @@ -49,9 +49,36 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.events != null) { + + // List of objects -> JSON array of nested objects + List eventsJson = new ArrayList<>(); + for (EventsParams item : this.events) { + if (item != null) { + eventsJson.add(item.toJsonMap()); + } + } + jsonData.put("events", eventsJson); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for UsageEventBatchIngestParams. */ @@ -82,7 +109,7 @@ public static final class EventsParams { private final String subscriptionId; - private final Timestamp usageTimestamp; + private final Long usageTimestamp; private final java.util.Map properties; @@ -105,7 +132,7 @@ public String getSubscriptionId() { return subscriptionId; } - public Timestamp getUsageTimestamp() { + public Long getUsageTimestamp() { return usageTimestamp; } @@ -140,9 +167,44 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf + * values (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} + * during serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.deduplicationId != null) { + + jsonData.put("deduplication_id", this.deduplicationId); + } + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.usageTimestamp != null) { + + jsonData.put("usage_timestamp", this.usageTimestamp); + } + + if (this.properties != null) { + + jsonData.put("properties", this.properties); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for EventsParams. */ @@ -157,7 +219,7 @@ public static final class EventsBuilder { private String subscriptionId; - private Timestamp usageTimestamp; + private Long usageTimestamp; private java.util.Map properties; @@ -173,7 +235,7 @@ public EventsBuilder subscriptionId(String value) { return this; } - public EventsBuilder usageTimestamp(Timestamp value) { + public EventsBuilder usageTimestamp(Long value) { this.usageTimestamp = value; return this; } diff --git a/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventCreateParams.java b/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventCreateParams.java index 0a99129b8..0a3859d17 100644 --- a/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventCreateParams.java +++ b/src/main/java/com/chargebee/v4/models/usageEvent/params/UsageEventCreateParams.java @@ -76,9 +76,44 @@ public Map toFormData() { return formData; } + /** + * Get the nested JSON body representation for this request. + * + *

Unlike {@link #toFormData()}, which flattens nested objects/arrays into bracketed {@code + * qs}-style keys for {@code application/x-www-form-urlencoded} requests, this method preserves + * the real object/array hierarchy so the payload can be serialized as a JSON string. Leaf values + * (e.g. {@code Timestamp}) are converted to their API representation by {@link JsonUtil} during + * serialization. + */ + public Map toJsonMap() { + Map jsonData = new LinkedHashMap<>(); + + if (this.deduplicationId != null) { + + jsonData.put("deduplication_id", this.deduplicationId); + } + + if (this.subscriptionId != null) { + + jsonData.put("subscription_id", this.subscriptionId); + } + + if (this.usageTimestamp != null) { + + jsonData.put("usage_timestamp", this.usageTimestamp); + } + + if (this.properties != null) { + + jsonData.put("properties", this.properties); + } + + return jsonData; + } + /** Get the JSON string representation for this request. */ public String toJsonString() { - return JsonUtil.toJson(toFormData()); + return JsonUtil.toJson(toJsonMap()); } /** Create a new builder for UsageEventCreateParams. */ diff --git a/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java b/src/test/java/com/chargebee/v4/internal/JsonUtilTest.java index b2111b812..743d2970b 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 000000000..550d026f3 --- /dev/null +++ b/src/test/java/com/chargebee/v4/models/promotionalGrant/params/PromotionalGrantsParamsJsonTest.java @@ -0,0 +1,182 @@ +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 as a nested JSON object") + void jsonBodyPreservesMetadataJsonObject() { + // toFormData() flattens metadata into a pre-serialized JSON string for the + // form-urlencoded transport, but the JSON transport (toJsonMap()/toJsonString()) + // must keep it as a real nested JSON object rather than a double-encoded string. + 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(); + + String json = params.toJsonString(); + JsonObject parsed = JsonUtil.parse(json); + // expires_at still a number. + assertEquals(expiresAt.getTime(), JsonUtil.getLong(parsed, "expires_at")); + // metadata is a nested JSON object, not a quoted/stringified JSON value. + assertTrue(parsed.get("metadata") != null && parsed.get("metadata").isJsonObject(), + "metadata must be a nested JSON object in the JSON body. JSON: " + json); + JsonObject metaObj = parsed.getAsJsonObject("metadata"); + assertEquals("ui", JsonUtil.getString(metaObj, "source")); + assertEquals(1L, JsonUtil.getLong(metaObj, "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; + } +}