diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java
index 3ac3993..7662637 100644
--- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java
+++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java
@@ -6,6 +6,16 @@
*/
public enum AmountRepresentation {
+ /**
+ * Default representation (inherit module-level configuration).
+ * When used in field-level annotation, indicates that the field should use
+ * the module's default representation.
+ *
+ *
+ * @since 3.1
+ */
+ DEFAULT,
+
/**
* Decimal number representation, where amount is (de)serialized as decimal number equal
* to {@link org.joda.money.Money Money}'s amount, e.g. {@code 12.34} for
diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java
new file mode 100644
index 0000000..9c36122
--- /dev/null
+++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java
@@ -0,0 +1,49 @@
+package tools.jackson.datatype.jodamoney;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.fasterxml.jackson.annotation.JacksonAnnotation;
+
+/**
+ * Annotation for configuring serialization and deserialization of {@link org.joda.money.Money Money}
+ * at the field level. This annotation allows per-property override of the amount representation
+ * used when converting Money to/from JSON.
+ *
+ * When applied to a field, getter, or constructor parameter, this annotation takes precedence over
+ * the module-level configuration set via {@link JodaMoneyModule#withAmountRepresentation(AmountRepresentation)}.
+ *
+ * Example usage:
+ *
+ * public class Payment {
+ * @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ * private Money amount;
+ *
+ * @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
+ * private Money fee;
+ * }
+ *
+ *
+ * @see AmountRepresentation
+ * @see JodaMoneyModule#withAmountRepresentation(AmountRepresentation)
+ *
+ * @since 3.1
+ */
+@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@JacksonAnnotation
+public @interface JodaMoney {
+
+ /**
+ * Specifies the amount representation to use for this property.
+ *
+ * Defaults to {@link AmountRepresentation#DEFAULT}, which means the property
+ * will use the module-level configuration or the built-in default
+ * ({@link AmountRepresentation#DECIMAL_NUMBER}).
+ *
+ * @return the amount representation to use
+ */
+ AmountRepresentation amountRepresentation() default AmountRepresentation.DEFAULT;
+}
diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java
index a713743..10f0b4f 100644
--- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java
+++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java
@@ -53,6 +53,7 @@ public void setupModule(SetupContext context)
public JodaMoneyModule withAmountRepresentation(final AmountRepresentation representation) {
switch (representation) {
+ case DEFAULT:
case DECIMAL_NUMBER:
return new JodaMoneyModule(DecimalNumberAmountConverter.getInstance());
case DECIMAL_STRING:
diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java
index a887572..b3f115f 100644
--- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java
+++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java
@@ -4,11 +4,14 @@
import java.util.Arrays;
import java.util.Collection;
+import com.fasterxml.jackson.annotation.JsonFormat;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
+import tools.jackson.databind.BeanProperty;
import tools.jackson.databind.DeserializationContext;
+import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.deser.std.StdDeserializer;
import tools.jackson.databind.jsontype.TypeDeserializer;
import tools.jackson.databind.type.LogicalType;
@@ -20,8 +23,8 @@
public class MoneyDeserializer extends StdDeserializer
{
- private final String F_AMOUNT = "amount";
- private final String F_CURRENCY = "currency";
+ private static final String F_AMOUNT = "amount";
+ private static final String F_CURRENCY = "currency";
private final AmountConverter amountConverter;
// Kept to maintain backward compatibility with 2.x
@@ -35,6 +38,74 @@ public MoneyDeserializer() {
this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null");
}
+ @Override
+ public ValueDeserializer> createContextual(DeserializationContext ctxt, BeanProperty property) {
+ if (property == null) {
+ return this;
+ }
+
+ AmountRepresentation effectiveRepresentation = _resolveRepresentation(property, ctxt);
+
+ if (effectiveRepresentation == null || effectiveRepresentation == AmountRepresentation.DEFAULT) {
+ // Keep current converter (module-level default)
+ return this;
+ }
+
+ AmountConverter newConverter = _getConverterForRepresentation(effectiveRepresentation);
+ if (newConverter == this.amountConverter) {
+ return this;
+ }
+
+ return new MoneyDeserializer(newConverter);
+ }
+
+ private AmountRepresentation _resolveRepresentation(BeanProperty property, DeserializationContext ctxt) {
+ // Priority 1: @JodaMoney annotation
+ JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class);
+ if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) {
+ return jodaMoney.amountRepresentation();
+ }
+
+ // Priority 2: @JsonFormat mapping
+ JsonFormat.Value format = property.findPropertyFormat(ctxt.getConfig(), Money.class);
+ if (format != null && format.getShape() != JsonFormat.Shape.ANY) {
+ AmountRepresentation mapped = _mapShapeToRepresentation(format.getShape());
+ if (mapped != null) {
+ return mapped;
+ }
+ }
+
+ // Priority 3 & 4: Module default or built-in default (already in amountConverter)
+ return null;
+ }
+
+ private AmountRepresentation _mapShapeToRepresentation(JsonFormat.Shape shape) {
+ switch (shape) {
+ case STRING:
+ return AmountRepresentation.DECIMAL_STRING;
+ case NUMBER:
+ case NUMBER_FLOAT:
+ return AmountRepresentation.DECIMAL_NUMBER;
+ case NUMBER_INT:
+ return AmountRepresentation.MINOR_CURRENCY_UNIT;
+ default:
+ return null; // Ignore other shapes
+ }
+ }
+
+ private AmountConverter _getConverterForRepresentation(AmountRepresentation representation) {
+ switch (representation) {
+ case DECIMAL_NUMBER:
+ return DecimalNumberAmountConverter.getInstance();
+ case DECIMAL_STRING:
+ return DecimalStringAmountConverter.getInstance();
+ case MINOR_CURRENCY_UNIT:
+ return MinorCurrencyUnitAmountConverter.getInstance();
+ default:
+ return this.amountConverter;
+ }
+ }
+
@Override
public LogicalType logicalType() {
// structured, hence POJO
diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java
index b2d989e..dd7d471 100644
--- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java
+++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java
@@ -2,10 +2,13 @@
import org.joda.money.Money;
+import com.fasterxml.jackson.annotation.JsonFormat;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonToken;
import tools.jackson.core.type.WritableTypeId;
+import tools.jackson.databind.BeanProperty;
+import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.jsontype.TypeSerializer;
@@ -25,6 +28,74 @@ public MoneySerializer() {
this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null");
}
+ @Override
+ public ValueSerializer> createContextual(SerializationContext ctxt, BeanProperty property) {
+ if (property == null) {
+ return this;
+ }
+
+ AmountRepresentation effectiveRepresentation = _resolveRepresentation(property, ctxt);
+
+ if (effectiveRepresentation == null || effectiveRepresentation == AmountRepresentation.DEFAULT) {
+ // Keep current converter (module-level default)
+ return this;
+ }
+
+ AmountConverter newConverter = _getConverterForRepresentation(effectiveRepresentation);
+ if (newConverter == this.amountConverter) {
+ return this;
+ }
+
+ return new MoneySerializer(newConverter);
+ }
+
+ private AmountRepresentation _resolveRepresentation(BeanProperty property, SerializationContext ctxt) {
+ // Priority 1: @JodaMoney annotation
+ JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class);
+ if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) {
+ return jodaMoney.amountRepresentation();
+ }
+
+ // Priority 2: @JsonFormat mapping
+ JsonFormat.Value format = property.findPropertyFormat(ctxt.getConfig(), Money.class);
+ if (format != null && format.getShape() != JsonFormat.Shape.ANY) {
+ AmountRepresentation mapped = _mapShapeToRepresentation(format.getShape());
+ if (mapped != null) {
+ return mapped;
+ }
+ }
+
+ // Priority 3 & 4: Module default or built-in default (already in amountConverter)
+ return null;
+ }
+
+ private AmountRepresentation _mapShapeToRepresentation(JsonFormat.Shape shape) {
+ switch (shape) {
+ case STRING:
+ return AmountRepresentation.DECIMAL_STRING;
+ case NUMBER:
+ case NUMBER_FLOAT:
+ return AmountRepresentation.DECIMAL_NUMBER;
+ case NUMBER_INT:
+ return AmountRepresentation.MINOR_CURRENCY_UNIT;
+ default:
+ return null; // Ignore other shapes
+ }
+ }
+
+ private AmountConverter _getConverterForRepresentation(AmountRepresentation representation) {
+ switch (representation) {
+ case DECIMAL_NUMBER:
+ return DecimalNumberAmountConverter.getInstance();
+ case DECIMAL_STRING:
+ return DecimalStringAmountConverter.getInstance();
+ case MINOR_CURRENCY_UNIT:
+ return MinorCurrencyUnitAmountConverter.getInstance();
+ default:
+ return this.amountConverter;
+ }
+ }
+
@Override
public void serialize(final Money value,
final JsonGenerator g, final SerializationContext ctxt)
diff --git a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java
new file mode 100644
index 0000000..2d86fdb
--- /dev/null
+++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java
@@ -0,0 +1,337 @@
+package tools.jackson.datatype.jodamoney;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.databind.ObjectMapper;
+import org.joda.money.CurrencyUnit;
+import org.joda.money.Money;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("MoneyFieldLevelRepresentation tests")
+public class MoneyFieldLevelRepresentationTest extends ModuleTestBase {
+
+ @Nested
+ @DisplayName("@JodaMoney annotation tests")
+ class JodaMoneyAnnotationTests {
+
+ @Test
+ @DisplayName("should serialize field with DECIMAL_STRING when @JodaMoney specified")
+ void fieldWithDecimalString() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithFieldAnnotations payment = new PaymentWithFieldAnnotations(
+ Money.parse("EUR 12.34"),
+ Money.parse("EUR 5.67"),
+ Money.parse("EUR 100.00")
+ );
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\""));
+ assertTrue(json.contains("\"fee\":{\"amount\":567"));
+ assertTrue(json.contains("\"total\":{\"amount\":100.00"));
+ }
+
+ @Test
+ @DisplayName("should deserialize field with DECIMAL_STRING when @JodaMoney specified")
+ void deserializeFieldWithDecimalString() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ String json = "{\"amount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}," +
+ "\"fee\":{\"amount\":567,\"currency\":\"EUR\"}," +
+ "\"total\":{\"amount\":100.00,\"currency\":\"EUR\"}}";
+
+ // when
+ PaymentWithFieldAnnotations payment = mapper.readValue(json, PaymentWithFieldAnnotations.class);
+
+ // then
+ assertEquals(Money.parse("EUR 12.34"), payment.amount);
+ assertEquals(Money.parse("EUR 5.67"), payment.fee);
+ assertEquals(Money.parse("EUR 100.00"), payment.total);
+ }
+
+ @Test
+ @DisplayName("should serialize getter with @JodaMoney annotation")
+ void getterWithAnnotation() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithGetterAnnotations payment = new PaymentWithGetterAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\""));
+ }
+
+ @Test
+ @DisplayName("should deserialize constructor parameter with @JodaMoney annotation")
+ void constructorParameterWithAnnotation() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ String json = "{\"amount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}}";
+
+ // when
+ PaymentWithConstructorAnnotations payment = mapper.readValue(json, PaymentWithConstructorAnnotations.class);
+
+ // then
+ assertEquals(Money.parse("EUR 12.34"), payment.getAmount());
+ }
+ }
+
+ @Nested
+ @DisplayName("Mixed configuration tests")
+ class MixedConfigurationTests {
+
+ @Test
+ @DisplayName("should use field annotation over module default")
+ void fieldOverridesModuleDefault() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_NUMBER));
+ PaymentWithFieldAnnotations payment = new PaymentWithFieldAnnotations(
+ Money.parse("EUR 12.34"),
+ Money.parse("EUR 5.67"),
+ Money.parse("EUR 100.00")
+ );
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // override to string
+ assertTrue(json.contains("\"fee\":{\"amount\":567")); // override to int
+ assertTrue(json.contains("\"total\":{\"amount\":100.00")); // uses module default (number)
+ }
+
+ @Test
+ @DisplayName("should use @JodaMoney over @JsonFormat when both present")
+ void jodaMoneyWinsOverJsonFormat() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithBothAnnotations payment = new PaymentWithBothAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney wins (STRING)
+ }
+
+ @Test
+ @DisplayName("should round-trip with mixed representations")
+ void roundTripWithMixedRepresentations() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithFieldAnnotations original = new PaymentWithFieldAnnotations(
+ Money.parse("EUR 12.34"),
+ Money.parse("EUR 5.67"),
+ Money.parse("EUR 100.00")
+ );
+
+ // when
+ String json = mapper.writeValueAsString(original);
+ PaymentWithFieldAnnotations deserialized = mapper.readValue(json, PaymentWithFieldAnnotations.class);
+
+ // then
+ assertEquals(original.amount, deserialized.amount);
+ assertEquals(original.fee, deserialized.fee);
+ assertEquals(original.total, deserialized.total);
+ }
+ }
+
+ @Nested
+ @DisplayName("DEFAULT representation tests")
+ class DefaultRepresentationTests {
+
+ @Test
+ @DisplayName("should inherit module default when @JodaMoney(DEFAULT) specified")
+ void defaultInheritsModuleConfig() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_STRING));
+ PaymentWithDefaultAnnotation payment = new PaymentWithDefaultAnnotation(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // inherits DECIMAL_STRING from module
+ }
+ }
+
+ @Nested
+ @DisplayName("Mix-in annotation tests")
+ class MixinAnnotationTests {
+
+ @Test
+ @DisplayName("should apply @JodaMoney from mix-in to override representation")
+ void jodaMoneyMixinOverridesDefault() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModuleBuilder()
+ .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class)
+ .build();
+ PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // mix-in applies DECIMAL_STRING
+ }
+
+ @Test
+ @DisplayName("should apply @JsonFormat from mix-in to override representation")
+ void jsonFormatMixinOverridesDefault() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModuleBuilder()
+ .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonFormat.class)
+ .build();
+ PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":1234")); // mix-in applies NUMBER_INT -> MINOR_CURRENCY_UNIT
+ }
+
+ @Test
+ @DisplayName("should round-trip with mix-in annotations")
+ void roundTripWithMixin() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModuleBuilder()
+ .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class)
+ .build();
+ PaymentWithoutAnnotations original = new PaymentWithoutAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(original);
+ PaymentWithoutAnnotations deserialized = mapper.readValue(json, PaymentWithoutAnnotations.class);
+
+ // then
+ assertEquals(original.amount, deserialized.amount);
+ }
+
+ @Test
+ @DisplayName("should apply @JodaMoney mix-in over @JsonFormat mix-in when both present")
+ void jodaMoneyMixinWinsOverJsonFormatMixin() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModuleBuilder()
+ .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithBothAnnotations.class)
+ .build();
+ PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney (STRING) wins over @JsonFormat (NUMBER_INT)
+ }
+ }
+
+ // Test POJOs
+
+ static class PaymentWithFieldAnnotations {
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ public Money amount;
+
+ @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
+ public Money fee;
+
+ public Money total; // No annotation - uses module default
+
+ @JsonCreator
+ public PaymentWithFieldAnnotations(
+ @JsonProperty("amount") Money amount,
+ @JsonProperty("fee") Money fee,
+ @JsonProperty("total") Money total
+ ) {
+ this.amount = amount;
+ this.fee = fee;
+ this.total = total;
+ }
+ }
+
+ static class PaymentWithGetterAnnotations {
+ private Money amount;
+
+ public PaymentWithGetterAnnotations(Money amount) {
+ this.amount = amount;
+ }
+
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ public Money getAmount() {
+ return amount;
+ }
+ }
+
+ static class PaymentWithConstructorAnnotations {
+ private final Money amount;
+
+ @JsonCreator
+ public PaymentWithConstructorAnnotations(
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ @JsonProperty("amount") Money amount
+ ) {
+ this.amount = amount;
+ }
+
+ public Money getAmount() {
+ return amount;
+ }
+ }
+
+ static class PaymentWithBothAnnotations {
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
+ public Money amount;
+
+ public PaymentWithBothAnnotations(Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithDefaultAnnotation {
+ @JodaMoney(amountRepresentation = AmountRepresentation.DEFAULT)
+ public Money amount;
+
+ public PaymentWithDefaultAnnotation(Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ // POJOs without annotations for mix-in tests
+ static class PaymentWithoutAnnotations {
+ public Money amount;
+
+ public PaymentWithoutAnnotations(Money amount) {
+ this.amount = amount;
+ }
+
+ public PaymentWithoutAnnotations() {
+ }
+ }
+
+ // Mix-in classes
+ abstract static class PaymentMixinWithJodaMoney {
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ public Money amount;
+ }
+
+ abstract static class PaymentMixinWithJsonFormat {
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
+ public Money amount;
+ }
+
+ abstract static class PaymentMixinWithBothAnnotations {
+ @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
+ public Money amount;
+ }
+}
diff --git a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java
new file mode 100644
index 0000000..04cea61
--- /dev/null
+++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java
@@ -0,0 +1,243 @@
+package tools.jackson.datatype.jodamoney;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.databind.ObjectMapper;
+import org.joda.money.Money;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("MoneyFormatShapeMapping tests")
+public class MoneyFormatShapeMappingTest extends ModuleTestBase {
+
+ @Nested
+ @DisplayName("@JsonFormat shape mapping tests")
+ class ShapeMappingTests {
+
+ @Test
+ @DisplayName("should map STRING to DECIMAL_STRING representation")
+ void stringShapeToDecimalString() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithStringShape payment = new PaymentWithStringShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\""));
+ }
+
+ @Test
+ @DisplayName("should map NUMBER to DECIMAL_NUMBER representation")
+ void numberShapeToDecimalNumber() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithNumberShape payment = new PaymentWithNumberShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":12.34"));
+ }
+
+ @Test
+ @DisplayName("should map NUMBER_FLOAT to DECIMAL_NUMBER representation")
+ void numberFloatShapeToDecimalNumber() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithNumberFloatShape payment = new PaymentWithNumberFloatShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":12.34"));
+ }
+
+ @Test
+ @DisplayName("should map NUMBER_INT to MINOR_CURRENCY_UNIT representation")
+ void numberIntShapeToMinorCurrencyUnit() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithNumberIntShape payment = new PaymentWithNumberIntShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":1234"));
+ }
+
+ @Test
+ @DisplayName("should ignore unsupported shape and use module default")
+ void unsupportedShapeUsesDefault() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_STRING));
+ PaymentWithObjectShape payment = new PaymentWithObjectShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // uses module default
+ }
+ }
+
+ @Nested
+ @DisplayName("Round-trip tests")
+ class RoundTripTests {
+
+ @Test
+ @DisplayName("should round-trip with STRING shape")
+ void roundTripStringShape() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithStringShape original = new PaymentWithStringShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(original);
+ PaymentWithStringShape deserialized = mapper.readValue(json, PaymentWithStringShape.class);
+
+ // then
+ assertEquals(original.amount, deserialized.amount);
+ }
+
+ @Test
+ @DisplayName("should round-trip with NUMBER_INT shape")
+ void roundTripNumberIntShape() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithNumberIntShape original = new PaymentWithNumberIntShape(Money.parse("EUR 12.34"));
+
+ // when
+ String json = mapper.writeValueAsString(original);
+ PaymentWithNumberIntShape deserialized = mapper.readValue(json, PaymentWithNumberIntShape.class);
+
+ // then
+ assertEquals(original.amount, deserialized.amount);
+ }
+ }
+
+ @Nested
+ @DisplayName("Multiple shapes tests")
+ class MultipleShapesTests {
+
+ @Test
+ @DisplayName("should serialize multiple fields with different shapes")
+ void multipleFieldsWithDifferentShapes() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ PaymentWithMultipleShapes payment = new PaymentWithMultipleShapes(
+ Money.parse("EUR 12.34"),
+ Money.parse("EUR 5.67"),
+ Money.parse("EUR 100.00")
+ );
+
+ // when
+ String json = mapper.writeValueAsString(payment);
+
+ // then
+ assertTrue(json.contains("\"stringAmount\":{\"amount\":\"12.34\""));
+ assertTrue(json.contains("\"numberAmount\":{\"amount\":5.67"));
+ assertTrue(json.contains("\"intAmount\":{\"amount\":10000"));
+ }
+
+ @Test
+ @DisplayName("should deserialize multiple fields with different shapes")
+ void deserializeMultipleFieldsWithDifferentShapes() throws Exception {
+ // setup
+ ObjectMapper mapper = mapperWithModule();
+ String json = "{\"stringAmount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}," +
+ "\"numberAmount\":{\"amount\":5.67,\"currency\":\"EUR\"}," +
+ "\"intAmount\":{\"amount\":10000,\"currency\":\"EUR\"}}";
+
+ // when
+ PaymentWithMultipleShapes payment = mapper.readValue(json, PaymentWithMultipleShapes.class);
+
+ // then
+ assertEquals(Money.parse("EUR 12.34"), payment.stringAmount);
+ assertEquals(Money.parse("EUR 5.67"), payment.numberAmount);
+ assertEquals(Money.parse("EUR 100.00"), payment.intAmount);
+ }
+ }
+
+ // Test POJOs
+
+ static class PaymentWithStringShape {
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ public Money amount;
+
+ @JsonCreator
+ public PaymentWithStringShape(@JsonProperty("amount") Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithNumberShape {
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER)
+ public Money amount;
+
+ @JsonCreator
+ public PaymentWithNumberShape(@JsonProperty("amount") Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithNumberFloatShape {
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
+ public Money amount;
+
+ @JsonCreator
+ public PaymentWithNumberFloatShape(@JsonProperty("amount") Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithNumberIntShape {
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
+ public Money amount;
+
+ @JsonCreator
+ public PaymentWithNumberIntShape(@JsonProperty("amount") Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithObjectShape {
+ @JsonFormat(shape = JsonFormat.Shape.OBJECT)
+ public Money amount;
+
+ @JsonCreator
+ public PaymentWithObjectShape(@JsonProperty("amount") Money amount) {
+ this.amount = amount;
+ }
+ }
+
+ static class PaymentWithMultipleShapes {
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ public Money stringAmount;
+
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER)
+ public Money numberAmount;
+
+ @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
+ public Money intAmount;
+
+ @JsonCreator
+ public PaymentWithMultipleShapes(
+ @JsonProperty("stringAmount") Money stringAmount,
+ @JsonProperty("numberAmount") Money numberAmount,
+ @JsonProperty("intAmount") Money intAmount
+ ) {
+ this.stringAmount = stringAmount;
+ this.numberAmount = numberAmount;
+ this.intAmount = intAmount;
+ }
+ }
+}
diff --git a/release-notes/CREDITS b/release-notes/CREDITS
new file mode 100644
index 0000000..c159870
--- /dev/null
+++ b/release-notes/CREDITS
@@ -0,0 +1,18 @@
+Here are people who have contributed to the development of Jackson JSON processor
+Miscellaneous datatypes
+(version numbers in brackets indicate release in which the problem was fixed)
+
+Tatu Saloranta (tatu.saloranta@iki.fi): author of `org.json` (aka `json-org`),
+ `jsr-353`, `[jakarta-]jsonp` modules, co-author of other modules
+
+Iurii Ignatko (welandaz@github): author of `joda-money` module (added in 2.11)
+
+Christopher Smith (chrylis@github): author of `jakarta-mail` module (added in 2.13)
+
+----------------------------------------------------------------------------
+
+@sri-adarsh-kumar
+
+* Contributed #76: (joda-money) Add field-level amount representation for Joda-Money
+ (`@JodaMoney` annotation)
+ (3.1.0)
diff --git a/release-notes/VERSION b/release-notes/VERSION
index 7d2f1cb..fc8897c 100644
--- a/release-notes/VERSION
+++ b/release-notes/VERSION
@@ -13,6 +13,12 @@ Modules:
=== Releases ===
------------------------------------------------------------------------
+3.1.0 (not yet released)
+
+#76: (joda-money) Add field-level amount representation for Joda-Money (`@JodaMoney`
+ annotation)
+ (contributed by @sri-adarsh-kumar)
+
3.0.1 (21-Oct-2025)
No changes since 3.0.0