Compare commits
10
Commits
cebba87864
...
6ee3bbe67c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
2155c94161 | ||
|
|
87f3e591ce | ||
|
|
1e903232f0 | ||
|
|
2c49d6f637 | ||
|
|
331c553164 | ||
|
|
3fb008a22a | ||
|
|
8b2fb052d4 | ||
|
|
ea39c8e82a |
+32
@@ -4809,4 +4809,36 @@ public class CommandWrapperBuilder {
|
|||||||
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createSurvey() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/surveys";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "ACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "DEACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
|
|||||||
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
||||||
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
|
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
|
||||||
|
|
||||||
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
|
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
|
||||||
|
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
|
||||||
|
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
|
||||||
|
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
|
||||||
|
// active) are the only statuses a second application on the same product should actually collide
|
||||||
|
// with.
|
||||||
|
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
|
||||||
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
|
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -651,6 +651,8 @@ public class AccountingProcessorHelper {
|
|||||||
|
|
||||||
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||||
|
final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
|
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId);
|
||||||
|
|
||||||
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
|
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
|
||||||
// where a bank actually picks it. The reserved name in the product's other GL mappings is
|
// where a bank actually picks it. The reserved name in the product's other GL mappings is
|
||||||
@@ -664,13 +666,20 @@ public class AccountingProcessorHelper {
|
|||||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||||
.orElse(payableAccount);
|
.orElse(payableAccount);
|
||||||
|
|
||||||
// The interest expense is not raised here. It is recognised as the interest accrues, day by
|
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
|
||||||
// day, and this posting settles that recognised debt - raising the expense again at payout
|
// named Payable account if the product has one, or straight against Savings Control if it
|
||||||
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
|
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
|
||||||
// which pays a product's whole membership the same way; a closure reaching this path must
|
// entries for interest until this leg existed, since a product with no Payable/Paid names
|
||||||
// produce the same entries as the nightly run, or the two disagree on what one payment did.
|
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
|
||||||
|
// to post in their place.
|
||||||
|
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
|
isReversal);
|
||||||
|
|
||||||
|
// The interest expense is not raised again here. It was just raised above, and this posting
|
||||||
|
// only settles that recognised debt onward to where it is actually paid out from -- raising
|
||||||
|
// the expense a second time would put the same interest on the P&L twice.
|
||||||
//
|
//
|
||||||
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
|
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
|
||||||
// product names no paid account both resolve to the payable and this says nothing, so it is
|
// product names no paid account both resolve to the payable and this says nothing, so it is
|
||||||
// skipped and the payout comes straight out of the payable.
|
// skipped and the payout comes straight out of the payable.
|
||||||
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
||||||
@@ -679,7 +688,7 @@ public class AccountingProcessorHelper {
|
|||||||
isReversal);
|
isReversal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 2: into the member's own balance.
|
// Stage 3: into the member's own balance.
|
||||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
isReversal);
|
isReversal);
|
||||||
|
|||||||
+16
-11
@@ -105,17 +105,22 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
||||||
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
||||||
.append(" eao.external_id as externalAssetOwner, ")
|
.append(" eao.external_id as externalAssetOwner, ")
|
||||||
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and
|
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
|
||||||
// current-account entries -- current products deliberately share SAVING's GL type
|
// m_current_account -- newer write paths tag current-account entries with it
|
||||||
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
|
// instead of reusing SAVING's type, specifically so this case needs no guessing.
|
||||||
// apart on its own, and m_savings_account/m_current_account each have their own id
|
//
|
||||||
// sequence: an entity_id can be a real row in both, making a plain
|
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
|
||||||
// COALESCE(ca.account_no, sa.account_no) pick the current account's number even for
|
// and current-account entries by older write paths not yet updated to the above --
|
||||||
// an entry that is genuinely a savings one. savings_transaction_id is the one
|
// current products used to deliberately share SAVING's GL type space (see
|
||||||
// unambiguous signal available here -- only a real savings transaction sets it -- so
|
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
|
||||||
// it decides first; only when it's absent (a current-account entry, or a savings
|
// m_current_account each have their own id sequence, so an entity_id can be a real
|
||||||
// entry with no linked transaction row) does the current-account guess apply.
|
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
|
||||||
|
// account's number even for an entry that is genuinely a savings one.
|
||||||
|
// savings_transaction_id is the one unambiguous signal available for those older
|
||||||
|
// entries -- only a real savings transaction sets it -- so it decides first; only
|
||||||
|
// when it's absent does the current-account guess apply.
|
||||||
.append(" CASE ")
|
.append(" CASE ")
|
||||||
|
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
|
||||||
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
||||||
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
||||||
.append(" ELSE NULL ")
|
.append(" ELSE NULL ")
|
||||||
@@ -141,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
||||||
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
||||||
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
||||||
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
|
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
|
||||||
if (associationParametersData.isTransactionDetailsRequired()) {
|
if (associationParametersData.isTransactionDetailsRequired()) {
|
||||||
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
||||||
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
||||||
|
|||||||
+3
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
|
|||||||
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
||||||
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
||||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/loans")
|
@Path("/v1/loans")
|
||||||
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class LoanCOBCatchUpApiResource {
|
public class LoanCOBCatchUpApiResource {
|
||||||
|
|
||||||
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("oldest-cob-closed")
|
@Path("oldest-cob-closed")
|
||||||
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
|
|||||||
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
||||||
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
||||||
public Response executeLoanCOBCatchUp() {
|
public Response executeLoanCOBCatchUp() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
|
||||||
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
||||||
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||||
|
|||||||
+13
-3
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||||
*/
|
*/
|
||||||
|
// m_savings_account and m_current_account each keep their own identity sequence, so the same
|
||||||
|
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
|
||||||
|
// be written for both savings and current-account entries alike, so esa/eca could both match the
|
||||||
|
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
|
||||||
|
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
|
||||||
|
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
|
||||||
|
// is what newer write paths tag current-account entries with instead, unambiguously; for older
|
||||||
|
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
|
||||||
|
// savings transaction sets it), checked before the ambiguous current/savings guess.
|
||||||
private static final String RESOLVE_SQL = """
|
private static final String RESOLVE_SQL = """
|
||||||
SELECT je.amount AS amount,
|
SELECT je.amount AS amount,
|
||||||
je.currency_code AS currencyCode,
|
je.currency_code AS currencyCode,
|
||||||
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
je.loan_transaction_id AS loanTxnId,
|
je.loan_transaction_id AS loanTxnId,
|
||||||
je.entity_type_enum AS entityType,
|
je.entity_type_enum AS entityType,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
esa.client_id,
|
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
|
||||||
eca.client_id,
|
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
|
||||||
|
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
|
||||||
eloan.client_id,
|
eloan.client_id,
|
||||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||||
sa.client_id,
|
sa.client_id,
|
||||||
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
) AS clientId
|
) AS clientId
|
||||||
FROM acc_gl_journal_entry je
|
FROM acc_gl_journal_entry je
|
||||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
|
||||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||||
|
|||||||
+21
-7
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
|
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||||
@@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId, accountType);
|
||||||
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
@@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
||||||
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) {
|
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
||||||
|
final ChequeAccountType accountType) {
|
||||||
final JsonArray debitArr = new JsonArray();
|
final JsonArray debitArr = new JsonArray();
|
||||||
final JsonObject debit = new JsonObject();
|
final JsonObject debit = new JsonObject();
|
||||||
debit.addProperty("glAccountId", debitGlId);
|
debit.addProperty("glAccountId", debitGlId);
|
||||||
@@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
|
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
|
||||||
final String glTxnId = result.getTransactionId();
|
final String glTxnId = result.getTransactionId();
|
||||||
if (glTxnId != null) {
|
if (glTxnId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, accountId);
|
linkDebitEntryToAccount(glTxnId, accountId, accountType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
|
/**
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
* Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an
|
||||||
|
* Account No.
|
||||||
|
*
|
||||||
|
* m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can
|
||||||
|
* be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of
|
||||||
|
* accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings
|
||||||
|
* account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually
|
||||||
|
* means "this id is in m_current_account" -- use it whenever accountType says so.
|
||||||
|
*/
|
||||||
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) {
|
||||||
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
||||||
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
||||||
// stale, unrelated journal entry.
|
// stale, unrelated journal entry.
|
||||||
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||||
glTxnId);
|
glTxnId);
|
||||||
if (!rowIds.isEmpty()) {
|
if (!rowIds.isEmpty()) {
|
||||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
final int entityType = accountType == ChequeAccountType.CURRENT
|
||||||
rowIds.get(0));
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
|
||||||
|
accountId, rowIds.get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -118,7 +119,7 @@ public class ClearingJournalService {
|
|||||||
}
|
}
|
||||||
final String glTxnId = result.getTransactionId();
|
final String glTxnId = result.getTransactionId();
|
||||||
if (glTxnId != null && linkAccountId != null) {
|
if (glTxnId != null && linkAccountId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
|
||||||
}
|
}
|
||||||
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
||||||
return glTxnId;
|
return glTxnId;
|
||||||
@@ -191,7 +192,13 @@ public class ClearingJournalService {
|
|||||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
/**
|
||||||
|
* Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two
|
||||||
|
* account tables number independently, so entity_type_enum must say which one accountId names, not
|
||||||
|
* assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means
|
||||||
|
* "this id is in m_current_account".
|
||||||
|
*/
|
||||||
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) {
|
||||||
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
|
||||||
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
|
||||||
// stale, unrelated journal entry.
|
// stale, unrelated journal entry.
|
||||||
@@ -200,8 +207,11 @@ public class ClearingJournalService {
|
|||||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||||
glTxnId);
|
glTxnId);
|
||||||
if (!rowIds.isEmpty()) {
|
if (!rowIds.isEmpty()) {
|
||||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
final int entityType = "CURRENT".equalsIgnoreCase(accountType)
|
||||||
rowIds.get(0));
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
|
||||||
|
accountId, rowIds.get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-13
@@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
|||||||
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
@@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||||
btTotalDr = btTotalDr.add(entryAmt);
|
btTotalDr = btTotalDr.add(entryAmt);
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
||||||
@@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
||||||
}
|
}
|
||||||
@@ -681,7 +682,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(directCaTxnId));
|
txnRefs.add(String.valueOf(directCaTxnId));
|
||||||
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
|
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true));
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
|
||||||
} else {
|
} else {
|
||||||
// Real CBS book transfer: direct credit to savings account.
|
// Real CBS book transfer: direct credit to savings account.
|
||||||
@@ -696,7 +697,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
accountId, entryAmt, officeId, txnDateStr, currentUser);
|
accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(directTxnId));
|
txnRefs.add(String.valueOf(directTxnId));
|
||||||
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
|
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false));
|
||||||
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -715,7 +716,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(debitTxnId));
|
txnRefs.add(String.valueOf(debitTxnId));
|
||||||
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
||||||
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
|
||||||
if (isCurrentAccount) {
|
if (isCurrentAccount) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -979,7 +980,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
||||||
|
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
|
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1005,7 +1006,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
|
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1568,20 +1569,27 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final BigDecimal amount;
|
final BigDecimal amount;
|
||||||
final String accountRef; // savings account number shown in GL comments; null for source/target GL
|
final String accountRef; // savings account number shown in GL comments; null for source/target GL
|
||||||
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
||||||
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
|
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
|
||||||
|
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
|
||||||
|
|
||||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId) {
|
final String accountRef, final String txnId) {
|
||||||
this(glAccountId, amount, accountRef, txnId, null);
|
this(glAccountId, amount, accountRef, txnId, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId, final Long savingsAccountId) {
|
final String accountRef, final String txnId, final Long savingsAccountId) {
|
||||||
|
this(glAccountId, amount, accountRef, txnId, savingsAccountId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId,
|
||||||
|
final Long savingsAccountId, final boolean isCurrentAccount) {
|
||||||
this.glAccountId = glAccountId;
|
this.glAccountId = glAccountId;
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.accountRef = accountRef;
|
this.accountRef = accountRef;
|
||||||
this.txnId = txnId;
|
this.txnId = txnId;
|
||||||
this.savingsAccountId = savingsAccountId;
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
this.isCurrentAccount = isCurrentAccount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,8 +1611,16 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
|
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
|
||||||
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
|
* row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—".
|
||||||
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
|
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
|
||||||
|
*
|
||||||
|
* m_current_account and m_savings_account each keep their own identity sequence, so the same
|
||||||
|
* numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2
|
||||||
|
* (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view
|
||||||
|
* would resolve a current account's entry to a same-numbered savings account or vice versa.
|
||||||
|
* entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's
|
||||||
|
* entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one
|
||||||
|
* this entry's id actually is, decided once already, where the account was first resolved.
|
||||||
*/
|
*/
|
||||||
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
||||||
final List<GlEntry> entries, final int typeEnum) {
|
final List<GlEntry> entries, final int typeEnum) {
|
||||||
@@ -1618,11 +1634,15 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
Long.class, glTxnId, typeEnum, entries.size());
|
Long.class, glTxnId, typeEnum, entries.size());
|
||||||
java.util.Collections.reverse(rowIds);
|
java.util.Collections.reverse(rowIds);
|
||||||
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
||||||
final Long savingsAccId = entries.get(i).savingsAccountId;
|
final GlEntry entry = entries.get(i);
|
||||||
|
final Long savingsAccId = entry.savingsAccountId;
|
||||||
if (savingsAccId != null) {
|
if (savingsAccId != null) {
|
||||||
|
final int entityType = entry.isCurrentAccount
|
||||||
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
|
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
|
||||||
savingsAccId, rowIds.get(i));
|
entityType, savingsAccId, rowIds.get(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -33,8 +33,12 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder
|
* FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
|
||||||
* needs (account/client/product identity, maturity date, deposit and maturity amounts).
|
* real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no
|
||||||
|
* closure instruction to auto-process -- with just the fields the maturity ladder needs
|
||||||
|
* (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays
|
||||||
|
* here until it's actually renewed or closed, since that's exactly the "needs manual attention"
|
||||||
|
* state the ladder exists to surface.
|
||||||
*
|
*
|
||||||
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
|
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
|
||||||
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
|
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
|
||||||
@@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource {
|
|||||||
private static final int FIXED_DEPOSIT = 200;
|
private static final int FIXED_DEPOSIT = 200;
|
||||||
/** SavingsAccountStatusType.ACTIVE. */
|
/** SavingsAccountStatusType.ACTIVE. */
|
||||||
private static final int ACTIVE = 300;
|
private static final int ACTIVE = 300;
|
||||||
|
/** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */
|
||||||
|
private static final int MATURED = 800;
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource {
|
|||||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
|
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ?
|
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
|
||||||
ORDER BY datp.maturity_date ASC
|
ORDER BY datp.maturity_date ASC
|
||||||
""", FIXED_DEPOSIT, ACTIVE);
|
""", FIXED_DEPOSIT, ACTIVE, MATURED);
|
||||||
|
|
||||||
return this.gson.toJson(rows);
|
return this.gson.toJson(rows);
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms
|
||||||
|
* anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current
|
||||||
|
* balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the
|
||||||
|
* balance the new term will actually start from -- exactly the numbers
|
||||||
|
* {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the
|
||||||
|
* account, so this only needs read permission, unlike its posting sibling.
|
||||||
|
*/
|
||||||
|
@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FixedDepositRenewalPreviewApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String preview(@PathParam("accountId") final Long accountId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||||
|
final Map<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
|
||||||
|
return this.gson.toJson(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-132
@@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
account.setClosedOnDate(closedDate);
|
account.setClosedOnDate(closedDate);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// Renews on the SAME account, so the customer keeps one account number however many times
|
// Renews on the SAME account, so the customer keeps one account number however many times
|
||||||
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
|
// the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
|
||||||
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
|
// (below), with only the leftover interest actually leaving, rather than the legacy
|
||||||
// rollover itself -- which also removes the transfer-suspense round trip the old
|
// close-and-reopen this branch used to use, which left the customer with a second account
|
||||||
// close-and-reopen flow needed.
|
// number for something that renews here just as cleanly as Reinvest Principal + Interest
|
||||||
|
// does. Full reinvest itself still moves nothing: the whole matured balance simply becomes
|
||||||
|
// the new term's principal, so there is no withdrawal, no deposit and no journal entry for
|
||||||
|
// the rollover itself.
|
||||||
final BigDecimal closingPrincipal = account.getDepositAmount();
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
||||||
: maturedBalance.subtract(closingPrincipal);
|
: maturedBalance.subtract(closingPrincipal);
|
||||||
|
final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance;
|
||||||
|
// Whatever doesn't renew is interest the customer earned and has to actually be paid out
|
||||||
|
// somewhere -- validated before anything below runs, so a missing target fails cleanly with
|
||||||
|
// nothing done rather than leaving a half-completed reinvestment.
|
||||||
|
final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount);
|
||||||
|
if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException(
|
||||||
|
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
|
||||||
|
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
|
||||||
|
}
|
||||||
|
|
||||||
// The active CBS base interest-rate version for this FD type: the same rate card the Base
|
// The active CBS base interest-rate version for this FD type: the same rate card the Base
|
||||||
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
|
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
|
||||||
@@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final Integer closingTenure = account.depositPeriod();
|
final Integer closingTenure = account.depositPeriod();
|
||||||
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
||||||
|
|
||||||
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
||||||
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
||||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
this.savingsAccountRepository.save(account);
|
this.savingsAccountRepository.save(account);
|
||||||
|
|
||||||
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
|
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
|
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
|
||||||
closingTenure, closingTenureFrequencyEnum);
|
closingTenure, closingTenureFrequencyEnum);
|
||||||
changes.put("renewedInPlace", Boolean.TRUE);
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
changes.put("renewalCycleNo", account.renewalCycleNo());
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
} else if (onClosureType.isReinvest()) {
|
// Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
|
||||||
// "Re-invest principal only" still closes and reopens, because only the principal carries
|
// zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
|
||||||
// forward and the interest has to leave the deposit -- which needs a payout destination that
|
|
||||||
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
|
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
|
||||||
final BigDecimal reInvestAmount = account.getDepositAmount();
|
|
||||||
// Whatever is left after the principal moves into the new deposit is interest the customer
|
|
||||||
// earned and has to actually be paid out somewhere -- it must not just sit on this account,
|
|
||||||
// which is about to be closed. Validated before anything below runs, so a missing target
|
|
||||||
// fails cleanly with nothing done rather than leaving a half-completed reinvestment.
|
|
||||||
final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount);
|
|
||||||
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
|
|
||||||
throw new GeneralPlatformDomainRuleException(
|
|
||||||
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
|
|
||||||
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
|
|
||||||
}
|
|
||||||
ExternalId externalId = this.externalIdFactory.create();
|
|
||||||
|
|
||||||
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
|
||||||
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
|
||||||
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
|
||||||
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
|
|
||||||
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
|
|
||||||
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
|
|
||||||
final boolean isInternalTransferLeg = true;
|
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
|
||||||
false, isRegularTransaction, isInternalTransferLeg);
|
|
||||||
savingsTransactionId = withdrawal.getId();
|
|
||||||
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
|
|
||||||
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
|
|
||||||
// postJournalEntries() call at the end of this method posts it a second time.
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
|
|
||||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
|
||||||
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
|
||||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
|
||||||
|
|
||||||
// Reached from the maturity job processing the account's standing maturity instruction.
|
|
||||||
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
|
|
||||||
|
|
||||||
// Pay out whatever didn't move into the new deposit, then actually close this account --
|
|
||||||
// previously this branch left the leftover interest stranded on an account that was never
|
|
||||||
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
|
|
||||||
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
|
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
@@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
|
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
account.updateClosedStatus();
|
|
||||||
account.updateOnAccountClosureStatus(onClosureType);
|
|
||||||
|
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
@@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
|
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
|
||||||
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
|
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
|
||||||
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
|
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
|
||||||
|
// Only on this first-ever close of this term -- otherwise a term that renews more than once
|
||||||
|
// (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the
|
||||||
|
// same chart against the same closingRow every time, piling up duplicate slab rows for a
|
||||||
|
// chart that only ever needs archiving once.
|
||||||
|
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
|
||||||
}
|
}
|
||||||
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
|
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
|
||||||
closingRow.markMatured();
|
closingRow.markMatured();
|
||||||
this.fixedDepositRenewalHistoryRepository.save(closingRow);
|
this.fixedDepositRenewalHistoryRepository.save(closingRow);
|
||||||
|
|
||||||
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
|
|
||||||
|
|
||||||
// The new term's own interest/maturity stay unset until it closes in turn.
|
// The new term's own interest/maturity stay unset until it closes in turn.
|
||||||
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
|
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
|
||||||
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
|
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
|
||||||
@@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
||||||
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
||||||
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
|
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
|
||||||
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
|
* transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
|
||||||
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
|
* Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
|
||||||
* the existing manual Close command still uses today.
|
* Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with
|
||||||
|
* only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}
|
||||||
|
* this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's
|
||||||
|
* own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an
|
||||||
|
* already-stored instruction).
|
||||||
* <p>
|
* <p>
|
||||||
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
|
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
|
||||||
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
|
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
|
||||||
@@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
|
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// These two branches keep the account open, so they never reach close()/prematureClosure() and
|
// These three branches keep the account open, so they never reach close()/prematureClosure()
|
||||||
// would otherwise accept any date at all -- including one behind already-posted interest, which
|
// and would otherwise accept any date at all -- including one behind already-posted interest,
|
||||||
// the renewal would then turn into a pivot that reverses and re-posts it.
|
// which the renewal would then turn into a pivot that reverses and re-posts it.
|
||||||
account.validateRenewalDate(closedDate);
|
account.validateRenewalDate(closedDate);
|
||||||
|
|
||||||
final BigDecimal closingPrincipal = account.getDepositAmount();
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
@@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
|
||||||
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
|
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
|
||||||
}
|
}
|
||||||
|
} else if (onClosureType.isReinvestPrincipal()) {
|
||||||
|
// Exactly the closing term's own principal -- the fixed point on the same split Partial
|
||||||
|
// Reinvest lets staff pick freely. Renews in place same as the other two, on just this
|
||||||
|
// account, with only the interest actually leaving (below) -- no second account, unlike the
|
||||||
|
// legacy close-and-reopen this branch used to do.
|
||||||
|
renewedAmount = closingPrincipal;
|
||||||
} else {
|
} else {
|
||||||
renewedAmount = maturedBalance;
|
renewedAmount = maturedBalance;
|
||||||
}
|
}
|
||||||
@@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
changes.put("renewedInPlace", Boolean.TRUE);
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
changes.put("renewalCycleNo", account.renewalCycleNo());
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
if (onClosureType.isPartialReinvest()) {
|
if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
|
||||||
// The kept portion never left the account -- renewInPlace() just relabels it as the new
|
// The kept portion never left the account -- renewInPlace() just relabels it as the new
|
||||||
// term's principal -- so only the remainder is an actual money movement, posted the same
|
// term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
|
||||||
// way the Transfer to Savings branch below posts its full-balance transfer.
|
// an actual money movement, posted the same way the Transfer to Savings branch below posts
|
||||||
|
// its full-balance transfer.
|
||||||
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
|
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
|
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
|
||||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
|
||||||
isAccountTransfer, isExceptionForBalanceCheck);
|
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||||
}
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
}
|
||||||
} else if (onClosureType.isReinvestPrincipal()) {
|
|
||||||
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
|
|
||||||
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
|
|
||||||
// has nowhere to send.
|
|
||||||
final BigDecimal maturedBalance = account.getAccountBalance();
|
|
||||||
final BigDecimal reInvestAmount = account.getDepositAmount();
|
|
||||||
final ExternalId externalId = this.externalIdFactory.create();
|
|
||||||
|
|
||||||
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
|
||||||
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
|
||||||
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
|
||||||
final boolean isInternalTransferLeg = true;
|
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
|
||||||
false, isRegularTransaction, isInternalTransferLeg);
|
|
||||||
savingsTransactionId = withdrawal.getId();
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
|
|
||||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
|
||||||
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
|
||||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
|
||||||
|
|
||||||
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
|
||||||
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
|
|
||||||
|
|
||||||
// Only the principal moved into the new deposit -- the interest earned on this term is still
|
|
||||||
// sitting in this account's balance. Pay it out to the chosen savings account now, before
|
|
||||||
// closing, rather than stranding it here with nowhere left to go.
|
|
||||||
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
|
|
||||||
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
|
||||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
|
||||||
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
|
|
||||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
|
|
||||||
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
|
||||||
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
|
||||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
|
||||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alreadyMatured) {
|
|
||||||
account.close(user, command, changes);
|
|
||||||
} else {
|
|
||||||
account.prematureClosure(user, command, changes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
|
|||||||
+154
-58
@@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
|
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
|
||||||
|
|
||||||
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
|
// Reinvest Principal Only now renews in place on this same account (see
|
||||||
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
|
// DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
|
||||||
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
|
// set any more, but this guard is left as a harmless no-op rather than removed, in case a future
|
||||||
|
// closure type reintroduces a genuine close-and-reopen.
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
@@ -1554,7 +1555,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
// an account opened straight into Reinvest Principal Only can reach here with none set.
|
// an account opened straight into Reinvest Principal Only can reach here with none set.
|
||||||
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
|
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
|
||||||
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
|
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
|
||||||
toSavingsId = fdAccount.getLinkedAccountId();
|
toSavingsId = resolveLinkedAccountId(fdAccount.getId());
|
||||||
}
|
}
|
||||||
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
|
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
|
||||||
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
|
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
|
||||||
@@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
// would treat it as new and post it a second time without this.
|
// would treat it as new and post it a second time without this.
|
||||||
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
||||||
|
|
||||||
|
// Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never
|
||||||
|
// actually set any more, but this guard is left as a harmless no-op rather than removed,
|
||||||
|
// in case a future closure type reintroduces a genuine close-and-reopen.
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
||||||
// activateWithBalance() always returns zero here -- it exists to stop a manually
|
|
||||||
// opened FD from auto-crediting itself at activation (funding is supposed to be a
|
|
||||||
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
|
|
||||||
// withdrawn from the matured account and needs to land in the new one as part of
|
|
||||||
// this same automated closure, so it has to come from what reInvest() actually
|
|
||||||
// carried over onto the new account, not from that unrelated activation guard.
|
|
||||||
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
||||||
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
||||||
if (activationChargeAmount.isGreaterThanZero()) {
|
if (activationChargeAmount.isGreaterThanZero()) {
|
||||||
@@ -1620,7 +1618,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
|
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
|
||||||
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
|
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
|
||||||
// should always be present, but fail clearly rather than silently stranding the interest if not.
|
// should always be present, but fail clearly rather than silently stranding the interest if not.
|
||||||
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId();
|
final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId());
|
||||||
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
|
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
||||||
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
|
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
|
||||||
@@ -1630,13 +1628,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
|
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
|
||||||
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
|
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
|
||||||
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
|
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
|
||||||
Integer renewalGracePeriodDays = null;
|
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
|
||||||
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
|
|
||||||
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
|
|
||||||
}
|
|
||||||
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
|
|
||||||
renewalGracePeriodDays = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate maturityDate = fdAccount.maturityDate();
|
final LocalDate maturityDate = fdAccount.maturityDate();
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
@@ -1669,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
||||||
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
|
|
||||||
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId);
|
// Both reinvest types now renew this same account in place (see
|
||||||
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that
|
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
|
||||||
// actually carries the new term's maturity date/amount either way.
|
// actually set any more, but this guard is left as a harmless no-op rather than removed, in case
|
||||||
|
// a future closure type reintroduces a genuine close-and-reopen.
|
||||||
FixedDepositAccount renewedAccount = fdAccount;
|
FixedDepositAccount renewedAccount = fdAccount;
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
|
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
|
||||||
@@ -1697,17 +1690,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
"This FD has not matured yet.");
|
"This FD has not matured yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer renewalGracePeriodDays = null;
|
|
||||||
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
|
|
||||||
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
|
|
||||||
}
|
|
||||||
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
|
|
||||||
renewalGracePeriodDays = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate maturityDate = fdAccount.maturityDate();
|
final LocalDate maturityDate = fdAccount.maturityDate();
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
|
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount)));
|
||||||
|
|
||||||
final Map<String, Object> result = new HashMap<>();
|
final Map<String, Object> result = new HashMap<>();
|
||||||
result.put("withinGrace", withinGrace);
|
result.put("withinGrace", withinGrace);
|
||||||
@@ -1716,49 +1701,160 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
|
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
|
||||||
* linked Savings/Current account's product -- credited onto the FD itself as a genuine
|
* dialog before the staff member confirms anything -- the actual current balance, the linked
|
||||||
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the
|
* account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
|
||||||
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD
|
* onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
|
||||||
* Dashboard's generic Payment command) reads the account's balance. Shared by
|
* projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
|
||||||
* {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's
|
* Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
|
||||||
* Renew action now calls the latter as a pre-step before submitting the same generic `payment`
|
* zero for the gap fields since nothing is owed.
|
||||||
* command the account's own Account Actions menu already uses, so every closure type (Transfer to
|
|
||||||
* Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the
|
|
||||||
* two this method's sibling endpoint originally supported.
|
|
||||||
*/
|
*/
|
||||||
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
|
@Transactional(readOnly = true)
|
||||||
final Long linkedAccountId = fdAccount.getLinkedAccountId();
|
@Override
|
||||||
if (linkedAccountId == null) {
|
public Map<String, Object> previewFdRenewal(final Long fdAccountId) {
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
|
||||||
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it "
|
DepositAccountType.FIXED_DEPOSIT);
|
||||||
+ "before renewing late, so the gap-period interest has a rate to use.");
|
final Map<String, Object> result = new HashMap<>();
|
||||||
|
if (!fdAccount.isMatured()) {
|
||||||
|
result.put("isMatured", false);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject("""
|
result.put("isMatured", true);
|
||||||
|
|
||||||
|
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
|
||||||
|
final LocalDate maturityDate = fdAccount.maturityDate();
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
|
||||||
|
final BigDecimal currentBalance = fdAccount.getAccountBalance();
|
||||||
|
|
||||||
|
result.put("maturityDate", maturityDate.toString());
|
||||||
|
result.put("renewalGracePeriodDays", renewalGracePeriodDays);
|
||||||
|
result.put("withinGrace", withinGrace);
|
||||||
|
result.put("currentBalance", currentBalance);
|
||||||
|
|
||||||
|
if (withinGrace) {
|
||||||
|
result.put("gapDays", 0L);
|
||||||
|
result.put("gapInterestAmount", BigDecimal.ZERO);
|
||||||
|
result.put("projectedBalance", currentBalance);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
|
||||||
|
result.put("gapDays", gapDays);
|
||||||
|
|
||||||
|
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
|
||||||
|
result.put("linkedAccountId", linkedAccountId);
|
||||||
|
if (linkedAccountId == null) {
|
||||||
|
// No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking
|
||||||
|
// "link a Savings account first" message rather than a number, same as the posting path does.
|
||||||
|
result.put("gapInterestAmount", null);
|
||||||
|
result.put("projectedBalance", null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId));
|
||||||
|
|
||||||
|
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
||||||
|
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
||||||
|
result.put("linkedAccountRate", linkedAccountRate);
|
||||||
|
result.put("gapInterestAmount", gapInterestAmount);
|
||||||
|
// Paid onto the linked savings account, not this FD -- what renews is still just the FD's own
|
||||||
|
// current balance.
|
||||||
|
result.put("projectedBalance", currentBalance);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */
|
||||||
|
private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) {
|
||||||
|
Integer renewalGracePeriodDays = null;
|
||||||
|
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
|
||||||
|
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
|
||||||
|
}
|
||||||
|
return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's
|
||||||
|
* {@code getLinkedAccountId()} -- an account whose link was set at creation through the real
|
||||||
|
* account-opening command reads back correctly there, but this call site kept returning null for
|
||||||
|
* an account confirmed to have {@code linked_account_id} set in the database, which every one of
|
||||||
|
* this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps
|
||||||
|
* whatever staleness that entity accessor has, rather than trying to chase it down across every
|
||||||
|
* caller that touches this account within the same request.
|
||||||
|
*/
|
||||||
|
private Long resolveLinkedAccountId(final Long savingsAccountId) {
|
||||||
|
return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class,
|
||||||
|
savingsAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) {
|
||||||
|
return this.jdbcTemplate.queryForObject("""
|
||||||
SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
|
SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
|
||||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
WHERE sa.id = ?
|
WHERE sa.id = ?
|
||||||
""", BigDecimal.class, linkedAccountId);
|
""", BigDecimal.class, linkedAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
|
/** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} --
|
||||||
|
* shown in the Renewal Preview so staff can see exactly which account the gap interest is about
|
||||||
|
* to land on, not just that "a linked account" exists. */
|
||||||
|
private String resolveLinkedAccountNo(final Long linkedAccountId) {
|
||||||
|
return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple interest on the FD's own current balance, for the stretch between its maturity date and
|
||||||
|
* today, at the linked account's product rate -- the pure calculation, shared by the posting path
|
||||||
|
* ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number
|
||||||
|
* shown to staff before they confirm is exactly the number that gets credited. */
|
||||||
|
private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate,
|
||||||
|
final long gapDays) {
|
||||||
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
|
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
|
||||||
: fdAccount.getInterestCalculationDaysInYearType();
|
: fdAccount.getInterestCalculationDaysInYearType();
|
||||||
final MathContext mc = MoneyHelper.getMathContext();
|
final MathContext mc = MoneyHelper.getMathContext();
|
||||||
final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc)
|
return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc)
|
||||||
.multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc)
|
.divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
.setScale(2, java.math.RoundingMode.HALF_UP);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
|
||||||
|
* linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine
|
||||||
|
* INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in
|
||||||
|
* the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at
|
||||||
|
* the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding
|
||||||
|
* it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings
|
||||||
|
* interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay
|
||||||
|
* it out through the FD's own closure math instead of it already being where it belongs. The FD's
|
||||||
|
* own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or
|
||||||
|
* the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap
|
||||||
|
* interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit}
|
||||||
|
* and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as
|
||||||
|
* a pre-step before submitting the same generic `payment` command the account's own Account Actions
|
||||||
|
* menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial
|
||||||
|
* Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint
|
||||||
|
* originally supported.
|
||||||
|
*/
|
||||||
|
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
|
||||||
|
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
|
||||||
|
if (linkedAccountId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
|
||||||
|
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it "
|
||||||
|
+ "before renewing late, so the gap-period interest has somewhere to be paid.");
|
||||||
|
}
|
||||||
|
final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
|
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
|
||||||
|
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
|
||||||
|
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
|
||||||
|
|
||||||
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
|
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||||
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
|
|
||||||
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(),
|
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
|
||||||
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true);
|
linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
|
||||||
fdAccount.addTransaction(gapInterestTxn);
|
linkedAccount.addTransaction(gapInterestTxn);
|
||||||
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions());
|
linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
|
this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
|
||||||
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
|
postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||||
}
|
}
|
||||||
return gapInterestAmount;
|
return gapInterestAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
msg = "Savings product with short name `" + shortName + "` already exists";
|
msg = "Savings product with short name `" + shortName + "` already exists";
|
||||||
param = "shortName";
|
param = "shortName";
|
||||||
msgArgs = new Object[] { shortName, dae };
|
msgArgs = new Object[] { shortName, dae };
|
||||||
|
} else if (checkEx.getMessage().contains("numeric field overflow")) {
|
||||||
|
msgCode += ".amount.too.large";
|
||||||
|
msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again.";
|
||||||
|
msgArgs = new Object[] { dae };
|
||||||
} else {
|
} else {
|
||||||
msgCode += ".unknown.data.integrity.issue";
|
msgCode += ".unknown.data.integrity.issue";
|
||||||
|
msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage();
|
||||||
msgArgs = new Object[] { dae };
|
msgArgs = new Object[] { dae };
|
||||||
}
|
}
|
||||||
log.error("Error occured.", dae);
|
log.error("Error occured.", dae);
|
||||||
|
|||||||
+26
-9
@@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
@@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final Long drGlAccountId;
|
final Long drGlAccountId;
|
||||||
Long linkedAccountId = null;
|
Long linkedAccountId = null;
|
||||||
|
boolean linkedAccountIsCurrent = false;
|
||||||
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
||||||
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
|
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
|
||||||
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
|
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
|
||||||
@@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
issuingBranchId, issuedDate, currentUser.getId(), note);
|
issuingBranchId, issuedDate, currentUser.getId(), note);
|
||||||
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
||||||
linkedAccountId = currentAccountId;
|
linkedAccountId = currentAccountId;
|
||||||
|
linkedAccountIsCurrent = true;
|
||||||
}
|
}
|
||||||
if (drGlAccountId == null) {
|
if (drGlAccountId == null) {
|
||||||
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
|
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
|
||||||
@@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
||||||
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
|
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
|
||||||
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
|
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
|
||||||
|
linkedAccountIsCurrent);
|
||||||
|
|
||||||
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
|
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
|
||||||
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
|
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
|
||||||
@@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
Long resultingCurrentTransactionId = null;
|
Long resultingCurrentTransactionId = null;
|
||||||
final Long drGlAccountId;
|
final Long drGlAccountId;
|
||||||
Long linkedAccountId = null;
|
Long linkedAccountId = null;
|
||||||
|
boolean linkedAccountIsCurrent = false;
|
||||||
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
|
||||||
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
||||||
// Book debit, no journal entry of its own -- see approveIssueRequest().
|
// Book debit, no journal entry of its own -- see approveIssueRequest().
|
||||||
@@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
issuingBranchId, issuedDate, currentUser.getId(), note);
|
issuingBranchId, issuedDate, currentUser.getId(), note);
|
||||||
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
drGlAccountId = getCurrentControlGlId(currentAccountId);
|
||||||
linkedAccountId = currentAccountId;
|
linkedAccountId = currentAccountId;
|
||||||
|
linkedAccountIsCurrent = true;
|
||||||
}
|
}
|
||||||
if (drGlAccountId == null) {
|
if (drGlAccountId == null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
@@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
|
||||||
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
|
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
|
||||||
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
|
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
|
||||||
|
linkedAccountIsCurrent);
|
||||||
|
|
||||||
for (final Map<String, Object> request : requests) {
|
for (final Map<String, Object> request : requests) {
|
||||||
this.sponsorDDWritePlatformService.issueDemandDraft(
|
this.sponsorDDWritePlatformService.issueDemandDraft(
|
||||||
@@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
* liability, so they get their own credit leg rather than being folded into the liability). Resolves
|
* liability, so they get their own credit leg rather than being folded into the liability). Resolves
|
||||||
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
|
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
|
||||||
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
|
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
|
||||||
* savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 /
|
* savings/current account for an account-debit DD), the Dr-side row is tagged
|
||||||
* entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching
|
* entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so
|
||||||
* how Fineract's own auto-generated entries display.
|
* GL reports show the account number instead of a blank "—", matching how Fineract's own
|
||||||
|
* auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account
|
||||||
|
* each with their own sequence) can't resolve a same-numbered stranger's account instead.
|
||||||
*/
|
*/
|
||||||
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
||||||
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
|
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
|
||||||
final String comments, final Long linkedAccountId) {
|
final String comments, final Long linkedAccountId) {
|
||||||
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
|
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
|
||||||
txnDate, comments, linkedAccountId);
|
txnDate, comments, linkedAccountId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
||||||
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
||||||
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
||||||
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) {
|
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId,
|
||||||
|
final boolean linkedAccountIsCurrent) {
|
||||||
|
|
||||||
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
|
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
@@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
|
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
|
||||||
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
|
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
|
||||||
if (linkedAccountId != null) {
|
if (linkedAccountId != null) {
|
||||||
|
// m_current_account and m_savings_account each keep their own identity sequence, so the same
|
||||||
|
// numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId
|
||||||
|
// names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit
|
||||||
|
// branch resolves either a savingsAccountId or a currentAccountId into this same parameter).
|
||||||
|
final int entityType = linkedAccountIsCurrent
|
||||||
|
? PortfolioProductType.CURRENT.getValue()
|
||||||
|
: PortfolioProductType.SAVING.getValue();
|
||||||
this.jdbcTemplate.update(
|
this.jdbcTemplate.update(
|
||||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
|
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ("
|
||||||
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
||||||
+ " ORDER BY id DESC LIMIT 1)",
|
+ " ORDER BY id DESC LIMIT 1)",
|
||||||
linkedAccountId, transactionId);
|
entityType, linkedAccountId, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.spm.api;
|
package org.apache.fineract.spm.api;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
@@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.spm.data.SurveyData;
|
import org.apache.fineract.spm.data.SurveyData;
|
||||||
import org.apache.fineract.spm.domain.Survey;
|
import org.apache.fineract.spm.domain.Survey;
|
||||||
@@ -57,6 +60,8 @@ public class SpmApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext securityContext;
|
private final PlatformSecurityContext securityContext;
|
||||||
private final SpmService spmService;
|
private final SpmService spmService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -103,12 +108,10 @@ public class SpmApiResource {
|
|||||||
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
|
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
|
||||||
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
|
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
|
||||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
|
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
|
||||||
final Survey survey = SurveyMapper.map(surveyData, new Survey());
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
this.spmService.createSurvey(survey);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
return getResponse(survey.getId());
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@@ -116,12 +119,10 @@ public class SpmApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Transactional
|
@Transactional
|
||||||
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
|
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
|
||||||
final Survey surveyToUpdate = this.spmService.findById(id);
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
this.spmService.updateSurvey(survey);
|
|
||||||
return getResponse(survey.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -131,22 +132,17 @@ public class SpmApiResource {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Deactivate Survey", description = "")
|
@Operation(summary = "Deactivate Survey", description = "")
|
||||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||||
this.securityContext.authenticatedUser();
|
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
|
||||||
|
final CommandWrapper commandRequest;
|
||||||
if (command != null && command.equalsIgnoreCase("activate")) {
|
if (command != null && command.equalsIgnoreCase("activate")) {
|
||||||
this.spmService.activateSurvey(id);
|
commandRequest = builder.activateSurvey(id).build();
|
||||||
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
|
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
|
||||||
this.spmService.deactivateSurvey(id);
|
commandRequest = builder.deactivateSurvey(id).build();
|
||||||
} else {
|
} else {
|
||||||
throw new UnrecognizedQueryParamException("command", command);
|
throw new UnrecognizedQueryParamException("command", command);
|
||||||
}
|
}
|
||||||
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
}
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
|
||||||
private String getResponse(Long id) {
|
|
||||||
Gson gson = new Gson();
|
|
||||||
HashMap<String, Object> response = new HashMap<>();
|
|
||||||
response.put("resourceId", id);
|
|
||||||
return gson.toJson(response);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.spm.handler;
|
||||||
|
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.spm.service.SpmService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "SURVEY", action = "ACTIVATE")
|
||||||
|
public class ActivateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final SpmService spmService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ActivateSurveyCommandHandler(final SpmService spmService) {
|
||||||
|
this.spmService = spmService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
this.spmService.activateSurvey(command.entityId());
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(command.entityId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.spm.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.spm.data.SurveyData;
|
||||||
|
import org.apache.fineract.spm.domain.Survey;
|
||||||
|
import org.apache.fineract.spm.service.SpmService;
|
||||||
|
import org.apache.fineract.spm.util.SurveyMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "SURVEY", action = "CREATE")
|
||||||
|
public class CreateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final SpmService spmService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
|
||||||
|
this.spmService = spmService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
final SurveyData surveyData = readSurveyData(command);
|
||||||
|
final Survey survey = SurveyMapper.map(surveyData, new Survey());
|
||||||
|
this.spmService.createSurvey(survey);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(survey.getId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SurveyData readSurveyData(final JsonCommand command) {
|
||||||
|
try {
|
||||||
|
return this.objectMapper.readValue(command.json(), SurveyData.class);
|
||||||
|
} catch (final JsonProcessingException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.spm.handler;
|
||||||
|
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.spm.service.SpmService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "SURVEY", action = "DEACTIVATE")
|
||||||
|
public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final SpmService spmService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public DeactivateSurveyCommandHandler(final SpmService spmService) {
|
||||||
|
this.spmService = spmService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
this.spmService.deactivateSurvey(command.entityId());
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(command.entityId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.spm.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.spm.data.SurveyData;
|
||||||
|
import org.apache.fineract.spm.domain.Survey;
|
||||||
|
import org.apache.fineract.spm.service.SpmService;
|
||||||
|
import org.apache.fineract.spm.util.SurveyMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "SURVEY", action = "UPDATE")
|
||||||
|
public class UpdateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final SpmService spmService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
|
||||||
|
this.spmService = spmService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
final SurveyData surveyData = readSurveyData(command);
|
||||||
|
final Survey surveyToUpdate = this.spmService.findById(command.entityId());
|
||||||
|
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
|
||||||
|
this.spmService.updateSurvey(survey);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(survey.getId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SurveyData readSurveyData(final JsonCommand command) {
|
||||||
|
try {
|
||||||
|
return this.objectMapper.readValue(command.json(), SurveyData.class);
|
||||||
|
} catch (final JsonProcessingException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -114,17 +114,33 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform
|
|||||||
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
|
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
|
||||||
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
|
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline
|
||||||
|
* (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a
|
||||||
|
* different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen
|
||||||
|
* (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global
|
||||||
|
* maker-checker on/off switch and other System -> Configurations settings. If either were flipped
|
||||||
|
* to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin
|
||||||
|
* could lock themselves out of ever approving further changes to maker-checker configuration
|
||||||
|
* itself, with no ordinary way to reverse it. Excluding them here only hides them from this
|
||||||
|
* screen; it does not touch their can_maker_checker value or their normal enforcement.
|
||||||
|
*/
|
||||||
|
private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'";
|
||||||
|
|
||||||
public String makerCheckerablePermissionSchema() {
|
public String makerCheckerablePermissionSchema() {
|
||||||
/*
|
/*
|
||||||
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
|
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
|
||||||
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
|
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
|
||||||
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above).
|
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as
|
||||||
|
* codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES
|
||||||
|
* above) to avoid a self-lockout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
|
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
|
||||||
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
|
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
|
||||||
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
|
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
|
||||||
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
|
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
|
||||||
|
+ " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")"
|
||||||
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
|
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,4 +149,7 @@
|
|||||||
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
|
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
|
||||||
name and this changeset's id were updated to match. -->
|
name and this changeset's id were updated to match. -->
|
||||||
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- The Manage Surveys screen (SPM scorecards, spm.api.SpmApiResource) previously performed
|
||||||
|
create/update/activate/deactivate directly against SpmService with only a login check -
|
||||||
|
no permission check at any layer. These 4 write operations have now been wired into the
|
||||||
|
standard command pipeline (CommandWrapperBuilder.createSurvey/updateSurvey/activateSurvey/
|
||||||
|
deactivateSurvey -> CreateSurveyCommandHandler/UpdateSurveyCommandHandler/
|
||||||
|
ActivateSurveyCommandHandler/DeactivateSurveyCommandHandler, entity "SURVEY"), which requires
|
||||||
|
these permission rows to exist so PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource()
|
||||||
|
can authorize the caller. Seeded with can_maker_checker=false: no prior maker-checker behaviour
|
||||||
|
existed for this screen, and enabling maker-checker is a business decision left for later. -->
|
||||||
|
<changeSet id="0584_add_manage_survey_permissions" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="CREATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="CREATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="UPDATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="UPDATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="ACTIVATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="ACTIVATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="DEACTIVATE_SURVEY"/>
|
||||||
|
<column name="entity_name" value="SURVEY"/>
|
||||||
|
<column name="action_name" value="DEACTIVATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- No role previously held any permission for this screen (it had no permission check at all),
|
||||||
|
so there are no "roles that already hold the base permission" to copy from. Following the
|
||||||
|
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
|
||||||
|
Super user plus any manager-named role. -->
|
||||||
|
<changeSet id="0584_grant_manage_survey_permissions" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY')
|
||||||
|
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- LoanCOBCatchUpApiResource.executeLoanCOBCatchUp() (POST /v1/loans/catch-up) previously had no
|
||||||
|
permission check at all. A validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP") check was added
|
||||||
|
directly (not full CommandWrapper/maker-checker wiring, since this is a job-execution trigger,
|
||||||
|
not a CRUD operation on a persisted entity - matching the existing EXECUTE_INLINE_JOB precedent),
|
||||||
|
which requires this permission row to exist. can_maker_checker=false: this closes the
|
||||||
|
authorization gap only, it does not introduce maker-checker for job execution. -->
|
||||||
|
<changeSet id="0585_add_loan_cob_catchup_permission" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="EXECUTE_LOANCOBCATCHUP"/>
|
||||||
|
<column name="entity_name" value="LOANCOBCATCHUP"/>
|
||||||
|
<column name="action_name" value="EXECUTE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- No role previously held any permission for this endpoint (it had no permission check at all),
|
||||||
|
so there are no "roles that already hold the base permission" to copy from. Following the
|
||||||
|
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
|
||||||
|
Super user plus any manager-named role. -->
|
||||||
|
<changeSet id="0585_grant_loan_cob_catchup_permission" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP'
|
||||||
|
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!-- The Admin -> System maker-checker audit found 8 base permissions whose "_CHECKER" counterpart
|
||||||
|
row was never seeded (AppUser.validateHasCheckerPermissionTo() computes
|
||||||
|
checkerPermissionName = code.toUpperCase() + "_CHECKER" and requires the approver to hold
|
||||||
|
either CHECKER_SUPER_USER or that exact permission code - see the identical issue already
|
||||||
|
fixed for LOAN_ORIGINATOR in 0583_add_loan_originator_checker_permissions.xml). Without these
|
||||||
|
rows, no ordinary role can ever be granted checker rights for these operations, and
|
||||||
|
UPDATE_BATCH_BUSINESS_STEP (Manage Jobs - COB Workflow Step Reorder) is the one permission in
|
||||||
|
all of Admin -> System that already has can_maker_checker=true today, making its missing
|
||||||
|
checker row a live gap, not merely a structural one. All 8 rows are seeded with
|
||||||
|
can_maker_checker=false: this changeset only makes the approval path structurally ready, it
|
||||||
|
does not itself enable maker-checker on any base permission.
|
||||||
|
|
||||||
|
For CREATE/UPDATE/DELETE_ACCOUNTNUMBERFORMAT_CHECKER, entity_name is set to
|
||||||
|
"ACCOUNTNUMBERFORMAT" for all three (matching CREATE_ACCOUNTNUMBERFORMAT and
|
||||||
|
UPDATE_ACCOUNTNUMBERFORMAT). The pre-existing base permission DELETE_ACCOUNTNUMBERFORMAT has
|
||||||
|
entity_name="HOOK" (a pre-existing data bug, confirmed via read-only query, left untouched -
|
||||||
|
fixing an existing permission row's value was out of scope for this change). This checker row
|
||||||
|
deliberately uses the correct entity name rather than propagating that bug. -->
|
||||||
|
<changeSet id="0586_add_system_module_checker_permissions" author="cbs-anshul">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="CREATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="UPDATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="configuration"/>
|
||||||
|
<column name="code" value="DELETE_ACCOUNTNUMBERFORMAT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
|
||||||
|
<column name="action_name" value="DELETE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="CREATE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="UPDATE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="infrastructure"/>
|
||||||
|
<column name="code" value="DELETE_ENTITYMAPPING_CHECKER"/>
|
||||||
|
<column name="entity_name" value="ENTITYMAPPING"/>
|
||||||
|
<column name="action_name" value="DELETE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="CREATE_USERAGREEMENT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="USERAGREEMENT"/>
|
||||||
|
<column name="action_name" value="CREATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="organisation"/>
|
||||||
|
<column name="code" value="UPDATE_BATCH_BUSINESS_STEP_CHECKER"/>
|
||||||
|
<column name="entity_name" value="BATCH_BUSINESS_STEP"/>
|
||||||
|
<column name="action_name" value="UPDATE_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Granted to whichever roles already hold the corresponding base permission - the same
|
||||||
|
"grant to roles that already hold the base permission" convention used in
|
||||||
|
0583_add_loan_originator_checker_permissions.xml. -->
|
||||||
|
<changeSet id="0586_grant_system_module_checker_permissions" author="cbs-anshul">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, checker.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission base ON base.id = rp.permission_id
|
||||||
|
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
|
||||||
|
WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
||||||
|
'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING',
|
||||||
|
'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp2
|
||||||
|
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+20
-6
@@ -105,14 +105,28 @@ public interface DepositAccountWritePlatformService {
|
|||||||
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
|
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product
|
* Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto
|
||||||
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning
|
* that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has
|
||||||
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the
|
* passed; a no-op, returning zero, if still within it. Paid to the linked account because it was
|
||||||
* generic `payment` command (the same one the account's own Account Actions menu uses), so every
|
* earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it
|
||||||
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit}
|
* renews from its real maturity amount regardless of which closure type follows. Called as a
|
||||||
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
|
* pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same
|
||||||
|
* one the account's own Account Actions menu uses), so every closure type is available from the
|
||||||
|
* Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns
|
||||||
|
* {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
|
||||||
*/
|
*/
|
||||||
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
|
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only preview of what renewing this matured FD right now would do -- shown in the FD
|
||||||
|
* Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns
|
||||||
|
* isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace,
|
||||||
|
* currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo,
|
||||||
|
* linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and
|
||||||
|
* projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually
|
||||||
|
* renews).
|
||||||
|
*/
|
||||||
|
Map<String, Object> previewFdRenewal(Long fdAccountId);
|
||||||
|
|
||||||
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
|
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user