fix(deposits): correct FD reinvestment on maturity and unblock product edit submission
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

Reinvestment on maturity was creating the new deposit with a zero balance, because it
sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to
stop a manually opened FD from auto-crediting itself. It now carries over what reInvest()
actually put on the new account. Both legs of the renewal are also posted as an internal
transfer, so the funds move through the transfer-suspense GL instead of appearing to leave
and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no
chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the
renewed term.

Adds a reinvestment trail: the new account records which account it came from, and the
account view resolves both directions of the link so either side can be traced.

Also fixes product edit submission, which failed outright for any product carrying
payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload
carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving
gl_account_id null and tripping the satellite table's NOT NULL constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 12:57:02 +05:30
co-authored by Claude Opus 5
parent eb1aa767f7
commit 20fe11e60d
10 changed files with 217 additions and 19 deletions
@@ -138,6 +138,23 @@ public final class FixedDepositAccountData extends DepositAccountData {
private Boolean isSeniorCitizen;
private java.math.BigDecimal seniorCitizenInterestRate;
// Reinvestment trail: which account this one was reinvested FROM (set on the new account) and
// which account it was reinvested INTO (derived by reverse lookup on the old account), so staff
// can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions
// instead of the link disappearing once the old account closes.
private Long reinvestedFromAccountId;
private String reinvestedFromAccountNo;
private Long reinvestedIntoAccountId;
private String reinvestedIntoAccountNo;
public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo,
final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) {
this.reinvestedFromAccountId = reinvestedFromAccountId;
this.reinvestedFromAccountNo = reinvestedFromAccountNo;
this.reinvestedIntoAccountId = reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
}
// CBS account-level fields (stored per-account in m_savings_account)
private String accountStatus;
private String modeOfOperation;
@@ -329,6 +346,10 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.jointHolders = src.jointHolders;
this.isSeniorCitizen = src.isSeniorCitizen;
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
this.reinvestedFromAccountId = src.reinvestedFromAccountId;
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
this.subStatus = src.subStatus;
this.reasonForBlock = src.reasonForBlock;
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
@@ -89,6 +89,21 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
@Column(name = "transfer_to_savings_account_id")
private Long transferToSavingsAccountId;
// Set on the NEW account created by a "Re-Invest" maturity closure, pointing back at the account
// it was reinvested from. There is no separate "reinvested into" column on the OLD account -- that
// direction is derived by looking up whichever row has this account's id here (see
// DepositAccountReadPlatformServiceImpl), so the relationship needs tracking in only one place.
@Column(name = "reinvested_from_savings_id")
private Long reinvestedFromSavingsId;
public Long getReinvestedFromSavingsId() {
return this.reinvestedFromSavingsId;
}
public void setReinvestedFromSavingsId(final Long reinvestedFromSavingsId) {
this.reinvestedFromSavingsId = reinvestedFromSavingsId;
}
protected DepositAccountTermAndPreClosure() {
}
@@ -605,19 +605,29 @@ public final class ProductPendingEditFieldMapper {
// ---- GL array mappings (1:many, satellite table) -----------------------------------------------
// The GL-account field is named differently per mapping array in the product payload -- payment
// channels carry "fundSourceAccountId", fee/penalty arrays carry "incomeAccountId" (see
// AccountingConstants.LoanProductAccountingParams). Reading the wrong name leaves gl_account_id
// null, which the satellite table rejects, so each array declares its own field name here and the
// same name is written back when the pending edit is rebuilt into a product-update payload.
private static final String PAYMENT_CHANNEL_GL_FIELD = "fundSourceAccountId";
private static final String INCOME_ACCOUNT_GL_FIELD = "incomeAccountId";
public static List<ProductPendingEditGlMapping> extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) {
final List<ProductPendingEditGlMapping> mappings = new java.util.ArrayList<>();
boolean any = false;
any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL,
"paymentTypeId", mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings);
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId",
INCOME_ACCOUNT_GL_FIELD, mappings);
any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId",
mappings);
INCOME_ACCOUNT_GL_FIELD, mappings);
return any ? mappings : null;
}
private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName,
final String mappingType, final String idFieldName, final List<ProductPendingEditGlMapping> out) {
final String mappingType, final String idFieldName, final String glFieldName,
final List<ProductPendingEditGlMapping> out) {
if (!command.hasParameter(paramName)) {
return false;
}
@@ -625,8 +635,13 @@ public final class ProductPendingEditFieldMapper {
if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
final JsonObject o = el.getAsJsonObject();
final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null;
final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null;
final Long idFieldValue = longOrNull(o, idFieldName);
final Long glAccountId = longOrNull(o, glFieldName);
// A mapping is only meaningful with both halves present; keeping a half-filled row
// would fail the satellite table's NOT NULL constraint and abort the whole edit.
if (idFieldValue == null || glAccountId == null) {
continue;
}
final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null;
final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null;
out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId));
@@ -635,15 +650,22 @@ public final class ProductPendingEditFieldMapper {
return true;
}
private static Long longOrNull(final JsonObject o, final String fieldName) {
final com.google.gson.JsonElement el = o.get(fieldName);
return el == null || el.isJsonNull() ? null : el.getAsLong();
}
public static void writeGlArrayMappingsToJson(final JsonObject root, final List<ProductPendingEditGlMapping> mappings) {
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings",
"paymentTypeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId");
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId");
"paymentTypeId", PAYMENT_CHANNEL_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId",
INCOME_ACCOUNT_GL_FIELD);
}
private static void writeGlArrayGroup(final JsonObject root, final List<ProductPendingEditGlMapping> mappings, final String type,
final String paramName, final String idFieldName) {
final String paramName, final String idFieldName, final String glFieldName) {
final JsonArray array = new JsonArray();
for (final ProductPendingEditGlMapping mapping : mappings) {
if (!type.equals(mapping.getMappingType())) {
@@ -652,7 +674,7 @@ public final class ProductPendingEditFieldMapper {
final JsonObject entry = new JsonObject();
final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId();
entry.addProperty(idFieldName, idFieldValue);
entry.addProperty("glAccountId", mapping.getGlAccountId());
entry.addProperty(glFieldName, mapping.getGlAccountId());
array.add(entry);
}
root.add(paramName, array);