From ce8bb7e5ca0a805b32b741df9579da914d60e756 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 30 Sep 2026 12:39:58 +0530 Subject: [PATCH] fix(products): named GL account changes (e.g. Savings Interest Payable/Paid) were silently lost on a pending edit Root cause: ProductPendingEditFieldMapper.populateCoreFields() captured otherGlMappings with stringValueOfParameterNamed(), which only handles a JSON string primitive. The maker's edit actually submits otherGlMappings as a nested {assets,liabilities,expenses,income} object -- the same shape SavingsProduct#update() already reads correctly via jsonFragment() -- so the pending-edit row was staged with this field blank every time, regardless of what the maker changed. Two consequences, both now fixed: - The checker's diff dialog showed "No changes to review" even when a GL account mapping (e.g. Savings/Current/FD/RD Interest Payable or Paid) was genuinely changed, since the submitted value never made it into proposedChanges. - Approving the edit replayed a payload with no otherGlMappings at all, so the GL account change was silently dropped rather than applied -- the edit showed as approved, but the product's accounting mapping never actually moved. Fixed the write side (jsonFragment instead of stringValueOfParameterNamed) and the read-side replay (writeCoreFieldsToJson now re-adds the stored value as a parsed JSON element via the new putJsonFragment, not a quoted string, which would have double-encoded it on replay). Shared by Savings, Current, FD and RD products, since they all go through this one mapper. Co-Authored-By: Claude Sonnet 5 --- .../domain/ProductPendingEditFieldMapper.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index 3043ceff6..30ce2a490 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -158,7 +158,15 @@ public final class ProductPendingEditFieldMapper { present.add("taxGroupId"); } if (command.hasParameter("otherGlMappings")) { - f.setOtherGlMappings(command.stringValueOfParameterNamed("otherGlMappings")); + // otherGlMappings arrives as a nested JSON object ({assets:[...], liabilities:[...], + // expenses:[...]}), the same shape SavingsProduct#update() reads via jsonFragment() -- + // NOT a JSON string primitive. stringValueOfParameterNamed() only handles the latter and + // silently came back blank for the former, so every "Savings Interest Payable"/"Savings + // Interest Paid" (and other named GL account) edit was staged with an empty value: it + // never reached the pending-edit row, so the checker's diff had nothing to show and + // approving replayed no GL account change at all. jsonFragment() is the same call the + // live (non-pending) update path already uses for this exact parameter. + f.setOtherGlMappings(command.jsonFragment("otherGlMappings")); present.add("otherGlMappings"); } if (command.hasParameter("isDormancyTrackingActive")) { @@ -355,7 +363,7 @@ public final class ProductPendingEditFieldMapper { putNumber(root, submitted, "minBalanceForInterestCalculation", f.getMinBalanceForInterestCalculation()); putBoolean(root, submitted, "withHoldTax", f.getWithHoldTax()); putNumber(root, submitted, "taxGroupId", f.getTaxGroupId()); - putString(root, submitted, "otherGlMappings", f.getOtherGlMappings()); + putJsonFragment(root, submitted, "otherGlMappings", f.getOtherGlMappings()); putBoolean(root, submitted, "isDormancyTrackingActive", f.getIsDormancyTrackingActive()); putNumber(root, submitted, "daysToInactive", f.getDaysToInactive()); putNumber(root, submitted, "daysToDormancy", f.getDaysToDormancy()); @@ -733,6 +741,24 @@ public final class ProductPendingEditFieldMapper { } } + /** + * Same as {@link #putString}, but for a value that is itself raw JSON (an object/array captured + * via {@code JsonCommand#jsonFragment}) rather than a plain string. {@code addProperty} would wrap + * it in quotes as an escaped string literal, which {@code jsonFragment} on the replayed command + * would then hand back as that literal quoted text instead of the original structure -- breaking + * the downstream `other_gl_mappings::jsonb` cast this field's value round-trips through. + */ + private static void putJsonFragment(final JsonObject root, final Set submitted, final String name, final String rawJson) { + if (!submitted.contains(name)) { + return; + } + if (rawJson == null || rawJson.isBlank()) { + root.add(name, com.google.gson.JsonNull.INSTANCE); + return; + } + root.add(name, com.google.gson.JsonParser.parseString(rawJson)); + } + private static void putNumber(final JsonObject root, final Set submitted, final String name, final Number value) { if (submitted.contains(name)) { root.addProperty(name, value);