Compare commits
28
Commits
dev
..
2c49d6f637
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c49d6f637 | ||
|
|
331c553164 | ||
|
|
3fb008a22a | ||
|
|
8b2fb052d4 | ||
|
|
ea39c8e82a | ||
|
|
32411c924b | ||
|
|
f696499102 | ||
|
|
4b9093007f | ||
|
|
d2e13cc7b9 | ||
|
|
ecfe8aa76a | ||
|
|
a7cb09f15c | ||
|
|
26d4c241c7 | ||
|
|
dbc2737ada | ||
|
|
f457b9b8c7 | ||
|
|
23ee4f1063 | ||
|
|
bc38216ca3 | ||
|
|
fde2699957 | ||
|
|
dd4bb4a1c0 | ||
|
|
6046134b51 | ||
|
|
e4fad8b648 | ||
|
|
e206536a6b | ||
|
|
bf52c2cda1 | ||
|
|
934c11bf87 | ||
|
|
c1e6e036d0 | ||
|
|
4c0512f154 | ||
|
|
b779d04571 | ||
|
|
184d1b8cf8 | ||
|
|
521d61d6b7 |
+7
-1
@@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
||||
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
|
||||
|
||||
// Optional, not mandatory like the three below: it names an account for cash physically
|
||||
// received at the counter for a share purchase, which every register in this bank now
|
||||
// books against the account the money actually reached instead. Left absent, a product
|
||||
// simply carries no such mapping, and any code path that still needs one (a genuine
|
||||
// over-the-counter collection) fails with a named "GL not mapped" error rather than
|
||||
// silently assuming the till.
|
||||
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||
element);
|
||||
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
|
||||
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
|
||||
.integerGreaterThanZero();
|
||||
|
||||
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
|
||||
|
||||
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
+14
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
return accountMappingDetails;
|
||||
}
|
||||
|
||||
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
|
||||
@Override
|
||||
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
|
||||
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
|
||||
PortfolioProductType.CURRENT.getValue());
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
|
||||
return setCashSavingsProductToGLAccountMaps(mappings);
|
||||
}
|
||||
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
|
||||
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
|
||||
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
|
||||
|
||||
+35
-3
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
||||
case NONE:
|
||||
break;
|
||||
case CASH_BASED:
|
||||
// asset
|
||||
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
||||
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
||||
// asset -- the one optional mapping here, so unlike the three below it needs its own
|
||||
// handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
|
||||
mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
|
||||
|
||||
// income
|
||||
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
|
||||
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
||||
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
|
||||
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
|
||||
* the accounting rule.
|
||||
*
|
||||
* <p>
|
||||
* The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
|
||||
* {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
|
||||
* changed" for them -- a null value there cannot happen without failing validation first. Shares
|
||||
* Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
|
||||
* share products alike) only handles "present": it silently leaves an existing mapping row alone
|
||||
* when the incoming value is null, rather than clearing it. Editing this one field out of an
|
||||
* existing product would otherwise leave the product still carrying its old GL underneath, invisible
|
||||
* on the edit form but still resolvable by any code path that looks it up directly.
|
||||
*/
|
||||
private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
|
||||
final Map<String, Object> changes) {
|
||||
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||
element);
|
||||
if (shareReferenceId != null) {
|
||||
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
||||
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
||||
return;
|
||||
}
|
||||
final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
|
||||
PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
|
||||
if (existing != null) {
|
||||
this.accountMappingRepository.delete(existing);
|
||||
changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -72,6 +72,7 @@ public enum JobName {
|
||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
|
||||
SAVING(2, "productType.saving"), //
|
||||
CLIENT(5, "productType.client"), //
|
||||
PROVISIONING(3, "productType.provisioning"), //
|
||||
SHARES(4, "productType.shares"); //
|
||||
SHARES(4, "productType.shares"), //
|
||||
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
|
||||
// product and a current product with the same id read each other's GL rows.
|
||||
CURRENT(6, "productType.current"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "disposition", length = 20)
|
||||
private String disposition;
|
||||
|
||||
/**
|
||||
* The other rules this same transaction matched, comma-separated.
|
||||
*
|
||||
* <p>
|
||||
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
|
||||
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
|
||||
* stopping them clearing one while escalating another. The rules that did not win are kept here
|
||||
* so the case file still shows everything that fired.
|
||||
*/
|
||||
@Column(name = "also_matched_rules", length = 500)
|
||||
private String alsoMatchedRules;
|
||||
|
||||
/**
|
||||
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
|
||||
*
|
||||
* <p>
|
||||
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
|
||||
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
|
||||
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
|
||||
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
|
||||
*/
|
||||
@Column(name = "highest_severity")
|
||||
private Integer highestSeverity;
|
||||
|
||||
protected AmlAlert() {}
|
||||
|
||||
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
|
||||
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return alert;
|
||||
}
|
||||
|
||||
public String getAlsoMatchedRules() {
|
||||
return this.alsoMatchedRules;
|
||||
}
|
||||
|
||||
public Integer getHighestSeverity() {
|
||||
return this.highestSeverity;
|
||||
}
|
||||
|
||||
public void recordHighestSeverity(final Integer severity) {
|
||||
this.highestSeverity = severity;
|
||||
}
|
||||
|
||||
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
|
||||
public void recordAlsoMatchedRules(final String ruleCodes) {
|
||||
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
|
||||
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this alert is being worked by somebody other than the given officer.
|
||||
*
|
||||
* <p>
|
||||
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
|
||||
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
|
||||
* would carry two reviews, and whichever was written last would be the one on file.
|
||||
*/
|
||||
public boolean isHeldByAnother(final AppUser officer) {
|
||||
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
|
||||
&& !this.reviewedBy.getId().equals(officer.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
|
||||
*
|
||||
* <p>
|
||||
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
|
||||
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
|
||||
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
|
||||
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
|
||||
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
|
||||
*/
|
||||
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
|
||||
this.reviewedBy = principalOfficer;
|
||||
this.reviewedOn = LocalDateTime.now();
|
||||
this.reviewNotes = notes;
|
||||
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
|
||||
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
|
||||
}
|
||||
|
||||
public void whitelist(AppUser reviewer, String notes) {
|
||||
this.reviewedBy = reviewer;
|
||||
this.reviewedOn = LocalDateTime.now();
|
||||
|
||||
+14
@@ -44,4 +44,18 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
*/
|
||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||
|
||||
/**
|
||||
* A customer's still-open alerts inside the observation window, oldest first.
|
||||
*
|
||||
* <p>
|
||||
* What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses
|
||||
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
|
||||
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the
|
||||
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
|
||||
* that a partial failure leaves the longest-standing ones dealt with.
|
||||
*/
|
||||
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
|
||||
+ "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC")
|
||||
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
|
||||
}
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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.aml.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/**
|
||||
* One rule a transaction matched, and what the officer decided about that rule.
|
||||
*
|
||||
* <p>
|
||||
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
|
||||
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
|
||||
* observation or sends the other five to an STR they do not belong in.
|
||||
*
|
||||
* <p>
|
||||
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
|
||||
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
|
||||
* afterwards must not rewrite what the officer was looking at when they decided.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_alert_rule")
|
||||
public class AmlAlertRule {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "alert_id", nullable = false)
|
||||
private AmlAlert alert;
|
||||
|
||||
@Column(name = "rule_code", nullable = false, length = 100)
|
||||
private String ruleCode;
|
||||
|
||||
@Column(name = "risk_category")
|
||||
private Integer riskCategory;
|
||||
|
||||
@Column(name = "is_governing", nullable = false)
|
||||
private boolean governing;
|
||||
|
||||
@Column(name = "disposition", length = 20)
|
||||
private String disposition;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "reviewed_by")
|
||||
private AppUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on")
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
@Column(name = "review_notes", length = 1000)
|
||||
private String reviewNotes;
|
||||
|
||||
/**
|
||||
* Whether the officer named this rule, as against it being closed to keep the case consistent.
|
||||
*
|
||||
* <p>
|
||||
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
|
||||
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
|
||||
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
|
||||
* the substance of the finding.
|
||||
*/
|
||||
@Column(name = "directly_decided", nullable = false)
|
||||
private boolean directlyDecided;
|
||||
|
||||
protected AmlAlertRule() {}
|
||||
|
||||
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
|
||||
final boolean governing) {
|
||||
final AmlAlertRule finding = new AmlAlertRule();
|
||||
finding.alert = alert;
|
||||
finding.ruleCode = ruleCode;
|
||||
finding.riskCategory = riskCategory;
|
||||
finding.governing = governing;
|
||||
return finding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param directlyDecided
|
||||
* true when the officer named this rule; false when it was closed alongside one they named
|
||||
*/
|
||||
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
|
||||
final boolean directlyDecided) {
|
||||
this.disposition = disposition.name();
|
||||
this.reviewedBy = reviewer;
|
||||
this.reviewedOn = LocalDateTime.now();
|
||||
this.reviewNotes = notes;
|
||||
this.directlyDecided = directlyDecided;
|
||||
}
|
||||
|
||||
public boolean isDirectlyDecided() {
|
||||
return this.directlyDecided;
|
||||
}
|
||||
|
||||
public boolean isDecided() {
|
||||
return this.disposition != null;
|
||||
}
|
||||
|
||||
public boolean isEscalated() {
|
||||
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public AmlAlert getAlert() {
|
||||
return this.alert;
|
||||
}
|
||||
|
||||
public String getRuleCode() {
|
||||
return this.ruleCode;
|
||||
}
|
||||
|
||||
public Integer getRiskCategory() {
|
||||
return this.riskCategory;
|
||||
}
|
||||
|
||||
public boolean isGoverning() {
|
||||
return this.governing;
|
||||
}
|
||||
|
||||
public String getDisposition() {
|
||||
return this.disposition;
|
||||
}
|
||||
|
||||
public AppUser getReviewedBy() {
|
||||
return this.reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return this.reviewedOn;
|
||||
}
|
||||
|
||||
public String getReviewNotes() {
|
||||
return this.reviewNotes;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.aml.domain;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
|
||||
|
||||
List<AmlAlertRule> findByAlertId(Long alertId);
|
||||
|
||||
/** Every finding across a page of alerts, in one read rather than one query per alert. */
|
||||
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.aml.domain;
|
||||
|
||||
/**
|
||||
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
|
||||
*
|
||||
* <p>
|
||||
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
|
||||
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
|
||||
* fortieth review cost an officer the same effort as the first and changed as little.
|
||||
*
|
||||
* <p>
|
||||
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
|
||||
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
|
||||
* about should come back sooner, not later.
|
||||
*/
|
||||
public enum AmlRecurringEscalationTier {
|
||||
|
||||
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
|
||||
ROUTINE(0, 90),
|
||||
|
||||
/**
|
||||
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
|
||||
* the reviewer is asked to consider whether the rating still fits.
|
||||
*/
|
||||
ELEVATED(7, 30),
|
||||
|
||||
/**
|
||||
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
|
||||
* to be taken by someone senior.
|
||||
*/
|
||||
SENIOR(15, 14),
|
||||
|
||||
/**
|
||||
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
|
||||
* is a decision that has to be defensible on its own - so the review stands for a week only.
|
||||
*/
|
||||
MANDATORY_STR(30, 7);
|
||||
|
||||
private final int minimumAlertCount;
|
||||
private final int reviewValidityDays;
|
||||
|
||||
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
|
||||
this.minimumAlertCount = minimumAlertCount;
|
||||
this.reviewValidityDays = reviewValidityDays;
|
||||
}
|
||||
|
||||
/** The tier a customer sitting on this many alerts falls into. */
|
||||
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
|
||||
AmlRecurringEscalationTier tier = ROUTINE;
|
||||
for (final AmlRecurringEscalationTier candidate : values()) {
|
||||
if (alertCount >= candidate.minimumAlertCount) {
|
||||
tier = candidate;
|
||||
}
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
public int getMinimumAlertCount() {
|
||||
return minimumAlertCount;
|
||||
}
|
||||
|
||||
public int getReviewValidityDays() {
|
||||
return reviewValidityDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the alert count may climb before a standing review is set aside and the customer returns early.
|
||||
*
|
||||
* <p>
|
||||
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
|
||||
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
|
||||
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
|
||||
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
|
||||
*/
|
||||
public int alertGrowthBeforeEarlyReturn() {
|
||||
return switch (this) {
|
||||
case ROUTINE -> 5;
|
||||
case ELEVATED -> 4;
|
||||
case SENIOR -> 3;
|
||||
case MANDATORY_STR -> 2;
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
|
||||
public boolean expectsStr() {
|
||||
return this == MANDATORY_STR;
|
||||
}
|
||||
}
|
||||
+59
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
|
||||
@Column(name = "reviewed_on", nullable = false)
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
/**
|
||||
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
|
||||
* behaviour of lapsing the moment one more alert lands.
|
||||
*/
|
||||
@Column(name = "valid_until")
|
||||
private LocalDate validUntil;
|
||||
|
||||
@Column(name = "escalation_tier", length = 20)
|
||||
private String escalationTier;
|
||||
|
||||
/** How many of the customer's open alerts this review actually disposed of. */
|
||||
@Column(name = "alerts_actioned")
|
||||
private Integer alertsActioned;
|
||||
|
||||
protected AmlRecurringReview() {}
|
||||
|
||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
|
||||
review.notes = notes;
|
||||
review.reviewedBy = reviewedBy;
|
||||
review.reviewedOn = LocalDateTime.now();
|
||||
|
||||
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
|
||||
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
|
||||
// always being cleared.
|
||||
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
|
||||
review.escalationTier = tier.name();
|
||||
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
|
||||
review.alertsActioned = 0;
|
||||
return review;
|
||||
}
|
||||
|
||||
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
|
||||
public void recordAlertsActioned(final int count) {
|
||||
this.alertsActioned = count;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return reviewedOn;
|
||||
}
|
||||
|
||||
public LocalDate getValidUntil() {
|
||||
return validUntil;
|
||||
}
|
||||
|
||||
public AmlRecurringEscalationTier getEscalationTier() {
|
||||
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
|
||||
}
|
||||
|
||||
public Integer getAlertsActioned() {
|
||||
return alertsActioned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this review still holds the customer out of the review queue.
|
||||
*
|
||||
* <p>
|
||||
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
|
||||
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
|
||||
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
|
||||
* lapsing as soon as one more alert arrives.
|
||||
*/
|
||||
public boolean stillStands(final int currentAlertCount) {
|
||||
if (validUntil == null) {
|
||||
return currentAlertCount <= alertCountAtReview;
|
||||
}
|
||||
if (LocalDate.now().isAfter(validUntil)) {
|
||||
return false;
|
||||
}
|
||||
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.aml.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One alert covered by an STR case.
|
||||
*
|
||||
* <p>
|
||||
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
|
||||
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
|
||||
* Principal Officer has to be able to read in one place.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_str_case_alert")
|
||||
public class AmlStrCaseAlert {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "str_case_id", nullable = false)
|
||||
private AmlStrCase strCase;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "alert_id", nullable = false)
|
||||
private AmlAlert alert;
|
||||
|
||||
@Column(name = "added_on_utc", nullable = false)
|
||||
private LocalDateTime addedOn;
|
||||
|
||||
protected AmlStrCaseAlert() {}
|
||||
|
||||
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
|
||||
final AmlStrCaseAlert link = new AmlStrCaseAlert();
|
||||
link.strCase = strCase;
|
||||
link.alert = alert;
|
||||
link.addedOn = LocalDateTime.now();
|
||||
return link;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public AmlStrCase getStrCase() {
|
||||
return strCase;
|
||||
}
|
||||
|
||||
public AmlAlert getAlert() {
|
||||
return alert;
|
||||
}
|
||||
|
||||
public LocalDateTime getAddedOn() {
|
||||
return addedOn;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
|
||||
|
||||
/** The alerts one case covers, oldest first - the order the pattern happened in. */
|
||||
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
|
||||
|
||||
/**
|
||||
* The case already covering an alert, if any.
|
||||
*
|
||||
* <p>
|
||||
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
|
||||
* one, however it comes to be escalated again.
|
||||
*/
|
||||
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
|
||||
|
||||
long countByStrCase_Id(Long strCaseId);
|
||||
}
|
||||
+4
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
|
||||
|
||||
long countByClient_Id(Long clientId);
|
||||
|
||||
/** The case already open for a transaction, so escalating it again does not open a second one. */
|
||||
Optional<AmlStrCase> findByAlertId(Long alertId);
|
||||
|
||||
List<AmlStrCase> findByStatus(String status);
|
||||
|
||||
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
|
||||
|
||||
+7
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
|
||||
* by hand from the ledger.
|
||||
*/
|
||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||
|
||||
/**
|
||||
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
|
||||
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
|
||||
*/
|
||||
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
|
||||
}
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* What one customer's money actually did over a period, and what that says about their rating.
|
||||
*
|
||||
* <p>
|
||||
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
|
||||
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
|
||||
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
|
||||
* Neither is a pattern.
|
||||
*
|
||||
* <p>
|
||||
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
|
||||
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
|
||||
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
|
||||
* the system was looking at rather than a conclusion with no working.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_behaviour_window")
|
||||
public class ClientBehaviourWindow {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
private Long clientId;
|
||||
|
||||
@Column(name = "window_from", nullable = false)
|
||||
private LocalDate windowFrom;
|
||||
|
||||
@Column(name = "window_to", nullable = false)
|
||||
private LocalDate windowTo;
|
||||
|
||||
@Column(name = "txn_count")
|
||||
private Integer txnCount;
|
||||
|
||||
@Column(name = "distinct_months")
|
||||
private Integer distinctMonths;
|
||||
|
||||
@Column(name = "credit_total", precision = 19, scale = 6)
|
||||
private BigDecimal creditTotal;
|
||||
|
||||
@Column(name = "debit_total", precision = 19, scale = 6)
|
||||
private BigDecimal debitTotal;
|
||||
|
||||
@Column(name = "largest_txn", precision = 19, scale = 6)
|
||||
private BigDecimal largestTxn;
|
||||
|
||||
@Column(name = "cash_txn_count")
|
||||
private Integer cashTxnCount;
|
||||
|
||||
@Column(name = "cash_total", precision = 19, scale = 6)
|
||||
private BigDecimal cashTotal;
|
||||
|
||||
@Column(name = "large_txn_count")
|
||||
private Integer largeTxnCount;
|
||||
|
||||
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||
private BigDecimal declaredAnnualIncome;
|
||||
|
||||
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||
private BigDecimal declaredAnnualTurnover;
|
||||
|
||||
@Column(name = "income_ratio", precision = 19, scale = 6)
|
||||
private BigDecimal incomeRatio;
|
||||
|
||||
@Column(name = "alert_count")
|
||||
private Integer alertCount;
|
||||
|
||||
@Column(name = "str_case_count")
|
||||
private Integer strCaseCount;
|
||||
|
||||
@Column(name = "risk_category_at_review")
|
||||
private Integer riskCategoryAtReview;
|
||||
|
||||
/** UP, DOWN or HOLD. */
|
||||
@Column(name = "suggested_direction", length = 10)
|
||||
private String suggestedDirection;
|
||||
|
||||
@Column(name = "suggested_category")
|
||||
private Integer suggestedCategory;
|
||||
|
||||
@Column(name = "suggestion_reason", length = 500)
|
||||
private String suggestionReason;
|
||||
|
||||
@Column(name = "computed_on")
|
||||
private LocalDateTime computedOn;
|
||||
|
||||
protected ClientBehaviourWindow() {}
|
||||
|
||||
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
|
||||
final ClientBehaviourWindow window = new ClientBehaviourWindow();
|
||||
window.clientId = clientId;
|
||||
window.windowFrom = windowFrom;
|
||||
window.windowTo = windowTo;
|
||||
window.computedOn = LocalDateTime.now();
|
||||
return window;
|
||||
}
|
||||
|
||||
public void recordDistinctMonths(final Integer months) {
|
||||
this.distinctMonths = months;
|
||||
}
|
||||
|
||||
public Integer getDistinctMonths() {
|
||||
return this.distinctMonths;
|
||||
}
|
||||
|
||||
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
|
||||
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
|
||||
this.txnCount = txnCount;
|
||||
this.creditTotal = creditTotal;
|
||||
this.debitTotal = debitTotal;
|
||||
this.largestTxn = largestTxn;
|
||||
this.cashTxnCount = cashTxnCount;
|
||||
this.cashTotal = cashTotal;
|
||||
this.largeTxnCount = largeTxnCount;
|
||||
}
|
||||
|
||||
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
|
||||
this.alertCount = alertCount;
|
||||
this.strCaseCount = strCaseCount;
|
||||
this.riskCategoryAtReview = riskCategoryAtReview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnover measured against what the customer said to expect.
|
||||
*
|
||||
* <p>
|
||||
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
|
||||
* unknown as a benign zero would clear every customer the bank never asked.
|
||||
*/
|
||||
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
|
||||
this.declaredAnnualIncome = income;
|
||||
this.declaredAnnualTurnover = turnover;
|
||||
final BigDecimal basis = income != null && income.signum() > 0 ? income
|
||||
: (turnover != null && turnover.signum() > 0 ? turnover : null);
|
||||
this.incomeRatio = basis == null || periodTurnover == null ? null
|
||||
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public void recordSuggestion(final String direction, final Integer category, final String reason) {
|
||||
this.suggestedDirection = direction;
|
||||
this.suggestedCategory = category;
|
||||
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
|
||||
}
|
||||
|
||||
public Long getClientId() {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
public LocalDate getWindowFrom() {
|
||||
return this.windowFrom;
|
||||
}
|
||||
|
||||
public LocalDate getWindowTo() {
|
||||
return this.windowTo;
|
||||
}
|
||||
|
||||
public Integer getTxnCount() {
|
||||
return this.txnCount;
|
||||
}
|
||||
|
||||
public BigDecimal getCashTotal() {
|
||||
return this.cashTotal;
|
||||
}
|
||||
|
||||
public Integer getLargeTxnCount() {
|
||||
return this.largeTxnCount;
|
||||
}
|
||||
|
||||
public Integer getAlertCount() {
|
||||
return this.alertCount;
|
||||
}
|
||||
|
||||
public Integer getStrCaseCount() {
|
||||
return this.strCaseCount;
|
||||
}
|
||||
|
||||
public Integer getRiskCategoryAtReview() {
|
||||
return this.riskCategoryAtReview;
|
||||
}
|
||||
|
||||
public String getSuggestedDirection() {
|
||||
return this.suggestedDirection;
|
||||
}
|
||||
|
||||
public Integer getSuggestedCategory() {
|
||||
return this.suggestedCategory;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
|
||||
|
||||
List<ClientBehaviourWindow> findByClientId(Long clientId);
|
||||
|
||||
/**
|
||||
* Clears the windows computed for a given day before the job writes that day's.
|
||||
*
|
||||
* <p>
|
||||
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
|
||||
* set of windows rather than a customer appearing several times on the review screen with slightly different
|
||||
* figures.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
|
||||
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
|
||||
}
|
||||
+58
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "form15_fy", length = 10)
|
||||
private String form15Fy;
|
||||
|
||||
// ── Risk profile ─────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
|
||||
*
|
||||
* <p>
|
||||
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
|
||||
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
|
||||
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
|
||||
*/
|
||||
@Column(name = "is_pep", nullable = false)
|
||||
private boolean isPep;
|
||||
|
||||
@Column(name = "sanctions_match", nullable = false)
|
||||
private boolean sanctionsMatch;
|
||||
|
||||
@Column(name = "adverse_media_match", nullable = false)
|
||||
private boolean adverseMediaMatch;
|
||||
|
||||
/** What the customer told the bank to expect, which their actual turnover is measured against. */
|
||||
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||
private BigDecimal declaredAnnualIncome;
|
||||
|
||||
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||
private BigDecimal declaredAnnualTurnover;
|
||||
|
||||
// ── CKYCRR ───────────────────────────────────────────────────────────────
|
||||
/** Central KYC Records Registry number issued by CERSAI */
|
||||
@Column(name = "ckyc_number", length = 14)
|
||||
@@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
v -> this.aadhaarReceiptRemarks = v);
|
||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||
v -> this.aadhaarReceiptTakenOn = v);
|
||||
// Risk profile. These are determinations about the customer rather than about a transaction, and the
|
||||
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
|
||||
// period's activity looks.
|
||||
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
|
||||
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
|
||||
v -> this.sanctionsMatch = v != null ? v : false);
|
||||
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
|
||||
v -> this.adverseMediaMatch = v != null ? v : false);
|
||||
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
|
||||
v -> this.declaredAnnualIncome = v);
|
||||
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
|
||||
v -> this.declaredAnnualTurnover = v);
|
||||
|
||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||
@@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
recalculateExpiryForRiskCategory();
|
||||
}
|
||||
|
||||
public boolean isPep() {
|
||||
return this.isPep;
|
||||
}
|
||||
|
||||
public boolean isSanctionsMatch() {
|
||||
return this.sanctionsMatch;
|
||||
}
|
||||
|
||||
public boolean isAdverseMediaMatch() {
|
||||
return this.adverseMediaMatch;
|
||||
}
|
||||
|
||||
public BigDecimal getDeclaredAnnualIncome() {
|
||||
return this.declaredAnnualIncome;
|
||||
}
|
||||
|
||||
public BigDecimal getDeclaredAnnualTurnover() {
|
||||
return this.declaredAnnualTurnover;
|
||||
}
|
||||
|
||||
public String getPanNumber() {
|
||||
return panNumber;
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
|
||||
*
|
||||
* <p>
|
||||
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
|
||||
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
|
||||
* three times a month needs different figures from one lending to traders.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_risk_policy_parameter")
|
||||
public class RiskPolicyParameter {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "parameter_key", nullable = false, length = 80)
|
||||
private String parameterKey;
|
||||
|
||||
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
|
||||
private BigDecimal parameterValue;
|
||||
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
@Column(name = "last_modified_by")
|
||||
private Long lastModifiedBy;
|
||||
|
||||
@Column(name = "last_modified_on")
|
||||
private LocalDateTime lastModifiedOn;
|
||||
|
||||
protected RiskPolicyParameter() {}
|
||||
|
||||
public String getParameterKey() {
|
||||
return this.parameterKey;
|
||||
}
|
||||
|
||||
public BigDecimal getParameterValue() {
|
||||
return this.parameterValue;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return this.description;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
|
||||
+9
-2
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
|
||||
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
||||
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
||||
public static final String tenureInMonthsParamName = "tenureInMonths";
|
||||
public static final String tenureInDaysParamName = "tenureInDays";
|
||||
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
||||
|
||||
// Preclosure parameters
|
||||
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
|
||||
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
|
||||
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
|
||||
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
|
||||
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
|
||||
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
|
||||
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
|
||||
|
||||
public static final String depositAmountParamName = "depositAmount";
|
||||
public static final String depositMinAmountParamName = "minDepositAmount";
|
||||
@@ -247,11 +251,13 @@ public final class DepositsApiConstants {
|
||||
|
||||
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
|
||||
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
||||
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||
renewalGracePeriodDaysParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
|
||||
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
||||
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||
renewalGracePeriodDaysParamName));
|
||||
|
||||
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
|
||||
@@ -511,6 +517,7 @@ public final class DepositsApiConstants {
|
||||
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
||||
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
|
||||
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
||||
// Exact calendar-based calculation params
|
||||
|
||||
+9
@@ -286,6 +286,15 @@ public class SavingsApiConstants {
|
||||
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
|
||||
public static final String validForPeriodParamName = "validForPeriod";
|
||||
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
|
||||
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
|
||||
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
|
||||
public static final String odMinLimitParamName = "odMinLimit";
|
||||
public static final String odSecurityTypeParamName = "odSecurityType";
|
||||
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
|
||||
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
|
||||
public static final String odExcessAllowedParamName = "odExcessAllowed";
|
||||
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
|
||||
public static final String odPenalRateParamName = "odPenalRate";
|
||||
|
||||
// Status of Account fields
|
||||
public static final String accountStatusParamName = "accountStatus";
|
||||
|
||||
+10
@@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable {
|
||||
@Getter @Setter private String avgBalanceCalcPeriod;
|
||||
@Getter @Setter private String validForPeriod;
|
||||
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
|
||||
@Getter @Setter private BigDecimal odMinLimit;
|
||||
@Getter @Setter private String odSecurityType;
|
||||
@Getter @Setter private Boolean odExcessAllowed;
|
||||
@Getter @Setter private BigDecimal odExcessLimitPercent;
|
||||
@Getter @Setter private BigDecimal odPenalRate;
|
||||
|
||||
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
|
||||
@Getter @Setter private Map<String, Object> accountFacilityConfig;
|
||||
@@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable {
|
||||
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
|
||||
target.setValidForPeriod(source.getValidForPeriod());
|
||||
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
|
||||
target.setOdMinLimit(source.getOdMinLimit());
|
||||
target.setOdSecurityType(source.getOdSecurityType());
|
||||
target.setOdExcessAllowed(source.getOdExcessAllowed());
|
||||
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
|
||||
target.setOdPenalRate(source.getOdPenalRate());
|
||||
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
|
||||
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
|
||||
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
|
||||
|
||||
+2
-1
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
|
||||
@Column(name = "status_enum", nullable = false)
|
||||
private Integer statusEnum = 100;
|
||||
|
||||
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
|
||||
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
|
||||
// 600=BlockDebit
|
||||
@Column(name = "sub_status_enum", nullable = false)
|
||||
private Integer subStatusEnum = 0;
|
||||
|
||||
|
||||
+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")
|
||||
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);
|
||||
}
|
||||
|
||||
+15
-1
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
@JoinColumn(name = "office_id", nullable = false)
|
||||
private Office office;
|
||||
|
||||
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
|
||||
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
|
||||
@Column(name = "transaction_type_enum", nullable = false)
|
||||
private Integer transactionTypeEnum;
|
||||
|
||||
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
return txn;
|
||||
}
|
||||
|
||||
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
|
||||
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
|
||||
BigDecimal amount, LocalDate transactionDate) {
|
||||
CurrentAccountTransaction txn = new CurrentAccountTransaction();
|
||||
txn.currentAccount = account;
|
||||
txn.office = office;
|
||||
txn.transactionTypeEnum = 19;
|
||||
txn.amount = amount;
|
||||
txn.transactionDate = transactionDate;
|
||||
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
|
||||
txn.submittedOnDate = transactionDate;
|
||||
return txn;
|
||||
}
|
||||
|
||||
// transactionTypeEnum = 7 (Reversal / Undo)
|
||||
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
|
||||
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
|
||||
|
||||
+6
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
|
||||
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
|
||||
|
||||
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
|
||||
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
|
||||
*/
|
||||
boolean escheat(Long accountId);
|
||||
}
|
||||
|
||||
+112
-16
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
|
||||
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
|
||||
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
|
||||
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
|
||||
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
|
||||
SavingsApiConstants.glAccountIdParamName));
|
||||
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
|
||||
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
|
||||
|
||||
/** How long an OD sanction on this scheme runs before it has to be renewed. */
|
||||
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
|
||||
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
|
||||
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
|
||||
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
|
||||
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
|
||||
/** Excess drawing is a small tolerance over the limit, never a second limit. */
|
||||
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
|
||||
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
|
||||
}
|
||||
|
||||
validateOverdraftParams(baseDataValidator, element);
|
||||
validateOverdraftParams(baseDataValidator, element, null);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
|
||||
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
|
||||
}
|
||||
|
||||
if (null != isDormancyActive && isDormancyActive) {
|
||||
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
|
||||
}
|
||||
}
|
||||
|
||||
validateOverdraftParams(baseDataValidator, element);
|
||||
validateOverdraftParams(baseDataValidator, element, product);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
||||
/**
|
||||
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
|
||||
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
|
||||
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
|
||||
* as RBI requires for advances, or at most quarterly.
|
||||
*
|
||||
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
|
||||
* checked against the scheme it will produce; on create it is null.
|
||||
*/
|
||||
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
|
||||
final CurrentProduct product) {
|
||||
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
|
||||
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
|
||||
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
|
||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
|
||||
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
|
||||
: product != null && product.isAllowOverdraft();
|
||||
|
||||
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
|
||||
product == null ? null : product.getOverdraftLimit());
|
||||
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
|
||||
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
|
||||
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
|
||||
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
|
||||
product == null ? null : product.getMinOverdraftForInterestCalculation());
|
||||
|
||||
if (!allowOverdraft) {
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
return;
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
|
||||
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
|
||||
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
|
||||
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
|
||||
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
|
||||
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
|
||||
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
|
||||
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
|
||||
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
|
||||
|
||||
final String securityType = stringOrStored(element, odSecurityTypeParamName,
|
||||
product == null ? null : product.getOdSecurityType());
|
||||
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
|
||||
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
|
||||
|
||||
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
|
||||
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
|
||||
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
|
||||
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
|
||||
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
|
||||
: product != null && product.isOdExcessAllowed();
|
||||
if (excessAllowed) {
|
||||
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
|
||||
product == null ? null : product.getOdExcessLimitPercent());
|
||||
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_EXCESS_PERCENT);
|
||||
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
|
||||
product == null ? null : product.getOdPenalRate());
|
||||
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||
}
|
||||
|
||||
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
|
||||
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
|
||||
: product == null ? null : product.getInterestPostingPeriodType();
|
||||
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
|
||||
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
|
||||
SavingsPostingInterestPeriodType.QUATERLY.getValue());
|
||||
|
||||
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
|
||||
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
|
||||
: product == null ? null : product.getInterestCalculationDaysInYearType();
|
||||
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
|
||||
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
|
||||
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
|
||||
}
|
||||
|
||||
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
|
||||
return this.fromApiJsonHelper.parameterExists(paramName, element)
|
||||
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
|
||||
: stored;
|
||||
}
|
||||
|
||||
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
|
||||
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
|
||||
: stored;
|
||||
}
|
||||
|
||||
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
||||
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
|
||||
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
|
||||
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
|
||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
||||
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
||||
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
||||
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import jakarta.persistence.CascadeType;
|
||||
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "od_account_closed_zero_balance_in_day_closing")
|
||||
private boolean odAccountClosedZeroBalanceInDayClosing = false;
|
||||
|
||||
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
|
||||
@Column(name = "od_min_limit", scale = 6, precision = 19)
|
||||
private BigDecimal odMinLimit;
|
||||
|
||||
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
|
||||
@Column(name = "od_security_type", length = 20)
|
||||
private String odSecurityType;
|
||||
|
||||
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
|
||||
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
|
||||
@Column(name = "od_excess_allowed")
|
||||
private boolean odExcessAllowed = false;
|
||||
|
||||
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
|
||||
private BigDecimal odExcessLimitPercent;
|
||||
|
||||
@Column(name = "od_penal_rate", scale = 6, precision = 19)
|
||||
private BigDecimal odPenalRate;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
|
||||
private CurrentProductFacilityConfig facilityConfig;
|
||||
|
||||
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
|
||||
this.odAccountClosedZeroBalanceInDayClosing = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
|
||||
actualChanges.put(odMinLimitParamName, newValue);
|
||||
this.odMinLimit = newValue;
|
||||
}
|
||||
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
|
||||
actualChanges.put(odSecurityTypeParamName, newValue);
|
||||
this.odSecurityType = newValue;
|
||||
}
|
||||
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||
actualChanges.put(odExcessAllowedParamName, newValue);
|
||||
this.odExcessAllowed = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
|
||||
actualChanges.put(odExcessLimitPercentParamName, newValue);
|
||||
this.odExcessLimitPercent = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
|
||||
actualChanges.put(odPenalRateParamName, newValue);
|
||||
this.odPenalRate = newValue;
|
||||
}
|
||||
if (!this.odExcessAllowed) {
|
||||
this.odExcessLimitPercent = null;
|
||||
this.odPenalRate = null;
|
||||
}
|
||||
// Clear OD-specific fields when overdraft is disabled
|
||||
if (!this.allowOverdraft) {
|
||||
this.avgBalanceCalcPeriod = null;
|
||||
this.validForPeriod = null;
|
||||
this.odAccountClosedZeroBalanceInDayClosing = false;
|
||||
this.odMinLimit = null;
|
||||
this.odSecurityType = null;
|
||||
this.odExcessAllowed = false;
|
||||
this.odExcessLimitPercent = null;
|
||||
this.odPenalRate = null;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
|
||||
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
|
||||
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
|
||||
public BigDecimal getOdMinLimit() { return odMinLimit; }
|
||||
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
|
||||
public String getOdSecurityType() { return odSecurityType; }
|
||||
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
|
||||
public boolean isOdExcessAllowed() { return odExcessAllowed; }
|
||||
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
|
||||
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
|
||||
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
|
||||
public BigDecimal getOdPenalRate() { return odPenalRate; }
|
||||
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
|
||||
}
|
||||
|
||||
+22
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
|
||||
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
|
||||
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
|
||||
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
|
||||
}
|
||||
if (command.parameterExists(odMinLimitParamName)) {
|
||||
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
|
||||
}
|
||||
if (command.parameterExists(odSecurityTypeParamName)) {
|
||||
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
|
||||
}
|
||||
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
|
||||
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||
product.setOdExcessAllowed(excessAllowed);
|
||||
if (excessAllowed) {
|
||||
if (command.parameterExists(odExcessLimitPercentParamName)) {
|
||||
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
|
||||
}
|
||||
if (command.parameterExists(odPenalRateParamName)) {
|
||||
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return product;
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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.currentproduct.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
|
||||
*
|
||||
* <p>
|
||||
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
|
||||
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
|
||||
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
|
||||
* the two can never meet.
|
||||
*
|
||||
* <p>
|
||||
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
|
||||
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentProductGlMappingWriter {
|
||||
|
||||
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
|
||||
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
|
||||
}
|
||||
|
||||
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
|
||||
*
|
||||
* @param accountingRule
|
||||
* the product's accounting rule after this request (1 = NONE)
|
||||
*/
|
||||
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
if (accountingRule == null || accountingRule == 1) {
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||
return changes;
|
||||
}
|
||||
final JsonElement parsed = command.parsedJson();
|
||||
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
|
||||
final String param = entry.getKey();
|
||||
if (!json.has(param)) {
|
||||
continue;
|
||||
}
|
||||
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
|
||||
final Long existing = jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||
+ " AND payment_type IS NULL AND charge_id IS NULL",
|
||||
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
|
||||
if (java.util.Objects.equals(existing, glAccountId)) {
|
||||
continue;
|
||||
}
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
|
||||
if (glAccountId != null) {
|
||||
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
|
||||
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
|
||||
}
|
||||
changes.put(param, glAccountId);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Removes every mapping of a deleted product. */
|
||||
public void deleteAll(final Long productId) {
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||
}
|
||||
}
|
||||
+8
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
||||
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
|
||||
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
|
||||
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
|
||||
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
|
||||
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
|
||||
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
|
||||
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
|
||||
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
|
||||
|
||||
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
||||
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
|
||||
productData.setValidForPeriod(rs.getString("validForPeriod"));
|
||||
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
|
||||
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
|
||||
productData.setOdSecurityType(rs.getString("odSecurityType"));
|
||||
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
|
||||
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
|
||||
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
|
||||
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
|
||||
|
||||
return productData;
|
||||
|
||||
+6
-6
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandSourceRepository commandSourceRepository;
|
||||
private final CurrentProductGlMappingWriter glMappingWriter;
|
||||
|
||||
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
|
||||
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
|
||||
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
|
||||
}
|
||||
|
||||
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
|
||||
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(product.getId()) //
|
||||
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
}
|
||||
|
||||
// accounting related changes
|
||||
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
|
||||
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
|
||||
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
|
||||
product.getAccountingType());
|
||||
changes.putAll(accountingMappingChanges);
|
||||
|
||||
if (!changes.isEmpty()) {
|
||||
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
|
||||
|
||||
this.currentProductRepository.delete(product);
|
||||
this.glMappingWriter.deleteAll(productId);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(product.getId()) //
|
||||
|
||||
+3
-2
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
|
||||
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
|
||||
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
FromJsonHelper fromApiJsonHelper,
|
||||
CommandSourceRepository commandSourceRepository) {
|
||||
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
|
||||
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
|
||||
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
|
||||
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
|
||||
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
|
||||
}
|
||||
}
|
||||
|
||||
+1667
-1611
File diff suppressed because it is too large
Load Diff
+11
@@ -105,7 +105,18 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
||||
.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(" eao.external_id as externalAssetOwner, ")
|
||||
// entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and
|
||||
// current-account entries -- current products deliberately share SAVING's GL type
|
||||
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
|
||||
// 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
|
||||
// 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 here -- only a real savings transaction sets it -- so
|
||||
// it decides first; only when it's absent (a current-account entry, or a savings
|
||||
// entry with no linked transaction row) does the current-account guess apply.
|
||||
.append(" CASE ")
|
||||
.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(" ELSE NULL ")
|
||||
.append(" END as savingsAccountNo ");
|
||||
|
||||
+6
-1
@@ -631,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
|
||||
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
|
||||
|
||||
if (cashBasedAccountingEnabled) {
|
||||
// A register that knows the transaction's real counterparty posts the entry itself, in one
|
||||
// balanced pair against that account, and says so by wrapping the command in
|
||||
// ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second
|
||||
// entry for the same money, booked against the product's fixed Shares Reference account --
|
||||
// which is what that register is avoiding in the first place.
|
||||
if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) {
|
||||
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
|
||||
accrualBasedAccountingEnabled);
|
||||
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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.accounting.journalentry.service;
|
||||
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry.
|
||||
*
|
||||
* <p>
|
||||
* Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two
|
||||
* shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key
|
||||
* ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing,
|
||||
* so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to
|
||||
* share one transaction id and render as a single doubled voucher. Others concatenated
|
||||
* {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen
|
||||
* ({@code "CASHMIG-1-20260913-923935527932300"}).
|
||||
*
|
||||
* <p>
|
||||
* This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}:
|
||||
*
|
||||
* <pre>
|
||||
* SHWGL-260916-0001 the first share-withdrawal GL settlement posted on 16 Sep 2026
|
||||
* DIVRND-260916-0007 the seventh dividend rounding posted that day
|
||||
* </pre>
|
||||
*
|
||||
* The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each
|
||||
* day. That makes a transaction id self-describing: a reader can tell what it was and when it happened
|
||||
* without looking anything up.
|
||||
*
|
||||
* <p>
|
||||
* Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a
|
||||
* counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a
|
||||
* single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two
|
||||
* users posting at the same instant are serialised by the database rather than handed the same number - the
|
||||
* same mechanism {@code m_voucher_sequence} already uses for voucher numbers.
|
||||
*
|
||||
* <p>
|
||||
* The counter alone would still repeat if that table were ever truncated or rewound by hand, which is
|
||||
* precisely the accident being defended against, so the minted id is also checked against the ledger before
|
||||
* being handed back and the counter is advanced past anything already in use. That makes a duplicate
|
||||
* impossible by construction rather than by convention: the id is only returned once no row holds it.
|
||||
*
|
||||
* <p>
|
||||
* Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again
|
||||
* (every call site mints one, posts with it, and never derives it a second time), so old and new shapes
|
||||
* coexist without anything needing to read both.
|
||||
*
|
||||
* <p>
|
||||
* Postings made by Fineract's own command path are not covered here and keep their native
|
||||
* {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and
|
||||
* several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most
|
||||
* recently posted rows rather than assuming the text is unique.
|
||||
*/
|
||||
public final class LedgerTransactionId {
|
||||
|
||||
/** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */
|
||||
private static final int MAX_PROBES = 50;
|
||||
|
||||
private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd");
|
||||
|
||||
private LedgerTransactionId() {}
|
||||
|
||||
/**
|
||||
* Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}.
|
||||
*
|
||||
* @param prefix
|
||||
* short uppercase code naming the event, from {@link LedgerTxnPrefix}
|
||||
*/
|
||||
public static String next(final JdbcTemplate jdbcTemplate, final String prefix) {
|
||||
// Tenant timezone, not the server's - the day the counter resets on has to be the branch's
|
||||
// business day, otherwise a late-evening posting lands in the wrong series.
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String datePart = today.format(YYMMDD);
|
||||
|
||||
for (int probe = 0; probe < MAX_PROBES; probe++) {
|
||||
final int serial = nextSerial(jdbcTemplate, prefix, today);
|
||||
final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial);
|
||||
if (!isTaken(jdbcTemplate, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
// Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks
|
||||
// it forward until it clears whatever is already posted, which self-heals the counter.
|
||||
}
|
||||
throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES
|
||||
+ " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}.
|
||||
*
|
||||
* <p>
|
||||
* Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that
|
||||
* stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that
|
||||
* started them, so the id has to come out the same on the second pass - a counter would hand the
|
||||
* remainder a different id and split one posting across two transactions. This builds the id from
|
||||
* what identifies the run instead: the same prefix, date and discriminator always produce the same
|
||||
* string, and no counter is consumed.
|
||||
*
|
||||
* <p>
|
||||
* Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the
|
||||
* product being posted, say), which is what makes the id reproducible in the first place. Use
|
||||
* {@link #next} for everything else - a posting that happens once wants a counter, not a formula.
|
||||
* The {@code P} marks the tail as a discriminator so it is not misread as a serial.
|
||||
*
|
||||
* @param date
|
||||
* the date the posting belongs to, which is not always today - interest can be posted
|
||||
* for an earlier day
|
||||
* @param discriminator
|
||||
* what makes this run distinct from another on the same date, e.g. the product id
|
||||
*/
|
||||
public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) {
|
||||
return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves the next counter value for one (prefix, day) series.
|
||||
*
|
||||
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and
|
||||
* increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed.
|
||||
*/
|
||||
private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number)
|
||||
VALUES (?,?,1)
|
||||
ON CONFLICT (prefix, series_date)
|
||||
DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1
|
||||
RETURNING last_number
|
||||
""", Integer.class, prefix, Date.valueOf(day));
|
||||
}
|
||||
|
||||
private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) {
|
||||
final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)",
|
||||
Boolean.class, transactionId);
|
||||
return Boolean.TRUE.equals(taken);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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.accounting.journalentry.service;
|
||||
|
||||
/**
|
||||
* The event codes that open a transaction id minted by {@link LedgerTransactionId}.
|
||||
*
|
||||
* <p>
|
||||
* They live together in one place so a reader can decode any transaction id on a screen from a single list,
|
||||
* and so no two features can drift into naming the same event differently - which had already happened:
|
||||
* dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from
|
||||
* another, for the same event.
|
||||
*
|
||||
* <p>
|
||||
* Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of
|
||||
* the audit trail once it has been posted, so treat these as append-only: add a new one for a new event
|
||||
* rather than renaming an existing one, which would leave already-posted entries speaking a language nothing
|
||||
* else uses any more.
|
||||
*/
|
||||
public final class LedgerTxnPrefix {
|
||||
|
||||
private LedgerTxnPrefix() {}
|
||||
|
||||
// A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the
|
||||
// native redeem command already minted ("SH" + share transaction id), because its debit leg is
|
||||
// that command's own and the two have to group as one balanced entry.
|
||||
|
||||
// ── Share tranche funding ─────────────────────────────────────────────────
|
||||
/** Additional share tranche: reversal of the stock reference posting. */
|
||||
public static final String SHARE_TRANCHE_REFERENCE = "SHTREF";
|
||||
/** Additional share tranche funded from the member's savings account. */
|
||||
public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV";
|
||||
|
||||
// ── Share collection and advances ─────────────────────────────────────────
|
||||
/** Share application money collected. */
|
||||
public static final String SHARE_COLLECTION = "SHCOL";
|
||||
/** Share advance collected against a future allotment. */
|
||||
public static final String SHARE_ADVANCE = "SHADV";
|
||||
/** Share advance applied to an allotment. */
|
||||
public static final String SHARE_ADVANCE_APPLIED = "SHAPP";
|
||||
|
||||
// ── Dividend ──────────────────────────────────────────────────────────────
|
||||
/** Dividend claim paid out to a member. */
|
||||
public static final String DIVIDEND_CLAIM = "DIVCLM";
|
||||
/** Dividend declaration posted. */
|
||||
public static final String DIVIDEND_DECLARED = "DIVDCL";
|
||||
/** Dividend declaration approved. */
|
||||
public static final String DIVIDEND_APPROVED = "DIVAPR";
|
||||
/** Dividend payout closed off. */
|
||||
public static final String DIVIDEND_CLOSED = "DIVCLS";
|
||||
/** Rounding difference on a dividend payout. */
|
||||
public static final String DIVIDEND_ROUNDING = "DIVRND";
|
||||
/** Unclaimed dividend forfeited. */
|
||||
public static final String DIVIDEND_FORFEIT = "DIVFRF";
|
||||
|
||||
// ── Branch day and cash ───────────────────────────────────────────────────
|
||||
/** Opening cash migrated into the branch day register. */
|
||||
public static final String CASH_MIGRATION = "CSHMIG";
|
||||
/** Rounding difference on a clerk's day closure. */
|
||||
public static final String CLOSURE_ROUNDING = "CLSRND";
|
||||
|
||||
// ── Year end ──────────────────────────────────────────────────────────────
|
||||
/** Year-end transfer of income into the profit account. */
|
||||
public static final String YEAR_END_PROFIT = "YEPFT";
|
||||
/** Year-end closure of an income or expense account. */
|
||||
public static final String YEAR_END_CLOSE = "YECLS";
|
||||
|
||||
// ── Savings ───────────────────────────────────────────────────────────────
|
||||
/** Pooled savings interest posting for one product and date. */
|
||||
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.accounting.journalentry.service;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Lets a register that posts a share transaction's accounting itself tell the platform not to post its
|
||||
* own.
|
||||
*
|
||||
* <h3>Why this exists</h3>
|
||||
*
|
||||
* <p>
|
||||
* Fineract books every share transaction against a fixed "Shares Reference" account taken from the
|
||||
* product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money
|
||||
* actually did. That account is a placeholder for a counterparty the platform does not know, and in a
|
||||
* real core banking ledger there is no such thing — a redemption paid in cash credits the cash
|
||||
* account, and one settled by transfer credits the account it was transferred to. Naming a single
|
||||
* asset account for both leaves every redemption sitting against a GL the money never touched, which
|
||||
* then has to be corrected by hand.
|
||||
*
|
||||
* <p>
|
||||
* The governed clerk registers already know the real counterparty, because the clerk keyed it: the
|
||||
* linked savings account, or the GL picked on the transfer's credit row. They post the redemption
|
||||
* themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform
|
||||
* post its version first and then reverse the half that was wrong, which left a dead Cr Shares
|
||||
* Reference row behind on every redemption and made the ledger read as though cash had moved.
|
||||
*
|
||||
* <h3>Why a flag and not the product's accounting rule</h3>
|
||||
*
|
||||
* <p>
|
||||
* Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE})
|
||||
* would silence the platform everywhere, including the share account screen's own Redeem action, which
|
||||
* no register covers — those redemptions would post nothing at all and go unnoticed, because that
|
||||
* branch fails silently by design. Scoping the suppression to the one call that is about to post its
|
||||
* own entry leaves every other path exactly as it is.
|
||||
*
|
||||
* <p>
|
||||
* A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing
|
||||
* it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to
|
||||
* hang the flag on, and nesting must not switch the suppression off early for an outer caller.
|
||||
*/
|
||||
public final class ShareAccountingSuppression {
|
||||
|
||||
private static final ThreadLocal<Boolean> CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE);
|
||||
|
||||
private ShareAccountingSuppression() {}
|
||||
|
||||
/**
|
||||
* Runs {@code command} with the platform's own share journal entries suppressed, for a caller that
|
||||
* posts the transaction's accounting itself.
|
||||
*/
|
||||
public static <T> T withOwnShareEntries(final Supplier<T> command) {
|
||||
final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get();
|
||||
CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE);
|
||||
try {
|
||||
return command.get();
|
||||
} finally {
|
||||
CALLER_POSTS_SHARE_ENTRIES.set(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */
|
||||
public static boolean shouldPostShareEntries() {
|
||||
return !CALLER_POSTS_SHARE_ENTRIES.get();
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
@@ -117,7 +119,7 @@ public class IncomeProfitTransferService {
|
||||
final String currencyCode = resolveCurrency(incomeGlId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YEPROFIT-" + System.nanoTime();
|
||||
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT);
|
||||
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
@@ -210,7 +212,7 @@ public class IncomeProfitTransferService {
|
||||
final String currencyCode = resolveCurrency(glId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
|
||||
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE);
|
||||
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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.account.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* A book debit: money leaves a savings or current account by book entry, not as cash.
|
||||
*
|
||||
* <p>
|
||||
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
|
||||
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
|
||||
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
|
||||
* report shows.
|
||||
*
|
||||
* <p>
|
||||
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
|
||||
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
|
||||
* transaction, so a later failure rolls the debit back with everything else.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountBookDebitService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
|
||||
/** Debits a savings account; returns the m_savings_account_transaction id. */
|
||||
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||
final Long userId) {
|
||||
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
|
||||
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(con -> {
|
||||
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
|
||||
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
|
||||
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
|
||||
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, accountId);
|
||||
ps.setLong(2, officeId);
|
||||
ps.setDate(3, sqlDate);
|
||||
ps.setBigDecimal(4, amount);
|
||||
ps.setDate(5, sqlDate);
|
||||
ps.setString(6, refNo());
|
||||
ps.setBigDecimal(7, balance.subtract(amount));
|
||||
ps.setLong(8, userId);
|
||||
ps.setLong(9, userId);
|
||||
ps.setObject(10, now);
|
||||
ps.setObject(11, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
|
||||
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/** Debits a current account; returns the m_current_account_transaction id. */
|
||||
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||
final Long userId, final String note) {
|
||||
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
|
||||
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(con -> {
|
||||
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
|
||||
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
|
||||
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
|
||||
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
|
||||
ps.setLong(1, accountId);
|
||||
ps.setLong(2, officeId);
|
||||
ps.setDate(3, sqlDate);
|
||||
ps.setBigDecimal(4, amount);
|
||||
ps.setString(5, refNo());
|
||||
ps.setDate(6, sqlDate);
|
||||
ps.setBigDecimal(7, balance.subtract(amount));
|
||||
ps.setString(8, remarks);
|
||||
ps.setLong(9, userId);
|
||||
return ps;
|
||||
}, kh);
|
||||
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
|
||||
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
|
||||
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
|
||||
accountId);
|
||||
}
|
||||
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
|
||||
if (status != 300) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
|
||||
kind + " account " + accountId + " is not active.", accountId);
|
||||
}
|
||||
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
|
||||
: (BigDecimal) rows.get(0).get("account_balance_derived");
|
||||
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
|
||||
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
|
||||
final BigDecimal available = balance.subtract(onHold);
|
||||
if (available.compareTo(amount) < 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
|
||||
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
|
||||
+ ", Requested: " + amount,
|
||||
accountId);
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
private static String refNo() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
}
|
||||
}
|
||||
+26
-21
@@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
|
||||
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountRepository currentAccountRepository;
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
|
||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
|
||||
toSavingsAccount.productId(), toSavingsAccount.getId());
|
||||
toSavingsAccount.productId(), false, toSavingsAccount.getId());
|
||||
|
||||
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
|
||||
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
|
||||
@@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getTransactionDate(),
|
||||
fromSavingsAccount.productId(), fromSavingsAccount.getId());
|
||||
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
|
||||
|
||||
if (accountTransferDetails == null) {
|
||||
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
|
||||
@@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getTransactionDate(),
|
||||
toCurrentAccount.getProductId(), toCurrentAccount.getId());
|
||||
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
|
||||
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getTransactionDate(),
|
||||
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
|
||||
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
|
||||
|
||||
if (accountTransferDetails == null) {
|
||||
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
|
||||
@@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate) {
|
||||
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
||||
final BigDecimal available = balance.subtract(onHold);
|
||||
@@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
currentAccountRepository.save(account);
|
||||
if (account.getProductId() != null && txn.getId() != null) {
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
|
||||
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
|
||||
account.getId(), currentTxnId, txnDate, amount, false);
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
account.getId(), currentTxnId, txnDate, amount);
|
||||
}
|
||||
return txn;
|
||||
}
|
||||
@@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
||||
final BigDecimal available = balance.subtract(onHold);
|
||||
@@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
|
||||
@@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Deposit amount into a current account and save the transaction.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
|
||||
final BigDecimal newBalance = balance.add(amount);
|
||||
@@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
|
||||
@@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
*/
|
||||
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
|
||||
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
|
||||
final Long counterpartProductId, final Long counterpartEntityId) {
|
||||
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
|
||||
if (account.getProductId() == null || txn.getId() == null) {
|
||||
return;
|
||||
}
|
||||
@@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
if (isWithdrawal) {
|
||||
// DR: current account GL CR: counterpart (savings/current) GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
|
||||
account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), account.getId(),
|
||||
counterpartProductId, counterpartEntityId,
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
currentTxnId, txnDate, amount);
|
||||
} else {
|
||||
// DR: counterpart (savings/current) GL CR: current account GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
|
||||
account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartEntityId,
|
||||
account.getProductId(), account.getId(),
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
currentTxnId, txnDate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
+141
-140
@@ -1,140 +1,141 @@
|
||||
/**
|
||||
* 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.account.starter;
|
||||
|
||||
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
|
||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
|
||||
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
|
||||
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
|
||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
|
||||
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
|
||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
||||
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class AccountConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
|
||||
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
|
||||
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
|
||||
SqlValidator sqlValidator) {
|
||||
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
|
||||
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
|
||||
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
|
||||
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
|
||||
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
|
||||
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
|
||||
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
|
||||
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator) {
|
||||
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
|
||||
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
|
||||
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
|
||||
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
|
||||
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
|
||||
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
|
||||
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
|
||||
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
|
||||
accountTransferDetailRepository, standingInstructionRepository);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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.account.starter;
|
||||
|
||||
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
|
||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
|
||||
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
|
||||
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
|
||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
|
||||
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
|
||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
||||
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class AccountConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
|
||||
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
|
||||
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
|
||||
SqlValidator sqlValidator) {
|
||||
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
|
||||
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
|
||||
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
|
||||
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
|
||||
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
|
||||
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
|
||||
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
|
||||
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator) {
|
||||
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
|
||||
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
|
||||
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
|
||||
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
|
||||
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
|
||||
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
|
||||
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
|
||||
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
|
||||
accountTransferDetailRepository, standingInstructionRepository);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -61,6 +61,8 @@ public interface ShareAccountApiConstants {
|
||||
String externalid_paramname = "externalId";
|
||||
|
||||
String resolutionnumber_paramname = "resolutionNumber";
|
||||
/** Date of the board meeting that passed the resolution. */
|
||||
String resolutiondate_paramname = "resolutionDate";
|
||||
|
||||
String currency_paramname = "currencyCode";
|
||||
|
||||
@@ -146,7 +148,7 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
resolutionnumber_paramname, resolutiondate_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
|
||||
+10
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.api;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
@@ -30,4 +31,13 @@ public class AlertReviewRequest {
|
||||
|
||||
private String disposition;
|
||||
private String notes;
|
||||
|
||||
/**
|
||||
* The rules being decided, when the officer is deciding some of them rather than the whole alert.
|
||||
*
|
||||
* <p>
|
||||
* Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level
|
||||
* Whitelist and Suspicious buttons send and what this endpoint has always done.
|
||||
*/
|
||||
private List<String> ruleCodes;
|
||||
}
|
||||
|
||||
+7
-1
@@ -71,7 +71,13 @@ public class AmlAlertApiResource {
|
||||
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
|
||||
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
|
||||
final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition());
|
||||
if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) {
|
||||
// Deciding named rules within the alert. The alert's own status follows from what is left undecided, so
|
||||
// it is not set here.
|
||||
this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST",
|
||||
request.getNotes());
|
||||
} else if (escalating) {
|
||||
this.writePlatformService.escalate(alertId, request.getNotes());
|
||||
} else {
|
||||
this.writePlatformService.whitelist(alertId, request.getNotes());
|
||||
|
||||
+11
@@ -72,6 +72,17 @@ public class AmlCtrReportApiResource {
|
||||
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("pending-lines")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reportable cash transactions not yet in any generated CTR",
|
||||
description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.")
|
||||
public String retrievePendingLines() {
|
||||
this.context.authenticatedUser();
|
||||
return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("generate")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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.aml.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Moving a customer's KYC risk category, under maker-checker.
|
||||
*
|
||||
* <p>
|
||||
* Raised from the Risk Review Report, which already works out which customers are rated below what
|
||||
* their behaviour warrants. The rating moves only when a checker approves, and the change is written
|
||||
* into the customer's risk history with the reason given.
|
||||
*/
|
||||
@Path("/v1/aml/risk-change-requests")
|
||||
@Component
|
||||
@Tag(name = "Client Risk Category Change",
|
||||
description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientRiskChangeApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRiskChangeRequestService service;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
/**
|
||||
* Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which
|
||||
* Fineract's global handler does not map - left uncaught they reach the client as an opaque 500
|
||||
* with no message. Wrapped so the reviewer is told what actually stopped them.
|
||||
*/
|
||||
private <T> T safely(final Supplier<T> action) {
|
||||
try {
|
||||
return action.get();
|
||||
} catch (final IllegalArgumentException | IllegalStateException e) {
|
||||
throw apiError(400, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("message", message);
|
||||
body.addProperty("defaultUserMessage", message);
|
||||
body.addProperty("httpStatus", httpStatus);
|
||||
final JsonArray errors = new JsonArray();
|
||||
final JsonObject err = new JsonObject();
|
||||
err.addProperty("defaultUserMessage", message);
|
||||
errors.add(err);
|
||||
body.add("errors", errors);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List risk category change requests",
|
||||
description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.")
|
||||
public String retrieveAll(@QueryParam("status") final String status) {
|
||||
this.context.authenticatedUser();
|
||||
return this.gson.toJson(this.service.retrieveAll(status));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Request a change to a customer's risk category",
|
||||
description = "Maker step. Nothing moves until a checker approves.")
|
||||
public CommandProcessingResult request(final RiskChangeRequest request) {
|
||||
return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason()));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/approve")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a risk category change",
|
||||
description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.")
|
||||
public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) {
|
||||
return safely(() -> this.service.approve(requestId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/reject")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.")
|
||||
public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) {
|
||||
return safely(() -> this.service.reject(requestId, request.getRejectionReason()));
|
||||
}
|
||||
|
||||
/** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */
|
||||
public static final class RiskChangeRequest {
|
||||
|
||||
private Long clientId;
|
||||
private Integer requestedCategory;
|
||||
private String reason;
|
||||
|
||||
public Long getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public void setClientId(Long clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public Integer getRequestedCategory() {
|
||||
return requestedCategory;
|
||||
}
|
||||
|
||||
public void setRequestedCategory(Integer requestedCategory) {
|
||||
this.requestedCategory = requestedCategory;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RiskChangeRejectRequest {
|
||||
|
||||
private String rejectionReason;
|
||||
|
||||
public String getRejectionReason() {
|
||||
return rejectionReason;
|
||||
}
|
||||
|
||||
public void setRejectionReason(String rejectionReason) {
|
||||
this.rejectionReason = rejectionReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -30,6 +30,16 @@ import java.time.LocalDateTime;
|
||||
*/
|
||||
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
|
||||
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory,
|
||||
/** Other rules the same transaction matched, comma-separated. Null when only one fired. */
|
||||
String alsoMatchedRules,
|
||||
/** Worst severity across every rule the transaction matched, not only the governing one. */
|
||||
Integer highestSeverity,
|
||||
/**
|
||||
* Every rule the transaction matched, governing one first, each with its description and severity. The
|
||||
* comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands
|
||||
* to show, because a bare rule code does not tell an officer what was detected or how serious it is.
|
||||
*/
|
||||
java.util.List<AmlMatchedRuleData> matchedRules)
|
||||
implements Serializable {
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* One rule a transaction matched, as the alert screen needs to show it.
|
||||
*
|
||||
* <p>
|
||||
* An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An
|
||||
* officer deciding what to do with a transaction has to know what each of those codes means and how serious it is -
|
||||
* "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision.
|
||||
*
|
||||
* @param governing
|
||||
* whether this is the rule the alert was raised under. One transaction raises one alert, under whichever
|
||||
* rule most warrants attention, so exactly one entry in the list carries this.
|
||||
* @param riskCategory
|
||||
* 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns
|
||||
* rather than graded suspicions, so they carry no band.
|
||||
*/
|
||||
public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing,
|
||||
/** WHITELIST, ESCALATE, or null while this rule is still open. */
|
||||
String disposition, String reviewedByUsername, String reviewNotes,
|
||||
/** True where the officer named this rule; false where it was closed alongside one they named. */
|
||||
boolean directlyDecided) implements Serializable {
|
||||
}
|
||||
+125
-8
@@ -18,15 +18,25 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
|
||||
|
||||
private final AmlAlertRepository repository;
|
||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||
private final AmlAlertRuleRepository alertRuleRepository;
|
||||
private final AmlTransactionRuleRepository transactionRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||
// Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every
|
||||
// lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so
|
||||
// without this the screen fails outright at the very moment it has nothing to report.
|
||||
if (alerts.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
||||
// lookup inside the map step would re-read the same catalog rows repeatedly.
|
||||
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
|
||||
// Same reasoning for the also-matched rules, which are held on the alert as bare codes: one
|
||||
// read for every code on the page, not one per alert.
|
||||
// The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole
|
||||
// page at once rather than per alert.
|
||||
final Map<Long, List<AmlAlertRule>> findingsByAlertId = this.alertRuleRepository
|
||||
.findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream()
|
||||
.collect(Collectors.groupingBy(f -> f.getAlert().getId()));
|
||||
final Map<String, AmlTransactionRule> rulesByCode = rulesByCodeFor(alerts, findingsByAlertId);
|
||||
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values());
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
||||
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
|
||||
.map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
|
||||
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
|
||||
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
|
||||
.toList();
|
||||
/**
|
||||
* Every rule named anywhere on this page, keyed on its code.
|
||||
*
|
||||
* <p>
|
||||
* Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the
|
||||
* description behind it.
|
||||
*/
|
||||
private Map<String, AmlTransactionRule> rulesByCodeFor(final List<AmlAlert> alerts,
|
||||
final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||
final Set<String> codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank())
|
||||
.flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toCollection(java.util.HashSet::new));
|
||||
findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add);
|
||||
alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add);
|
||||
if (codes.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
final Map<String, AmlTransactionRule> byCode = new HashMap<>();
|
||||
this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule));
|
||||
return byCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule the transaction matched, governing one first, each carrying its description and severity.
|
||||
*
|
||||
* <p>
|
||||
* A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the
|
||||
* officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was
|
||||
* raised - keeps its code as the description, because the alert is a record of what happened and editing the rule
|
||||
* set afterwards does not change that.
|
||||
*/
|
||||
private List<AmlMatchedRuleData> matchedRulesOf(final AmlAlert alert, final Map<Long, Integer> riskByRiskRuleId,
|
||||
final Map<String, AmlTransactionRule> rulesByCode, final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||
final List<AmlAlertRule> findings = findingsByAlertId.get(alert.getId());
|
||||
if (findings != null && !findings.isEmpty()) {
|
||||
// Governing rule first, then by code, so the pane does not reorder itself between reads.
|
||||
return findings.stream()
|
||||
.sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode))
|
||||
.map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(),
|
||||
f.isGoverning(), f.getDisposition(),
|
||||
f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(),
|
||||
f.isDirectlyDecided()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat
|
||||
// fields so the pane still shows what fired, with no per-rule disposition to report.
|
||||
final List<AmlMatchedRuleData> matched = new ArrayList<>();
|
||||
final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId();
|
||||
matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(),
|
||||
governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false));
|
||||
|
||||
final String also = alert.getAlsoMatchedRules();
|
||||
if (also == null || also.isBlank()) {
|
||||
return matched;
|
||||
}
|
||||
for (final String raw : also.split(",")) {
|
||||
final String code = raw.trim();
|
||||
if (code.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
final AmlTransactionRule rule = rulesByCode.get(code);
|
||||
final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId();
|
||||
matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode),
|
||||
sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule's description, or its code when the rule itself is gone.
|
||||
*
|
||||
* <p>
|
||||
* An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code
|
||||
* stands in rather than the row disappearing.
|
||||
*/
|
||||
private String describe(final String ruleCode, final Map<String, AmlTransactionRule> rulesByCode) {
|
||||
final AmlTransactionRule rule = rulesByCode.get(ruleCode);
|
||||
return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity of each originating catalog rule, keyed on m_client_risk_rule.id.
|
||||
*
|
||||
* <p>
|
||||
* Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every
|
||||
* also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show.
|
||||
*/
|
||||
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts, final Collection<AmlTransactionRule> alsoMatched) {
|
||||
final List<Long> sourceIds = Stream
|
||||
.concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()),
|
||||
alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId))
|
||||
.filter(id -> id != null).distinct().toList();
|
||||
final Map<Long, Integer> byId = new HashMap<>();
|
||||
if (sourceIds.isEmpty()) {
|
||||
return byId;
|
||||
@@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
|
||||
return byId;
|
||||
}
|
||||
|
||||
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
|
||||
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId, Map<String, AmlTransactionRule> rulesByCode,
|
||||
Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
|
||||
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
|
||||
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
||||
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
||||
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory,
|
||||
alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId));
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||
|
||||
@@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService {
|
||||
AmlAlert whitelist(Long alertId, String notes);
|
||||
|
||||
AmlStrCase escalate(Long alertId, String notes);
|
||||
|
||||
/**
|
||||
* Escalates several alerts into a single STR case.
|
||||
*
|
||||
* <p>
|
||||
* For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert
|
||||
* separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a
|
||||
* pattern that only means anything taken together - and a customer reviewed again a month later would add several
|
||||
* more. An STR to FIU-IND reports the activity, so the case has to hold all of it.
|
||||
*
|
||||
* @param alertIds
|
||||
* the alerts the pattern is made of; any already covered by a case keep that case
|
||||
* @return the case covering them, or null when every alert was already covered
|
||||
*/
|
||||
AmlStrCase escalateAsOneCase(List<Long> alertIds, String notes);
|
||||
|
||||
/**
|
||||
* Decides individual rules within one alert, leaving the rest open.
|
||||
*
|
||||
* @param ruleCodes
|
||||
* the rules being decided now
|
||||
* @param disposition
|
||||
* WHITELIST or ESCALATE, applied to each of them
|
||||
*/
|
||||
AmlAlert reviewRules(Long alertId, List<String> ruleCodes, String disposition, String notes);
|
||||
}
|
||||
|
||||
+183
-2
@@ -19,12 +19,21 @@
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -34,28 +43,200 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
|
||||
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlAlertRuleRepository alertRuleRepository;
|
||||
private final AmlStrCaseRepository strCaseRepository;
|
||||
private final AmlStrCaseAlertRepository strCaseAlertRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||
|
||||
@Override
|
||||
public AmlAlert whitelist(Long alertId, String notes) {
|
||||
final AmlAlert alert = loadAlert(alertId);
|
||||
final AmlAlert alert = loadForDisposal(alertId);
|
||||
alert.whitelist(context.authenticatedUser(), notes);
|
||||
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
|
||||
return alertRepository.save(alert);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AmlStrCase escalate(Long alertId, String notes) {
|
||||
final AmlAlert alert = loadAlert(alertId);
|
||||
// loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one.
|
||||
// An STR is about the transaction, and one transaction gets one case.
|
||||
final AmlAlert alert = loadForDisposal(alertId);
|
||||
alert.escalate(context.authenticatedUser(), notes);
|
||||
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
||||
alertRepository.save(alert);
|
||||
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert));
|
||||
}
|
||||
|
||||
private AmlStrCase openStrCase(final AmlAlert alert) {
|
||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
// Recorded in the link table as well as on alert_id, so every case describes what it covers the same way -
|
||||
// a single-transaction case covering one alert, a pattern case covering several.
|
||||
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
return strCase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AmlStrCase escalateAsOneCase(final List<Long> alertIds, final String notes) {
|
||||
if (alertIds == null || alertIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
AmlStrCase strCase = null;
|
||||
|
||||
for (final Long alertId : alertIds) {
|
||||
final AmlAlert alert = loadForDisposal(alertId);
|
||||
|
||||
// An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice
|
||||
// under two references, and the older case is the one an officer may already have worked on.
|
||||
final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null);
|
||||
if (existing != null) {
|
||||
if (strCase == null) {
|
||||
strCase = existing.getStrCase();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
alert.escalate(currentUser, notes);
|
||||
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
||||
alertRepository.save(alert);
|
||||
|
||||
if (strCase == null) {
|
||||
// Opened from the first alert that needed one, which is the oldest: the case is dated and referenced
|
||||
// from where the pattern starts rather than from whichever alert happened to be escalated last.
|
||||
strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
}
|
||||
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
|
||||
}
|
||||
|
||||
return strCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a decision against individual rules within one alert.
|
||||
*
|
||||
* <p>
|
||||
* A transaction can trip six rules where five are explained by the customer's declared business and the sixth is
|
||||
* not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays
|
||||
* open while any rule is undecided, because a case with an open finding is not a closed case.
|
||||
*
|
||||
* <p>
|
||||
* Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not
|
||||
* about each rule that described it. Escalating a second rule on an alert already escalated adds the finding
|
||||
* without opening a duplicate case.
|
||||
*/
|
||||
@Override
|
||||
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
|
||||
final AmlAlert alert = loadForDisposal(alertId);
|
||||
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
|
||||
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
|
||||
|
||||
final Set<String> requested = Set.copyOf(ruleCodes);
|
||||
// A finding already decided is left alone, for the same reason the alert itself is: the decision recorded
|
||||
// against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing
|
||||
// only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than
|
||||
// silently doing nothing.
|
||||
final List<AmlAlertRule> targeted = findings.stream()
|
||||
.filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList();
|
||||
if (targeted.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided");
|
||||
}
|
||||
for (final AmlAlertRule finding : targeted) {
|
||||
finding.decide(decision, context.authenticatedUser(), notes, true);
|
||||
alertRuleRepository.save(finding);
|
||||
}
|
||||
|
||||
// Re-read state across every finding, including the ones just written, to decide where the alert now stands.
|
||||
final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated);
|
||||
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
|
||||
|
||||
// Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant
|
||||
// an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely
|
||||
// because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One
|
||||
// suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case
|
||||
// that is already open.
|
||||
if (anyEscalated) {
|
||||
alert.escalate(context.authenticatedUser(), notes);
|
||||
// The findings left open were carried into the escalation, so nothing is left dangling behind a decided
|
||||
// case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were
|
||||
// closed to keep the case consistent, and the record must not claim otherwise.
|
||||
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false);
|
||||
alertRepository.save(alert);
|
||||
if (!wasEscalated) {
|
||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
}
|
||||
return alert;
|
||||
}
|
||||
|
||||
// Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while
|
||||
// one of its rules is still unexamined.
|
||||
//
|
||||
// Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion -
|
||||
// it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never
|
||||
// offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable,
|
||||
// waiting on a judgement that is not the officer's to make.
|
||||
if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) {
|
||||
return alertRepository.save(alert);
|
||||
}
|
||||
alert.whitelist(context.authenticatedUser(), notes);
|
||||
return alertRepository.save(alert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a whole-alert decision down to every finding still open beneath it.
|
||||
*
|
||||
* <p>
|
||||
* Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings
|
||||
* blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings
|
||||
* already decided individually keep the decision the officer made about them.
|
||||
*/
|
||||
private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes,
|
||||
final boolean directlyDecided) {
|
||||
for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) {
|
||||
if (!finding.isDecided()) {
|
||||
finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided);
|
||||
alertRuleRepository.save(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an alert the current officer is entitled to dispose of.
|
||||
*
|
||||
* <p>
|
||||
* An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their
|
||||
* own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up
|
||||
* recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes
|
||||
* it a rule: the screen hides the buttons, but the endpoint is what enforces it.
|
||||
*/
|
||||
private AmlAlert loadForDisposal(final Long alertId) {
|
||||
final AmlAlert alert = loadAlert(alertId);
|
||||
|
||||
// A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged
|
||||
// transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second
|
||||
// officer write over it would leave the file saying whatever was submitted last, with the earlier decision and
|
||||
// the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately,
|
||||
// through the STR case, where the change is itself recorded.
|
||||
final AmlAlertStatus status = alert.getStatus();
|
||||
if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) {
|
||||
throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by "
|
||||
+ (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername())
|
||||
+ " and cannot be decided again");
|
||||
}
|
||||
|
||||
final AppUser current = context.authenticatedUser();
|
||||
if (alert.isHeldByAnother(current)) {
|
||||
throw new IllegalStateException("Alert " + alertId + " is being reviewed by "
|
||||
+ alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it");
|
||||
}
|
||||
return alert;
|
||||
}
|
||||
|
||||
private AmlAlert loadAlert(Long alertId) {
|
||||
return alertRepository.findById(alertId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
|
||||
|
||||
+17
-2
@@ -26,11 +26,13 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
|
||||
@@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService {
|
||||
|
||||
public AmlCtrReport generateForMonth(YearMonth month) {
|
||||
final String reportMonth = month.toString();
|
||||
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
|
||||
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
|
||||
// A month can be rebuilt while its report is still a draft, and must not be once it has been submitted.
|
||||
// Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash
|
||||
// transaction posted after it ran belonged to that month's filing and could never be added to it - the report
|
||||
// was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its
|
||||
// lines with what the month now holds; a submitted report is left exactly as filed.
|
||||
final Optional<AmlCtrReport> existing = ctrReportRepository.findByReportMonth(reportMonth);
|
||||
if (existing.isPresent()) {
|
||||
final AmlCtrReport report = existing.get();
|
||||
if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) {
|
||||
throw new IllegalStateException(
|
||||
"CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated");
|
||||
}
|
||||
ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId()));
|
||||
ctrReportRepository.delete(report);
|
||||
ctrReportRepository.flush();
|
||||
}
|
||||
|
||||
final LocalDate start = month.atDay(1);
|
||||
|
||||
+9
@@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService {
|
||||
* one month's report, or null for every generated month
|
||||
*/
|
||||
List<AmlCtrReportLineData> retrieveLines(Long reportId);
|
||||
|
||||
/**
|
||||
* Reportable cash transactions that no generated report contains yet.
|
||||
*
|
||||
* <p>
|
||||
* The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no
|
||||
* report until the month is regenerated. These are those transactions, so a reportable one is never invisible.
|
||||
*/
|
||||
List<AmlCtrReportLineData> retrievePendingLines();
|
||||
}
|
||||
|
||||
+79
-10
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
@@ -28,6 +29,7 @@ import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -68,6 +70,56 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
""";
|
||||
|
||||
/**
|
||||
* Cash transactions that a CTR must name but that no generated report contains yet.
|
||||
*
|
||||
* <p>
|
||||
* The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs
|
||||
* to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks
|
||||
* to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is
|
||||
* listed here as pending: shaped exactly like a generated line, with no report behind it.
|
||||
*
|
||||
* <p>
|
||||
* Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and
|
||||
* cannot be decided while the month is still running.
|
||||
*/
|
||||
private static final String PENDING_LINES_SQL = """
|
||||
SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id,
|
||||
to_char(t.transaction_date, 'YYYY-MM') AS report_month,
|
||||
'SAVINGS' AS transaction_type, t.id AS transaction_id,
|
||||
c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group,
|
||||
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
||||
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
||||
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
||||
sa.activatedon_date AS account_opened_on,
|
||||
sa.closedon_date AS account_closed_on,
|
||||
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
||||
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
||||
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
||||
WHEN 800 THEN 'MATURED' END AS account_status,
|
||||
k.ckyc_number AS ckyc_number,
|
||||
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
||||
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE pt.is_cash_payment = true
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_type_enum IN (1, 2)
|
||||
AND t.amount >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_aml_ctr_report_line el
|
||||
WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id)
|
||||
ORDER BY t.transaction_date DESC, t.amount DESC, t.id
|
||||
""";
|
||||
|
||||
/** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */
|
||||
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
|
||||
|
||||
private final AmlCtrReportRepository repository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@@ -88,18 +140,35 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
||||
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
|
||||
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
|
||||
|
||||
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
|
||||
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
|
||||
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
|
||||
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
|
||||
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||
rs.getBoolean("individual")), args.toArray());
|
||||
return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reportable cash transactions no generated report contains yet.
|
||||
*
|
||||
* <p>
|
||||
* Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they
|
||||
* are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which
|
||||
* is what makes them disappear from this list.
|
||||
*/
|
||||
@Override
|
||||
public List<AmlCtrReportLineData> retrievePendingLines() {
|
||||
return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD);
|
||||
}
|
||||
|
||||
/** Shared by both queries, which select the same columns so a pending line renders like a filed one. */
|
||||
private static final RowMapper<AmlCtrReportLineData> LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData(
|
||||
rs.getObject("id") == null ? null : rs.getLong("id"),
|
||||
rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"),
|
||||
rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"),
|
||||
rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"),
|
||||
rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||
rs.getBoolean("individual"));
|
||||
|
||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
||||
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
||||
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
||||
|
||||
+8
-4
@@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
|
||||
final List<AmlAlertData> alerts = byClient.get(clientId);
|
||||
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
|
||||
.orElse(null);
|
||||
// Reviewed at a lower count means new alerts have landed since — the customer has to
|
||||
// come back, otherwise one early review would silence them permanently.
|
||||
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
|
||||
// A standing review holds the customer out until its window runs out, or until their behaviour has moved
|
||||
// on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on
|
||||
// the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back
|
||||
// the day after being reviewed, for the officer to re-read the explanation they had just written.
|
||||
final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size());
|
||||
if (onlyNeedingReview && !needsReview) {
|
||||
continue;
|
||||
}
|
||||
@@ -91,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
|
||||
summary.riskCategory(), alerts.size(),
|
||||
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
||||
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
||||
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
|
||||
// Open means undecided, not a named status: a status this count does not list would otherwise be
|
||||
// dropped from the customer's open figure while the alert still sits in the feed awaiting review.
|
||||
(int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(),
|
||||
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
||||
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
||||
|
||||
+54
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||
@@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlAlertWritePlatformService alertWritePlatformService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
|
||||
|
||||
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
||||
this.context.authenticatedUser());
|
||||
|
||||
// The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row
|
||||
// and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an
|
||||
// STR while the alerts sat untouched and no case ever reached the Principal Officer.
|
||||
final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window);
|
||||
review.recordAlertsActioned(actioned);
|
||||
|
||||
return this.reviewRepository.saveAndFlush(review).getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the reviewer's conclusion through to the customer's open alerts.
|
||||
*
|
||||
* <pre>
|
||||
* CLEARED every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
|
||||
* ESCALATED every open alert is escalated, which opens an STR case for the Principal Officer
|
||||
* MONITOR nothing is disposed of - the point of monitoring is that the alerts stay open
|
||||
* </pre>
|
||||
*
|
||||
* Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the
|
||||
* audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any
|
||||
* of that here would be a second, quieter path to the same records.
|
||||
*/
|
||||
private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes,
|
||||
final int windowDays) {
|
||||
|
||||
if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final LocalDateTime since = LocalDateTime.now().minusDays(windowDays);
|
||||
final List<AmlAlert> openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since);
|
||||
|
||||
final String trail = "Recurring-behaviour review: " + notes;
|
||||
|
||||
if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) {
|
||||
// One case for the whole pattern, not one per transaction. What is being reported is the customer's
|
||||
// repeated activity, and the Principal Officer investigating it needs the transactions that make it up
|
||||
// together - a case per alert would hand them four fragments of one story, and five more the next time
|
||||
// this customer was reviewed.
|
||||
this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail);
|
||||
return openAlerts.size();
|
||||
}
|
||||
|
||||
int actioned = 0;
|
||||
for (final AmlAlert alert : openAlerts) {
|
||||
this.alertWritePlatformService.whitelist(alert.getId(), trail);
|
||||
actioned++;
|
||||
}
|
||||
return actioned;
|
||||
}
|
||||
|
||||
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
|
||||
+123
-4
@@ -20,17 +20,27 @@ package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -51,9 +61,39 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
private static final int MEDIUM_RISK = 2;
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
/**
|
||||
* Rules that carry a filing obligation in their own right, whatever else the transaction also
|
||||
* matched.
|
||||
*
|
||||
* <p>
|
||||
* A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or
|
||||
* above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a
|
||||
* behavioural rule outrank it would bury a statutory filing behind an internal observation.
|
||||
*/
|
||||
private static final Set<String> STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L",
|
||||
"STRUCTURED_CTR_10L_MONTHLY");
|
||||
|
||||
/**
|
||||
* Which rule an alert is raised under when a transaction matches several.
|
||||
*
|
||||
* <p>
|
||||
* Statutory reporting first, because those must be filed. Then the largest threshold, which is
|
||||
* the system's own measure of how far out of the ordinary the transaction is - a rule written
|
||||
* for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last,
|
||||
* so the same transaction always reports under the same rule rather than shifting with whatever
|
||||
* order the database returned.
|
||||
*/
|
||||
private static final Comparator<AmlTransactionRule> RULE_PRECEDENCE = Comparator
|
||||
.comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1)
|
||||
.thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(),
|
||||
Comparator.reverseOrder())
|
||||
.thenComparing(AmlTransactionRule::getRuleCode);
|
||||
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlAlertRuleRepository alertRuleRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
private final AmlSubjectScopeResolver subjectScopeResolver;
|
||||
|
||||
@@ -63,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
|
||||
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
|
||||
|
||||
final List<AmlAlert> raised = new ArrayList<>();
|
||||
final List<AmlTransactionRule> matched = new ArrayList<>();
|
||||
for (final AmlTransactionRule rule : candidates) {
|
||||
if (isHit(rule, context)) {
|
||||
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
|
||||
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
|
||||
raised.add(alertRepository.save(alert));
|
||||
matched.add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
final List<AmlAlert> raised = new ArrayList<>();
|
||||
if (!matched.isEmpty()) {
|
||||
// One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000
|
||||
// cash deposit arrived as two open alerts - the CTR and the new-account rule - and the
|
||||
// officer had to dispose of each. Nothing stopped them whitelisting one while
|
||||
// escalating the other, leaving the same deposit recorded as both cleared and
|
||||
// suspicious. A monitoring system reports a transaction once, under the rule that most
|
||||
// warrants attention.
|
||||
matched.sort(RULE_PRECEDENCE);
|
||||
final AmlTransactionRule governing = matched.get(0);
|
||||
|
||||
// When the alert was raised, not the transaction's value date. The context carries the date the
|
||||
// transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read
|
||||
// "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it
|
||||
// noticed something; the value date is still on the transaction the alert points at.
|
||||
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(),
|
||||
governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant());
|
||||
// The rules that did not govern are still recorded: an analyst needs to see that the
|
||||
// deposit was both reportable and on a new account, even though it is one case.
|
||||
if (matched.size() > 1) {
|
||||
alert.recordAlsoMatchedRules(
|
||||
matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", ")));
|
||||
}
|
||||
// The worst band across everything that matched, not only the rule the alert was raised
|
||||
// under. A statutory CTR outranks a high-severity behavioural rule when the governing
|
||||
// rule is picked, so without this the screen would see "CTR, no severity" and offer the
|
||||
// transaction up for a batch decision alongside ordinary low-value alerts.
|
||||
final Map<Long, Integer> bandBySourceId = severityBySourceRuleId(matched);
|
||||
alert.recordHighestSeverity(highestSeverityOf(bandBySourceId));
|
||||
final AmlAlert saved = alertRepository.save(alert);
|
||||
|
||||
// One finding per rule, so each can be disposed of on its own. Severity is copied in
|
||||
// rather than read back through the rule later: the alert records what was detected at
|
||||
// the time, and re-banding a rule afterwards must not rewrite that.
|
||||
for (final AmlTransactionRule rule : matched) {
|
||||
final Long sourceId = rule.getSourceClientRiskRuleId();
|
||||
alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(),
|
||||
sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing));
|
||||
}
|
||||
raised.add(saved);
|
||||
}
|
||||
|
||||
// Every transaction type funnels through here, so the recurring-behaviour check is done once
|
||||
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
|
||||
// the batch size lets the service tell a first-time offender from a repeat one without
|
||||
@@ -82,6 +162,45 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
return raised;
|
||||
}
|
||||
|
||||
/**
|
||||
* The worst severity band among the rules that matched.
|
||||
*
|
||||
* <p>
|
||||
* A transaction rule carries no severity of its own; it inherits the risk category of the
|
||||
* catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute
|
||||
* nothing, because a statutory return is not a graded suspicion. Null when none of the matched
|
||||
* rules carried a severity at all.
|
||||
*/
|
||||
private Integer highestSeverityOf(final Map<Long, Integer> bandBySourceId) {
|
||||
Integer worst = null;
|
||||
for (final Integer band : bandBySourceId.values()) {
|
||||
if (band != null && (worst == null || band > worst)) {
|
||||
worst = band;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}.
|
||||
*
|
||||
* <p>
|
||||
* Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written
|
||||
* beneath it - rather than querying the catalog twice for the same rows.
|
||||
*/
|
||||
private Map<Long, Integer> severityBySourceRuleId(final List<AmlTransactionRule> matched) {
|
||||
final List<Long> sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null)
|
||||
.distinct().toList();
|
||||
if (sourceIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
final Map<Long, Integer> bandById = new HashMap<>();
|
||||
for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) {
|
||||
bandById.put(catalogRule.getId(), catalogRule.getRiskCategory());
|
||||
}
|
||||
return bandById;
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (!appliesToThisTransaction(rule, context)) {
|
||||
return false;
|
||||
|
||||
+38
-1
@@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
@@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
|
||||
|
||||
private final AmlStrCaseRepository strCaseRepository;
|
||||
private final AmlStrCaseAlertRepository strCaseAlertRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final StrSlaCalculator strSlaCalculator;
|
||||
@@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo
|
||||
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
|
||||
: null;
|
||||
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
|
||||
return strCaseRepository.save(strCase);
|
||||
final AmlStrCase saved = strCaseRepository.save(strCase);
|
||||
|
||||
// The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded
|
||||
// clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have
|
||||
// decided one thing and the screen an officer works from would say another.
|
||||
final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS;
|
||||
final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear")
|
||||
+ (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes);
|
||||
for (final AmlAlert alert : alertsOf(saved)) {
|
||||
alert.applyStrConclusion(suspicious, principalOfficer, note);
|
||||
alertRepository.save(alert);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every alert the case covers.
|
||||
*
|
||||
* <p>
|
||||
* Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and
|
||||
* concluding it has to answer all of them rather than only the one whose id happens to sit on the case.
|
||||
*/
|
||||
private List<AmlAlert> alertsOf(final AmlStrCase strCase) {
|
||||
final List<AmlAlert> covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream()
|
||||
.map(AmlStrCaseAlert::getAlert).collect(Collectors.toList());
|
||||
// Cases opened before the link table existed carry only alert_id.
|
||||
if (covered.isEmpty() && strCase.getAlert() != null) {
|
||||
covered.add(strCase.getAlert());
|
||||
}
|
||||
return covered;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
/**
|
||||
* Moving a customer's KYC risk category, under maker-checker.
|
||||
*
|
||||
* <p>
|
||||
* The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so
|
||||
* raising or lowering one is a decision about how closely the bank watches that customer. It is
|
||||
* raised from the Risk Review Report - which already works out where a rating ought to sit - and
|
||||
* takes effect only when a checker agrees.
|
||||
*/
|
||||
public interface ClientRiskChangeRequestService {
|
||||
|
||||
/** Raised by the reviewer. Nothing moves until a checker approves. */
|
||||
CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason);
|
||||
|
||||
/** Applies the new category to the customer's KYC record and records the change in history. */
|
||||
CommandProcessingResult approve(Long requestId);
|
||||
|
||||
CommandProcessingResult reject(Long requestId, String rejectionReason);
|
||||
|
||||
/** @param status PENDING / APPROVED / REJECTED, or null for all */
|
||||
List<Map<String, Object>> retrieveAll(String status);
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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.aml.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
|
||||
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService {
|
||||
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_APPROVED = "APPROVED";
|
||||
private static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
/** KycRiskCategory: 1 Low, 2 Medium, 3 High. */
|
||||
private static final int LOWEST_CATEGORY = 1;
|
||||
private static final int HIGHEST_CATEGORY = 3;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final ClientKycRepositoryWrapper kycRepositoryWrapper;
|
||||
private final ClientRiskCategoryHistoryRepository historyRepository;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
|
||||
if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) {
|
||||
throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3).");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("A reason is required to change a customer's risk category.");
|
||||
}
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
|
||||
final Integer currentCategory = kyc.getRiskCategoryValue();
|
||||
|
||||
if (requestedCategory.equals(currentCategory)) {
|
||||
throw new IllegalStateException("The customer is already on that risk category.");
|
||||
}
|
||||
if (!this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId)
|
||||
.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"A risk category change is already pending for this customer. Wait for it to be approved or rejected.");
|
||||
}
|
||||
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(con -> {
|
||||
final java.sql.PreparedStatement ps = con.prepareStatement(
|
||||
"INSERT INTO m_client_risk_change_request "
|
||||
+ "(client_id, current_category, requested_category, reason, status, maker_id, created_date) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, client.getId());
|
||||
if (currentCategory == null) {
|
||||
ps.setNull(2, java.sql.Types.INTEGER);
|
||||
} else {
|
||||
ps.setInt(2, currentCategory);
|
||||
}
|
||||
ps.setInt(3, requestedCategory);
|
||||
ps.setString(4, reason);
|
||||
ps.setString(5, STATUS_PENDING);
|
||||
ps.setLong(6, currentUser.getId());
|
||||
ps.setObject(7, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
|
||||
return new CommandProcessingResultBuilder().withClientId(client.getId())
|
||||
.withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult approve(final Long requestId) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRequestOrThrow(requestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot approve your own risk category change.");
|
||||
}
|
||||
|
||||
final Long clientId = ((Number) request.get("client_id")).longValue();
|
||||
final Integer requestedCategory = ((Number) request.get("requested_category")).intValue();
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
|
||||
|
||||
// Read again rather than trusted from the request: the rating may have moved since - an AML
|
||||
// auto-escalation on the customer's third STR case does exactly that - and the history has
|
||||
// to record where it actually moved from.
|
||||
final Integer previousCategory = kyc.getRiskCategoryValue();
|
||||
if (requestedCategory.equals(previousCategory)) {
|
||||
throw new IllegalStateException(
|
||||
"The customer is already on the requested risk category; the rating moved after this request was raised.");
|
||||
}
|
||||
|
||||
// Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher
|
||||
// is called back in sooner, which is most of what the rating is for.
|
||||
kyc.escalateRiskCategory(requestedCategory);
|
||||
this.kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory,
|
||||
(String) request.get("reason"), currentUser, now));
|
||||
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
|
||||
STATUS_APPROVED, currentUser.getId(), now, requestId);
|
||||
|
||||
return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult reject(final Long requestId, final String rejectionReason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRequestOrThrow(requestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot reject your own risk category change.");
|
||||
}
|
||||
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? "
|
||||
+ "WHERE id = ?",
|
||||
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId);
|
||||
|
||||
return new CommandProcessingResultBuilder().withEntityId(requestId).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id AS "id",
|
||||
r.client_id AS "clientId",
|
||||
c.display_name AS "clientName",
|
||||
c.account_no AS "cifNumber",
|
||||
o.name AS "officeName",
|
||||
r.current_category AS "currentCategory",
|
||||
r.requested_category AS "requestedCategory",
|
||||
r.reason AS "reason",
|
||||
r.status AS "status",
|
||||
maker.username AS "makerUsername",
|
||||
to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate",
|
||||
checker.username AS "checkerUsername",
|
||||
to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate",
|
||||
r.rejection_reason AS "rejectionReason"
|
||||
FROM m_client_risk_change_request r
|
||||
JOIN m_client c ON c.id = r.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_appuser maker ON maker.id = r.maker_id
|
||||
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (status != null && !status.isBlank()) {
|
||||
sql.append(" AND r.status = ? ");
|
||||
args.add(status);
|
||||
}
|
||||
sql.append(" ORDER BY r.id DESC ");
|
||||
return this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||
}
|
||||
|
||||
private Map<String, Object> findRequestOrThrow(final Long requestId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Risk category change request not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same checker rule the rest of the AML module uses: a rating change is a compliance
|
||||
* decision, so it is approved by a manager rather than by whoever happens to be at the screen.
|
||||
*/
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("principal officer") || n.contains("compliance"));
|
||||
if (!isChecker) {
|
||||
throw new IllegalStateException(
|
||||
"Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -38,6 +38,8 @@ import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
@@ -513,8 +515,7 @@ public class BranchDayRegisterApiResource {
|
||||
final Long contraId = contraIds.get(0);
|
||||
final String currencyCode = jdbcTemplate.queryForObject(
|
||||
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
|
||||
final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
|
||||
+ System.nanoTime();
|
||||
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION);
|
||||
|
||||
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
|
||||
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
|
||||
@@ -1056,13 +1057,32 @@ public class BranchDayRegisterApiResource {
|
||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||
|
||||
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
|
||||
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
AND (office_id = ? OR office_id IS NULL)
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||
// it was missing from this total entirely, understating expected cash by exactly what a
|
||||
// walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
|
||||
// over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
|
||||
// fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
|
||||
// whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
|
||||
// this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
|
||||
// are correctly excluded by the payment method filter.
|
||||
final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
|
||||
FROM m_sponsor_dd_issue_request dd
|
||||
WHERE dd.status = 'APPROVED'
|
||||
AND dd.payment_method = 'CASH'
|
||||
AND dd.issued_date = ?
|
||||
AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
|
||||
|
||||
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
|
||||
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
|
||||
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
|
||||
|
||||
+173
-17
@@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource {
|
||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
// What the counter says this GL credit is actually for — a code value under the
|
||||
// "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row
|
||||
// is one of the flows below picks up its own money later (Share Advance Collection, Sponsor
|
||||
// DD), in which case that adoption checks this tag so it can never take a voucher meant for
|
||||
// the other one. See 0565_convert_entry_type_to_code_value.xml.
|
||||
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
|
||||
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
|
||||
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
|
||||
// retyping face value/charges once this request is approved and selected as cash evidence.
|
||||
@@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
||||
client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource {
|
||||
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
|
||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount"
|
||||
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id"
|
||||
+ " FROM m_cashier_deposit_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource {
|
||||
payment_type_name, description, txn_date, status,
|
||||
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||
submitted_at, actioned_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
|
||||
FROM m_cashier_deposit_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
@@ -412,13 +418,26 @@ public class CashierDepositRequestApiResource {
|
||||
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
|
||||
amount, ptId, txnDateStr, description, true);
|
||||
} else {
|
||||
// Determine real account type by checking which table owns this ID.
|
||||
// Determine real account type by checking which table/column owns this ID.
|
||||
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
|
||||
// even for current accounts (submitted before the frontend fix).
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
|
||||
// the very same m_savings_account table as a plain savings account, distinguished only by
|
||||
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
|
||||
// The account_no is also checked -- m_current_account and m_savings_account each keep
|
||||
// their own identity sequence, so the same numeric id can legitimately belong to a
|
||||
// current account AND an unrelated savings account at once. An id match alone once sent
|
||||
// a real deposit into a stranger's current account instead of the savings account the
|
||||
// request actually named; account_no is the one value that can't collide between them.
|
||||
final String requestAccountNo = (String) record.get("account_no");
|
||||
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
|
||||
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
|
||||
txnId = switch (depositCategory) {
|
||||
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
case "RECURRING_DEPOSIT" ->
|
||||
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
};
|
||||
if (!isCurrentAccount && txnId != null) {
|
||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||
@@ -565,7 +584,8 @@ public class CashierDepositRequestApiResource {
|
||||
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
|
||||
final String description = (String) record.get("description");
|
||||
|
||||
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
|
||||
final String requestAccountNo = (String) record.get("account_no");
|
||||
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
|
||||
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
|
||||
}
|
||||
|
||||
@@ -624,7 +644,12 @@ public class CashierDepositRequestApiResource {
|
||||
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
||||
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
||||
} else {
|
||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
||||
// The request's own account_no is normally right, but if a cashier ever searched by a
|
||||
// short/partial account number this is where that would surface -- prefer the number
|
||||
// looked up fresh from savingsAccountId, the id actually posted against.
|
||||
final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
|
||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
|
||||
realAccountNo != null ? realAccountNo : accountNo, amount);
|
||||
}
|
||||
|
||||
voucherService.addLines(voucherNo, List.of(debit, credit));
|
||||
@@ -634,6 +659,19 @@ public class CashierDepositRequestApiResource {
|
||||
}
|
||||
|
||||
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||
/** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
|
||||
* always querying {@code m_savings_account} would silently return an unrelated savings account's
|
||||
* number whenever the two id spaces happened to collide. Every other non-GL type here is a
|
||||
* {@code m_savings_account} row. */
|
||||
private String savingsAccountNoOf(final String accountType, final Long accountId) {
|
||||
if (accountId == null) {
|
||||
return null;
|
||||
}
|
||||
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
|
||||
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return Map.of();
|
||||
@@ -651,7 +689,7 @@ public class CashierDepositRequestApiResource {
|
||||
txn_date, status, cashier_id, cashier_name, cashier_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
|
||||
FROM m_cashier_deposit_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -694,6 +732,64 @@ public class CashierDepositRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeFixedDepositDeposit(
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.fixedDepositAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeRecurringDepositDeposit(
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.recurringAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
||||
@@ -774,17 +870,65 @@ public class CashierDepositRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
|
||||
private boolean isCurrentAccountById(final Long accountId) {
|
||||
/**
|
||||
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
|
||||
*
|
||||
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
|
||||
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
|
||||
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
|
||||
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
|
||||
* this returned true, even though the request was for m_savings_account id=1.
|
||||
*
|
||||
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
|
||||
* only trusted once the account_no on that current-account row confirms it is truly the same
|
||||
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
|
||||
* has no account_no to compare against, e.g. an id already known to be unambiguous).
|
||||
*/
|
||||
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
|
||||
try {
|
||||
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
|
||||
Integer.class, accountId, expectedAccountNo);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
|
||||
* rather than the request's own account_type string. FD and RD accounts live in the same
|
||||
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
|
||||
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
|
||||
* deposit command, which does not accept a term-deposit account id.
|
||||
*
|
||||
* expectedAccountNo guards the current-account check against an id collision with
|
||||
* m_savings_account — see {@link #isCurrentAccountById}.
|
||||
*/
|
||||
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
|
||||
if (accountId == null) {
|
||||
return "SAVINGS";
|
||||
}
|
||||
if (isCurrentAccountById(accountId, expectedAccountNo)) {
|
||||
return "CURRENT";
|
||||
}
|
||||
final List<Integer> depositTypes = jdbcTemplate.queryForList(
|
||||
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
|
||||
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
|
||||
if (Integer.valueOf(200).equals(depositType)) {
|
||||
return "FIXED_DEPOSIT";
|
||||
}
|
||||
if (Integer.valueOf(300).equals(depositType)) {
|
||||
return "RECURRING_DEPOSIT";
|
||||
}
|
||||
return "SAVINGS";
|
||||
}
|
||||
|
||||
// Parses a Fineract transaction id string to Long.
|
||||
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
|
||||
private Long parseTxnIdToLong(final String txnId) {
|
||||
@@ -1014,6 +1158,18 @@ public class CashierDepositRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
|
||||
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
|
||||
private Long resolveEntryTypeCvId(final Long provided) {
|
||||
if (provided != null) {
|
||||
return provided;
|
||||
}
|
||||
return jdbcTemplate.queryForObject(
|
||||
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
|
||||
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
|
||||
Long.class);
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action,
|
||||
final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
|
||||
+152
-82
@@ -499,86 +499,52 @@ public class CashReportApiResource {
|
||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
|
||||
|
||||
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
|
||||
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
|
||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
// Today's own movement -- what actually crossed the counter, the vault hatch or the
|
||||
// settlement window on this one date. Unchanged from before: this is informational, "what
|
||||
// happened today", and stays that way regardless of what the drawer carried in from an
|
||||
// earlier, unsettled day.
|
||||
final Map<Long, Map<String, Object>> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId);
|
||||
|
||||
// Every note and coin that reached the drawer or left it, per denomination.
|
||||
//
|
||||
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
|
||||
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
|
||||
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
|
||||
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
|
||||
// counted against. So the vault allocation and the settlement back are included on the same
|
||||
// footing as the counter work, and each row says where its notes came from.
|
||||
//
|
||||
// Cashier scoping differs per leg because each table records the party differently: the
|
||||
// counter tables name the acting user, the cash tables name the cashier row.
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
// What the drawer already held before today started -- everything ever recorded up to (but
|
||||
// not including) this date, net. A cashier who force-closed yesterday without settling, then
|
||||
// settled today, hands back notes that arrived on several different days; comparing that
|
||||
// settlement only against today's own vault-in/deposit legs manufactured an impossible
|
||||
// negative count for every denomination the earlier days had contributed. Cumulative "as of
|
||||
// yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use
|
||||
// for a drawer's running position, applied here as this report's opening balance.
|
||||
final Map<Long, Map<String, Object>> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId);
|
||||
|
||||
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
|
||||
+ "FROM ("
|
||||
// Counter work: what customers handed over and were paid.
|
||||
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
|
||||
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
|
||||
+ " FROM m_savings_txn_denomination std "
|
||||
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
||||
+ " UNION ALL "
|
||||
// Drawn from the vault in the morning.
|
||||
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
|
||||
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
|
||||
+ vaultCashierFilter
|
||||
+ " UNION ALL "
|
||||
// Handed back to the vault at close.
|
||||
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
|
||||
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
||||
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
|
||||
+ settleCashierFilter
|
||||
+ ") legs "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
|
||||
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
|
||||
+ "ORDER BY legs.denomination_value DESC, dm.kind";
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
final Map<Long, Map<String, Object>> merged = new LinkedHashMap<>(todayLegs);
|
||||
for (final Long denomId : openingLegs.keySet()) {
|
||||
merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id)));
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
final long vaultInCount = rs.getLong("vault_in_count");
|
||||
final long depositCount = rs.getLong("deposit_count");
|
||||
final long withdrawalCount = rs.getLong("withdrawal_count");
|
||||
final long settledOutCount = rs.getLong("settled_out_count");
|
||||
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
|
||||
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> today : merged.values()) {
|
||||
final Long denomId = (Long) today.get("denominationId");
|
||||
final Map<String, Object> opening = openingLegs.getOrDefault(denomId, null);
|
||||
final long openingCount = opening == null ? 0L
|
||||
: (long) opening.get("vaultInCount") + (long) opening.get("depositCount")
|
||||
- (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount");
|
||||
final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO
|
||||
: ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount"))
|
||||
.subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount"));
|
||||
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", rs.getLong("denomination_id"));
|
||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
final long vaultInCount = (long) today.get("vaultInCount");
|
||||
final long depositCount = (long) today.get("depositCount");
|
||||
final long withdrawalCount = (long) today.get("withdrawalCount");
|
||||
final long settledOutCount = (long) today.get("settledOutCount");
|
||||
final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount");
|
||||
final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount");
|
||||
final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount");
|
||||
final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount");
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", denomId);
|
||||
row.put("denominationValue", today.get("denominationValue"));
|
||||
row.put("kind", today.get("kind"));
|
||||
row.put("openingCount", openingCount);
|
||||
row.put("openingAmount", openingAmount);
|
||||
row.put("vaultInCount", vaultInCount);
|
||||
row.put("vaultInAmount", vaultInAmount);
|
||||
row.put("depositCount", depositCount);
|
||||
@@ -587,13 +553,18 @@ public class CashReportApiResource {
|
||||
row.put("withdrawalAmount", withdrawalAmount);
|
||||
row.put("settledOutCount", settledOutCount);
|
||||
row.put("settledOutAmount", settledOutAmount);
|
||||
// What is left of this denomination: everything that came in, less everything that went
|
||||
// out. This is the figure a teller counts their notes against, per note.
|
||||
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
|
||||
row.put("netAmount",
|
||||
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
|
||||
return row;
|
||||
}, args.toArray());
|
||||
// What is left of this denomination: whatever the drawer already held, plus what came in
|
||||
// today, less what went out today. This is the figure a teller counts their notes
|
||||
// against, per note.
|
||||
row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount);
|
||||
row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount)
|
||||
.subtract(settledOutAmount));
|
||||
rows.add(row);
|
||||
}
|
||||
rows.sort((a, b) -> {
|
||||
final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue"));
|
||||
return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind")));
|
||||
});
|
||||
|
||||
BigDecimal depositTotal = BigDecimal.ZERO;
|
||||
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
||||
@@ -762,6 +733,105 @@ public class CashReportApiResource {
|
||||
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
|
||||
* report and the position screen can never disagree about the same drawer.
|
||||
*/
|
||||
/**
|
||||
* The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per
|
||||
* denomination, with {@code dateComparison} deciding which side of the target date each leg is
|
||||
* read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for
|
||||
* everything before it). One query shape shared by both calls in {@link #closing}, rather than
|
||||
* two copies that could drift apart.
|
||||
*/
|
||||
private Map<Long, Map<String, Object>> fetchLegTotals(final String dateComparison, final String dateBound,
|
||||
final Long scopedCashierId) {
|
||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
|
||||
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
|
||||
+ "FROM ("
|
||||
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
|
||||
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
|
||||
+ " FROM m_savings_txn_denomination std "
|
||||
+ " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED
|
||||
+ " UNION ALL "
|
||||
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
|
||||
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||
+ " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false "
|
||||
+ vaultCashierFilter
|
||||
+ " UNION ALL "
|
||||
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
|
||||
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
||||
+ " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false "
|
||||
+ settleCashierFilter
|
||||
+ ") legs "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
|
||||
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
|
||||
+ "ORDER BY legs.denomination_value DESC, dm.kind";
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(dateBound);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(dateBound);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(dateBound);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
|
||||
final Map<Long, Map<String, Object>> byDenomination = new LinkedHashMap<>();
|
||||
jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
final Long denomId = rs.getLong("denomination_id");
|
||||
row.put("denominationId", denomId);
|
||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
row.put("vaultInCount", rs.getLong("vault_in_count"));
|
||||
row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount"));
|
||||
row.put("depositCount", rs.getLong("deposit_count"));
|
||||
row.put("depositAmount", rs.getBigDecimal("deposit_amount"));
|
||||
row.put("withdrawalCount", rs.getLong("withdrawal_count"));
|
||||
row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount"));
|
||||
row.put("settledOutCount", rs.getLong("settled_out_count"));
|
||||
row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount"));
|
||||
byDenomination.put(denomId, row);
|
||||
return row;
|
||||
}, args.toArray());
|
||||
return byDenomination;
|
||||
}
|
||||
|
||||
/** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination
|
||||
* that carried an opening balance into this date but saw no movement of its own on it -- without
|
||||
* this, that denomination would vanish from the report instead of showing its carried-forward
|
||||
* count untouched. */
|
||||
private Map<String, Object> emptyLegRow(final Map<String, Object> template) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", template.get("denominationId"));
|
||||
row.put("denominationValue", template.get("denominationValue"));
|
||||
row.put("kind", template.get("kind"));
|
||||
row.put("vaultInCount", 0L);
|
||||
row.put("vaultInAmount", BigDecimal.ZERO);
|
||||
row.put("depositCount", 0L);
|
||||
row.put("depositAmount", BigDecimal.ZERO);
|
||||
row.put("withdrawalCount", 0L);
|
||||
row.put("withdrawalAmount", BigDecimal.ZERO);
|
||||
row.put("settledOutCount", 0L);
|
||||
row.put("settledOutAmount", BigDecimal.ZERO);
|
||||
return row;
|
||||
}
|
||||
|
||||
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
|
||||
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
|
||||
final Object total = position.get("totalAmount");
|
||||
|
||||
+526
-62
@@ -24,6 +24,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -95,12 +97,42 @@ public class ShareAdvanceCollectionApiResource {
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
// ── acc_gl_account.classification_enum ─────────────────────────────────
|
||||
// Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE.
|
||||
private static final int GL_CLASSIFICATION_INCOME = 4;
|
||||
|
||||
// ── CashAccountsForShares financial_account_type ───────────────────────
|
||||
private static final int FA_SHARES_REFERENCE = 1;
|
||||
private static final int FA_SHARES_SUSPENSE = 2;
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
private static final int FA_SHARES_EQUITY = 4;
|
||||
|
||||
/**
|
||||
* Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually
|
||||
* collected FOR a share collection, and nothing else.
|
||||
*
|
||||
* <p>
|
||||
* The Entry Type the counter picks at save time is the only thing that says what a GL receipt was
|
||||
* taken for, so it decides this too: a receipt left General, or tagged for some other purpose
|
||||
* entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the
|
||||
* adoption path was not enough -- a receipt adopted before that guard existed stayed on these
|
||||
* lists for ever, which is exactly how a General voucher kept reappearing against share accounts.
|
||||
*
|
||||
* <p>
|
||||
* A collection with no voucher of its own is the one thing this lets through: those come from the
|
||||
* direct Share Advance Collection endpoint, which exists for nothing but share collections, so
|
||||
* there is no counter tag to read and none needed.
|
||||
*/
|
||||
private static final String SHARE_COLLECTION_TAGGED_ONLY = """
|
||||
AND (c.voucher_no IS NULL
|
||||
OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d
|
||||
JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id
|
||||
WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection')
|
||||
OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r
|
||||
JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id
|
||||
WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection'))
|
||||
""";
|
||||
|
||||
// ── acc_product_mapping.product_type for shares ────────────────────────
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
@@ -150,7 +182,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHADV-" + System.nanoTime();
|
||||
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE);
|
||||
final String receiptNo = nextReceiptNo();
|
||||
|
||||
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
|
||||
@@ -186,26 +218,160 @@ public class ShareAdvanceCollectionApiResource {
|
||||
// GET — Unapplied receipts, optionally for one client
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
@Transactional
|
||||
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
|
||||
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
|
||||
if (clientId != null) {
|
||||
sql.append(" AND c.client_id = ").append(clientId);
|
||||
}
|
||||
sql.append(" ORDER BY c.collection_date, c.id");
|
||||
// A voucher explicitly tagged Share Collection at save time no longer waits for the counter
|
||||
// to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
|
||||
// for it. Anything not tagged this way (an older row, or one deliberately left General) still
|
||||
// needs the by-voucher search below, same as before.
|
||||
autoAdoptShareCollectionTagged();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
|
||||
final List<Map<String, Object>> rows = unappliedFor(clientId);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("unappliedCollections", rows);
|
||||
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unapplied receipts ready to be ticked, for one member or for everybody.
|
||||
*
|
||||
* Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the
|
||||
* same rows under {@code own} so an Apply screen needs a single call rather than two that were
|
||||
* only ever querying the same table with the same filters.
|
||||
*/
|
||||
private List<Map<String, Object>> unappliedFor(final Long clientId) {
|
||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
|
||||
+ " cl.display_name AS client_name, "
|
||||
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
|
||||
// The GL account this specific receipt's money actually sits in -- its own posting's
|
||||
// credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
|
||||
// Account picker above can be kept in sync with whichever receipt gets ticked.
|
||||
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS gl_code "
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "
|
||||
+ SHARE_COLLECTION_TAGGED_ONLY);
|
||||
if (clientId != null) {
|
||||
sql.append(" AND c.client_id = ").append(clientId);
|
||||
}
|
||||
sql.append(" ORDER BY c.collection_date, c.id");
|
||||
return jdbcTemplate.queryForList(sql.toString());
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — One unapplied receipt by the voucher number quoted by the member
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET /unattributed — share-collection receipts that name no member yet
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Approved cashier receipts tagged as a share collection that are still waiting to be attached to
|
||||
* a member.
|
||||
*
|
||||
* <p>
|
||||
* A receipt taken straight into a GL account names no member — a GL account is nobody's account —
|
||||
* so it can never be auto-adopted onto whichever member's screen happens to be open. That rule
|
||||
* stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on
|
||||
* purpose and then finding nothing on the Apply screen but "no unapplied receipt for this
|
||||
* member", with no way to discover that the voucher they just raised is sitting right there.
|
||||
*
|
||||
* <p>
|
||||
* This lists exactly those receipts so they can be seen and picked. Picking one still runs the
|
||||
* same by-voucher adoption a typed voucher number does: a person at the counter deliberately
|
||||
* attaching a named voucher to a member they can see. The list makes the voucher findable; it
|
||||
* does not decide whose money it is.
|
||||
*
|
||||
* <p>
|
||||
* Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left
|
||||
* out, so nothing here is something a second member could take twice. A receipt that already
|
||||
* names a member (a clerk collection resolved from the savings account it debited) is still
|
||||
* listed here, not only on that member's own screen -- every branch counter runs the same Apply
|
||||
* screen for whichever member is in front of them, so a voucher must be findable there regardless
|
||||
* of which member's screen happened to raise or auto-adopt it first. Attaching it from a different
|
||||
* member's screen still goes through the ordinary by-voucher lookup below, which refuses a
|
||||
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
|
||||
* does not reassign it.
|
||||
*/
|
||||
/**
|
||||
* @param clientId
|
||||
* the member whose own Apply screen this is. Their own unapplied receipts come back
|
||||
* separately under {@code own} -- the same rows, in the same shape, that
|
||||
* {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and
|
||||
* those rows are left out of {@code rows} so the same receipt is never listed twice on
|
||||
* one screen. Omit it and everything available comes back under {@code rows}.
|
||||
*/
|
||||
@GET
|
||||
@Path("/unattributed")
|
||||
public Response listUnattributed(@QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
|
||||
// voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen
|
||||
// is opened, not only once the specific member it names happens to be looked up.
|
||||
autoAdoptShareCollectionTagged();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\","
|
||||
+ " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount,"
|
||||
+ " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\","
|
||||
+ " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\""
|
||||
+ " FROM m_cashier_deposit_request d"
|
||||
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
|
||||
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED'"
|
||||
+ " AND d.client_id IS NULL AND d.share_account_id IS NULL"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
|
||||
+ " UNION ALL "
|
||||
+ " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\","
|
||||
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS \"glAccountNo\","
|
||||
// The GL account's own name, not the member's -- "Deposited To" names an
|
||||
// account on every other row of this list, and a receipt that already names a
|
||||
// member still needs its own column to say whose it is (below), not to borrow
|
||||
// this one and read as though a person were a GL account.
|
||||
+ " (SELECT ga.name FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS \"glAccountName\","
|
||||
+ " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\","
|
||||
// Visibly names whose receipt this is -- listing it here does not attribute it
|
||||
// (attaching it still runs the same by-voucher lookup, which refuses a
|
||||
// mismatched member), but a counter deciding whether to attach it needs to see
|
||||
// that up front rather than discovering it only after clicking Attach.
|
||||
+ " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)"
|
||||
+ " || ' (linked to ' || cl.display_name || ')' AS description,"
|
||||
+ " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\""
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id"
|
||||
// A voucher's own free-standing collection (raised straight through POST
|
||||
// /shareadvancecollection, no cashier deposit or clerk request behind it) has
|
||||
// no entry-type tag at all -- there is nothing else it could be, so it always
|
||||
// qualifies. One that WAS adopted from a cashier deposit or clerk disbursement
|
||||
// only stays listed here while that source row is still tagged Share
|
||||
// Collection today -- a voucher whose entry type reads General (or was
|
||||
// corrected to General after adoption) belongs on the ordinary voucher
|
||||
// register, not on this Share Collection-only list, even though the earlier
|
||||
// adoption already happened and cannot itself be undone here.
|
||||
+ " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no"
|
||||
+ " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no"
|
||||
+ " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id"
|
||||
+ " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id"
|
||||
+ " WHERE c.status = 'UNAPPLIED'"
|
||||
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
|
||||
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
|
||||
// This member's own receipts come back under "own" below, ready to tick; listing
|
||||
// them here as well would put the same receipt on screen twice, once needing a
|
||||
// tick and once an Attach, for no difference in what it is.
|
||||
+ (clientId != null ? " AND c.client_id <> " + clientId : "")
|
||||
+ " ORDER BY \"txnDate\" DESC");
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("rows", rows);
|
||||
out.put("own", clientId == null ? List.of() : unappliedFor(clientId));
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
|
||||
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
||||
@@ -229,9 +395,18 @@ public class ShareAdvanceCollectionApiResource {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
|
||||
// gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen
|
||||
// follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a
|
||||
// receipt resolved through this search must feed that picker exactly the same as one resolved
|
||||
// from the member's own list, or the picker is silently left on "Select GL account…" for
|
||||
// every voucher found this way.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
|
||||
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
|
||||
+ " c.applied_share_account_id,"
|
||||
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS gl_code"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
|
||||
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
|
||||
|
||||
@@ -271,19 +446,33 @@ public class ShareAdvanceCollectionApiResource {
|
||||
// GET — GL accounts selectable for a by-voucher lookup
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
|
||||
* The GL account picker on the Apply Collection screen — narrowed to accounts that actually have
|
||||
* an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now,
|
||||
* so the counter is choosing from real, live money rather than the entire chart of accounts
|
||||
* (which is every postable GL account in the bank, share-related or not, well over a hundred of
|
||||
* them, Cash at Vault included, with nothing to say which one has anything to find).
|
||||
*
|
||||
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
|
||||
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
|
||||
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
|
||||
* applying a receipt picks the account the deposit actually went to, every time.
|
||||
* Still never resolved from a share product's own Share Suspense mapping -- that mapping is
|
||||
* exactly what this lookup no longer trusts, since it can go missing or wrong without anyone
|
||||
* noticing until a voucher search fails at the counter. This filters by what genuinely sits in
|
||||
* each account today, not by what any product's configuration claims should. Falls back to the
|
||||
* unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a
|
||||
* dead end -- a voucher search should not become unreachable just because this narrowing has
|
||||
* nothing to narrow to yet.
|
||||
*/
|
||||
@GET
|
||||
@Path("/glaccounts")
|
||||
public Response glAccounts() {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
||||
// Every postable GL account, same as before this endpoint was ever narrowed to "only
|
||||
// accounts that currently hold an eligible receipt" -- that narrowing meant to help the
|
||||
// counter, but it back-fired the moment a genuine voucher sat in a GL account no live
|
||||
// receipt query happened to match (a fee posted somewhere unusual, a receipt taken
|
||||
// yesterday), silently dropping it from the list with no way to pick it by hand. The full
|
||||
// list is always safe to search against; nothing here is auto-picked for the counter (see
|
||||
// ngOnInit in the dialog) unless it is genuinely the only option.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("glAccounts", rows);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
@@ -316,6 +505,47 @@ public class ShareAdvanceCollectionApiResource {
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — The actual Dr/Cr GL legs a receipt posted, for the details popup
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* What this receipt actually did to the ledger — the real Dr/Cr legs under its own
|
||||
* {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box
|
||||
* (a different, unrelated piece of screen state that a details popup must never be shown
|
||||
* against, since it can name a completely different account than the one this receipt sits in).
|
||||
*/
|
||||
@GET
|
||||
@Path("/{collectionId}/gl-entries")
|
||||
public Response glEntries(@PathParam("collectionId") final Long collectionId) {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> collRows = jdbcTemplate
|
||||
.queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId);
|
||||
if (collRows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found.");
|
||||
}
|
||||
final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id");
|
||||
final List<Map<String, Object>> legs = glTransactionId == null ? List.of()
|
||||
: jdbcTemplate.queryForList(
|
||||
"SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount"
|
||||
+ " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id",
|
||||
glTransactionId);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("transactionId", glTransactionId);
|
||||
final List<Map<String, Object>> entries = new java.util.ArrayList<>();
|
||||
for (final Map<String, Object> leg : legs) {
|
||||
final Map<String, Object> e = new LinkedHashMap<>();
|
||||
e.put("glCode", leg.get("gl_code"));
|
||||
e.put("accountName", leg.get("account_name"));
|
||||
e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr");
|
||||
e.put("amount", leg.get("amount"));
|
||||
entries.add(e);
|
||||
}
|
||||
out.put("entries", entries);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — Apply a receipt to an activated share account
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@@ -377,20 +607,10 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
||||
|
||||
// Every receipt belongs to the member: applying one to someone else's account would move
|
||||
// one member's money into another's share capital. Checked per receipt, since a settlement
|
||||
// can now carry several.
|
||||
final Long acctClientId = toLong(acct.get("client_id"));
|
||||
for (final Map<String, Object> row : collections) {
|
||||
if (!toLong(row.get("client_id")).equals(acctClientId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
|
||||
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
|
||||
}
|
||||
}
|
||||
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
|
||||
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
|
||||
}
|
||||
// No client-match check here by deliberate choice: any receipt tagged Share Collection can
|
||||
// be applied to any share account, regardless of which member it was originally recorded
|
||||
// against. The counter is trusted to attach the right voucher by hand -- nothing here blocks
|
||||
// it if they choose otherwise.
|
||||
|
||||
final int statusEnum = toInt(acct.get("status_enum"));
|
||||
if (statusEnum != STATUS_ACTIVE) {
|
||||
@@ -472,31 +692,62 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
||||
final String txnId = "SHAPP-" + System.nanoTime();
|
||||
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE_APPLIED);
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
|
||||
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
|
||||
// it was never in Sundry, so nothing here should debit Sundry for it.
|
||||
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
|
||||
// Dr whichever account each receipt's money is actually sitting in, receipt by receipt,
|
||||
// including any over-payment on them — the liability those receipts created is cleared in
|
||||
// full. A verified fee receipt is deliberately excluded: its money was never held for the
|
||||
// shares, so nothing here should move it.
|
||||
//
|
||||
// Read back from the collection's own posting rather than resolved from the product's Sundry
|
||||
// mapping. The cashier does not always deposit into Sundry -- an entry fee goes straight to
|
||||
// its income account, and instalments taken on different days can land in different accounts
|
||||
// -- so debiting the mapped Sundry account for every receipt took money out of an account
|
||||
// that had never received it, and left the account that had holding the balance for ever.
|
||||
// Debiting each receipt's own account is also what the clerk sees: the GL picked against the
|
||||
// voucher on the apply screen is the account being cleared here.
|
||||
//
|
||||
// A receipt that already sits in an INCOME-classified GL is left exactly where it is, not
|
||||
// debited out here at all -- an entry fee the cashier deposited straight into its own income
|
||||
// account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it)
|
||||
// was already recognised as income the moment it was banked. Debiting it out and crediting a
|
||||
// fresh income leg below for the same rupees would recognise it a second time -- the
|
||||
// duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted.
|
||||
// A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy
|
||||
// single-receipt case where share value and fee arrived together) still needs clearing, so it
|
||||
// is debited as before and its amount still moves to Income below.
|
||||
BigDecimal feeAlreadyRecognised = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : collections) {
|
||||
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
|
||||
final BigDecimal rowAmount = (BigDecimal) row.get("amount");
|
||||
if (chargeColl == null && isIncomeGl(receiptGl)) {
|
||||
feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount);
|
||||
continue;
|
||||
}
|
||||
insertEntry(officeId, receiptGl, DEBIT, rowAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
|
||||
}
|
||||
|
||||
// Cr Share Capital for the share value.
|
||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
|
||||
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
|
||||
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
|
||||
// income account itself), so moving it again here would double it. When it was bundled into
|
||||
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
|
||||
// to move, exactly as before.
|
||||
// Cr Income from Fees for the entry fee, but only for whatever part of it is not already
|
||||
// sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already
|
||||
// sits in whichever GL the cashier deposited it to, so moving it again here would double it.
|
||||
// When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null)
|
||||
// and genuinely never touched an income account, it still needs to move, exactly as before.
|
||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
if (chargeColl == null) {
|
||||
// Resolved from the share product's "Income from Fees" mapping, which points at the
|
||||
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee for " + accountNo);
|
||||
final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised);
|
||||
if (feeStillToRecognise.compareTo(BigDecimal.ZERO) > 0) {
|
||||
// Resolved from the share product's "Income from Fees" mapping, which points at the
|
||||
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
|
||||
currencyCode, "Share entry fee for " + accountNo);
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||
@@ -534,17 +785,24 @@ public class ShareAdvanceCollectionApiResource {
|
||||
// Every receipt in this settlement is marked applied, not just the one in the path — they
|
||||
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
|
||||
// receipt is marked applied too, even though it posted no GL entry of its own.
|
||||
//
|
||||
// client_id is rewritten to this account's own client here too -- a voucher can now be found
|
||||
// and applied from any member's screen, so whoever last searched it (the value it carried
|
||||
// going in) is not necessarily who its money actually became share capital for. Leaving the
|
||||
// old value in place would make the receipt's own record misname whose it is the moment it
|
||||
// settles, which is exactly the figure a receipt-detail popup reads back afterwards.
|
||||
final Object settledClientId = acct.get("client_id");
|
||||
for (final Long id : collectionIds) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
|
||||
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, id);
|
||||
}
|
||||
if (chargeCollectionId != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
|
||||
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, chargeCollectionId);
|
||||
}
|
||||
|
||||
// Reveals the purchase transaction in the passbook and transactions views.
|
||||
@@ -595,6 +853,102 @@ public class ShareAdvanceCollectionApiResource {
|
||||
* missing product mapping can never make a genuine voucher unreachable.
|
||||
* @return the newly created collection row, or null when no approved deposit matches.
|
||||
*/
|
||||
/**
|
||||
* Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share
|
||||
* Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional
|
||||
* Share screen on its own -- the point of tagging a voucher this way at save time in the first
|
||||
* place.
|
||||
*
|
||||
* A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own --
|
||||
* buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of
|
||||
* these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER
|
||||
* auto-adopted for any client -- it can only ever be claimed through the manual by-voucher search
|
||||
* (including a deliberate click in the Unattributed list), where a person at the counter is
|
||||
* actively vouching for it against a member they can see.
|
||||
*
|
||||
* A row that DOES carry its own client_id (a Clerk Collection, whose client comes from the
|
||||
* savings account it actually debited -- real evidence, not a guess) is converted here
|
||||
* unconditionally, regardless of who is asking: the point of this pass is that the voucher
|
||||
* becomes visible and findable everywhere the moment it is tagged and approved, not only on the
|
||||
* one screen that happens to belong to the member it names. Converting it does not hand it to
|
||||
* anybody -- {@link #findByVoucher} still refuses to apply a receipt to an account whose client_id
|
||||
* does not match, so the member it actually names is protected the same way whether this ran for
|
||||
* their own screen or a different one.
|
||||
*/
|
||||
private void autoAdoptShareCollectionTagged() {
|
||||
final List<Map<String, Object>> deposits = jdbcTemplate.queryForList(
|
||||
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id,"
|
||||
+ " d.cashier_id, d.cashier_name, d.description"
|
||||
+ " FROM m_cashier_deposit_request d"
|
||||
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
|
||||
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
|
||||
+ " AND d.client_id IS NOT NULL"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
|
||||
for (final Map<String, Object> dep : deposits) {
|
||||
final Long resolvedClientId = toLong(dep.get("client_id"));
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
|
||||
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String receiptNo = nextReceiptNo();
|
||||
// What the cashier actually typed at save time, when they typed anything -- that is what
|
||||
// the Apply Share screen's Description column exists to show. Falls back to a plain
|
||||
// label only for a row nobody described, same as the manual by-voucher adoption below.
|
||||
final String depDescription = (String) dep.get("description");
|
||||
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
|
||||
: "Share collection - Voucher " + dep.get("voucher_no");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")),
|
||||
java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"),
|
||||
toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> collections = jdbcTemplate.queryForList(
|
||||
"SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
|
||||
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id"
|
||||
+ " FROM m_clerk_disbursement_request d"
|
||||
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
|
||||
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
|
||||
for (final Map<String, Object> req : collections) {
|
||||
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
|
||||
if (savingsAccountId == null) {
|
||||
continue;
|
||||
}
|
||||
final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
||||
savingsAccountId);
|
||||
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId);
|
||||
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
|
||||
final Object actionedAt = req.get("actioned_at");
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String voucherNo = (String) req.get("voucher_no");
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
|
||||
: "Share collection - Voucher " + voucherNo;
|
||||
// The real acc_gl_journal_entry.transaction_id this approval posted (see
|
||||
// 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the
|
||||
// voucher number only for a row approved before that column existed, since something has
|
||||
// to go in a NOT NULL-shaped identity column and the voucher number at least resolves
|
||||
// back to this same row.
|
||||
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, voucherNo, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency,
|
||||
glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
|
||||
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
|
||||
@@ -604,8 +958,10 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final List<Map<String, Object>> deposits = jdbcTemplate
|
||||
.queryForList(
|
||||
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
|
||||
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
|
||||
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id, d.description,"
|
||||
+ " cv.code_value AS entry_type"
|
||||
+ " FROM m_cashier_deposit_request d"
|
||||
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
|
||||
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
|
||||
voucherNo, shareSundryAccountNo);
|
||||
|
||||
@@ -618,6 +974,26 @@ public class ShareAdvanceCollectionApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
||||
"Voucher " + voucherNo + " has already been applied to a share account.");
|
||||
}
|
||||
// The counter tags what a GL receipt was actually collected for at save time, from the
|
||||
// GLEntryType code (Admin > System > Manage Codes). Exact match required -- "General" and an
|
||||
// untagged row used to be let through too, on the reasoning that untyped money carries no
|
||||
// evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly
|
||||
// as often share-collection cash the counter forgot to tag, and letting it through here
|
||||
// quietly rewarded skipping the tag. A voucher only ever becomes reachable for a share
|
||||
// collection by being tagged for one, on purpose, at save time.
|
||||
final String depositEntryType = (String) dep.get("entry_type");
|
||||
if (!"Share Collection".equals(depositEntryType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
|
||||
"Voucher " + voucherNo + " was recorded as "
|
||||
+ (depositEntryType == null || depositEntryType.isBlank() ? "an untagged entry" : depositEntryType)
|
||||
+ ", not Share Collection. Ask the cashier to correct the entry type at Cashier › Transaction Entry, "
|
||||
+ "then search again.");
|
||||
}
|
||||
if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
|
||||
toLong(dep.get("id"))).isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
||||
"Voucher " + voucherNo + " has already been used to issue a Demand Draft.");
|
||||
}
|
||||
|
||||
// Prefer the member recorded on the deposit; fall back to the account being settled.
|
||||
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
|
||||
@@ -632,18 +1008,32 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
// What the cashier actually typed at save time, when they typed anything -- same preference
|
||||
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
|
||||
// auto-adoption or by this manual by-voucher lookup. Falls back to a plain label only for a
|
||||
// deposit nobody described.
|
||||
final String depDescription = (String) dep.get("description");
|
||||
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
|
||||
: "Adopted from cashier deposit voucher " + voucherNo;
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
|
||||
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
|
||||
"Adopted from cashier deposit voucher " + voucherNo);
|
||||
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
|
||||
|
||||
return jdbcTemplate.queryForMap(
|
||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
||||
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
|
||||
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
|
||||
// adopted voucher must feed the on-screen GL Account picker exactly like one
|
||||
// that was already sitting on the member's list, or the picker is silently
|
||||
// left on "Select GL account…" for every voucher adopted this way.
|
||||
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS gl_code"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||
receiptNo);
|
||||
}
|
||||
@@ -665,16 +1055,30 @@ public class ShareAdvanceCollectionApiResource {
|
||||
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
|
||||
final String glAccountNo = glAccountNoOverride.trim();
|
||||
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
|
||||
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
|
||||
+ " FROM m_clerk_disbursement_request"
|
||||
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
|
||||
+ " ORDER BY id DESC",
|
||||
"SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
|
||||
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type"
|
||||
+ " FROM m_clerk_disbursement_request d"
|
||||
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
|
||||
+ " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
|
||||
+ " ORDER BY d.id DESC",
|
||||
voucherNo, glAccountNo);
|
||||
if (requests.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> req = requests.get(0);
|
||||
|
||||
// Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL
|
||||
// account the clerk picked by hand, so it carries the identical ambiguity, and the identical
|
||||
// exact-match requirement: a voucher only becomes reachable for a share collection by being
|
||||
// tagged Share Collection at save time, "General" and untagged included.
|
||||
final String reqEntryType = (String) req.get("entry_type");
|
||||
if (!"Share Collection".equals(reqEntryType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
|
||||
"Voucher " + voucherNo + " was recorded as "
|
||||
+ (reqEntryType == null || reqEntryType.isBlank() ? "an untagged entry" : reqEntryType)
|
||||
+ ", not Share Collection. Ask the clerk to correct the entry type on this request, then search again.");
|
||||
}
|
||||
|
||||
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
|
||||
if (savingsAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
||||
@@ -692,16 +1096,27 @@ public class ShareAdvanceCollectionApiResource {
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
|
||||
: "Adopted from clerk disbursement voucher " + voucherNo;
|
||||
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
|
||||
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
|
||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId,
|
||||
toLong(req.get("clerk_id")), req.get("clerk_name"), note);
|
||||
|
||||
return jdbcTemplate.queryForMap(
|
||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
||||
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
|
||||
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
|
||||
// adopted voucher must feed the on-screen GL Account picker exactly like one
|
||||
// that was already sitting on the member's list, or the picker is silently
|
||||
// left on "Select GL account…" for every voucher adopted this way.
|
||||
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
|
||||
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
|
||||
+ " ORDER BY je.id LIMIT 1) AS gl_code"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||
receiptNo);
|
||||
}
|
||||
@@ -809,6 +1224,55 @@ public class ShareAdvanceCollectionApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a receipt's money is actually sitting in, read back from the posting that put it
|
||||
* there — the credit leg of the collection's own journal transaction.
|
||||
*
|
||||
* <p>
|
||||
* Whoever took the money decided where it went: this register's own collection credits Sundry, a
|
||||
* cashier deposit credits whichever account the cashier posted against, and an entry fee usually
|
||||
* goes straight to fee income. Reading the account back is therefore the only way to know it, and
|
||||
* it matches what the clerk picks against the voucher on the apply screen.
|
||||
*
|
||||
* <p>
|
||||
* Falls back to the product's mapped Sundry account for a collection with no posting recorded
|
||||
* against it, which is where anything taken before this register started stamping its transaction
|
||||
* id was held.
|
||||
*
|
||||
* <p>
|
||||
* Scoped to the live credit leg, newest first: a transaction id is only as unique as whatever
|
||||
* minted it, and picking the most recent match keeps this correct even if one is ever reused.
|
||||
*/
|
||||
private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
|
||||
if (glTransactionId == null || glTransactionId.isBlank()) {
|
||||
return fallbackGlId;
|
||||
}
|
||||
final List<Long> rows = jdbcTemplate.queryForList("""
|
||||
SELECT account_id FROM acc_gl_journal_entry
|
||||
WHERE transaction_id = ? AND type_enum = ? AND reversed = false
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", Long.class, glTransactionId, CREDIT);
|
||||
return rows.isEmpty() ? fallbackGlId : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum}
|
||||
* for the "Income" account type on {@code acc_gl_account}).
|
||||
*
|
||||
* <p>
|
||||
* Used to tell an entry-fee receipt that already sits recognised as income (deposited straight
|
||||
* into its own income GL by the cashier) apart from one still sitting in a holding account like
|
||||
* Share Sundry — only the latter still needs moving into income here.
|
||||
*/
|
||||
private boolean isIncomeGl(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return false;
|
||||
}
|
||||
final Integer classification = jdbcTemplate.queryForObject(
|
||||
"SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
|
||||
return classification != null && classification == GL_CLASSIFICATION_INCOME;
|
||||
}
|
||||
|
||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
||||
final Long glId = resolveGl(productId, financialAccountType);
|
||||
if (glId == null) {
|
||||
|
||||
-439
@@ -1,439 +0,0 @@
|
||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* Cashier Share Collection API — POST /v1/cashier/sharecollection
|
||||
*
|
||||
* Records cash received from a member for share purchase and posts GL entries:
|
||||
* Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount
|
||||
* Cr SHARES_SUSPENSE (Share Sundry) = shareAmount
|
||||
* Cr INCOME_FROM_FEES = chargeAmount (if > 0)
|
||||
*
|
||||
* On account activation Fineract posts:
|
||||
* Dr SHARES_SUSPENSE → Cr SHARES_EQUITY
|
||||
* Activation skips charge GL if is_paid_derived = true (set here).
|
||||
*/
|
||||
@Path("/v1/cashier/sharecollection")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareCollectionApiResource {
|
||||
|
||||
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
|
||||
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr)
|
||||
// Opposite of intuition — verified from Fineract AccountingProcessorHelper.
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
// ── CashAccountsForShares financial_account_type ───────────────────────
|
||||
private static final int FA_SHARES_REFERENCE = 1;
|
||||
private static final int FA_SHARES_SUSPENSE = 2;
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
|
||||
// ── acc_gl_journal_entry.entity_type_enum ─────────────────────────────
|
||||
// Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt()
|
||||
// which only handles values 1-5 and returns null for unknown values.
|
||||
|
||||
// ── acc_product_mapping.product_type for shares ────────────────────────
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
// ── m_share_account.status_enum ───────────────────────────────────────
|
||||
private static final int STATUS_SUBMITTED_PENDING = 100;
|
||||
|
||||
// ── Maximum days a collection record is considered valid ──────────────
|
||||
private static final int MAX_COLLECTION_AGE_DAYS = 30;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — Collect cash for a share account
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Transactional
|
||||
public Response collect(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
// ── Parse request ──────────────────────────────────────────────────
|
||||
if (body == null || body.isBlank()) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.request.empty",
|
||||
"Request body is required.");
|
||||
}
|
||||
final JsonObject json;
|
||||
try {
|
||||
json = JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.request.invalid",
|
||||
"Request body must be valid JSON.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
||||
final String transactionDateStr = getString(json, "transactionDate");
|
||||
final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount");
|
||||
final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount");
|
||||
final String note = sanitiseNote(getString(json, "note"));
|
||||
|
||||
// ── Validate inputs ────────────────────────────────────────────────
|
||||
if (shareAccountId == null) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.accountid.required",
|
||||
"shareAccountId is required.");
|
||||
}
|
||||
if (shareAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.shareamount.negative",
|
||||
"shareAmount cannot be negative.");
|
||||
}
|
||||
final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0
|
||||
? chargeAmountRaw : BigDecimal.ZERO;
|
||||
final BigDecimal totalAmount = shareAmount.add(safeCharge);
|
||||
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.total.zero",
|
||||
"Total collection amount must be greater than zero.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate;
|
||||
try {
|
||||
entryDate = transactionDateStr != null
|
||||
? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.date.invalid",
|
||||
"transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr);
|
||||
}
|
||||
|
||||
// ── Load account + client details ──────────────────────────────────
|
||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
||||
|
||||
// ── Account must be in 'Submitted and pending approval' ───────────
|
||||
final int statusEnum = toInt(acct.get("status_enum"));
|
||||
if (statusEnum != STATUS_SUBMITTED_PENDING) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.account.invalid.status",
|
||||
"Cash collection is only allowed for accounts in 'Submitted and pending approval' "
|
||||
+ "status. Account " + acct.get("account_no") + " has status code " + statusEnum + ".");
|
||||
}
|
||||
|
||||
// ── Prevent double collection ──────────────────────────────────────
|
||||
if (Boolean.TRUE.equals(acct.get("cashier_collected"))) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.already.collected",
|
||||
"Cash has already been collected for share account "
|
||||
+ acct.get("account_no") + ". Re-collection is not allowed.");
|
||||
}
|
||||
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
final String clientName = (String) acct.get("client_name");
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
// ── Resolve GL accounts from product mapping ───────────────────────
|
||||
final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE);
|
||||
final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||
final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES);
|
||||
|
||||
if (glReference == null) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.gl.reference.notmapped",
|
||||
"Share Reference (Cash at Vault) GL is not mapped for product " + productId
|
||||
+ ". Configure Share Product accounting mappings first.");
|
||||
}
|
||||
if (glSuspense == null) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.gl.suspense.notmapped",
|
||||
"Share Suspense GL is not mapped for product " + productId
|
||||
+ ". Configure Share Product accounting mappings first.");
|
||||
}
|
||||
if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.gl.income.notmapped",
|
||||
"Income from Fees GL is not mapped for product " + productId
|
||||
+ " but charge amount " + safeCharge + " is present. "
|
||||
+ "Configure Share Product accounting mappings first.");
|
||||
}
|
||||
|
||||
// ── Generate unique transaction ID ─────────────────────────────────
|
||||
final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
|
||||
final long userId = currentUser.getId();
|
||||
final String collectorName = currentUser.getUsername();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
// ── Post GL journal entries ────────────────────────────────────────
|
||||
// Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
|
||||
insertEntry(officeId, glReference, DEBIT, totalAmount, txnId,
|
||||
entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share collection received for " + accountNo
|
||||
+ (note != null ? " | " + note : ""));
|
||||
|
||||
// Cr Share Sundry (SHARES_SUSPENSE) = shareAmount
|
||||
insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId,
|
||||
entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share amount held in suspense - " + accountNo);
|
||||
|
||||
// Cr Income from Fees = chargeAmount (only if > 0)
|
||||
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
|
||||
insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId,
|
||||
entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Charge collected upfront for " + accountNo);
|
||||
|
||||
// Mark charges paid → activation will skip duplicate charge GL entry
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_share_account_charge "
|
||||
+ "SET is_paid_derived = true, "
|
||||
+ " amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 "
|
||||
+ "WHERE account_id = ? AND is_active = true AND is_paid_derived = false",
|
||||
shareAccountId);
|
||||
}
|
||||
|
||||
// ── Flip cashier_collected flag ────────────────────────────────────
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_share_account SET cashier_collected = true WHERE id = ?",
|
||||
shareAccountId);
|
||||
|
||||
// ── Insert audit / validation record ──────────────────────────────
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_cashier_collection "
|
||||
+ "(share_account_id, collected_by_user, collected_by_name, collection_date, "
|
||||
+ " share_amount, charge_amount, total_amount, gl_transaction_id, "
|
||||
+ " office_id, currency_code, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
shareAccountId, userId, collectorName,
|
||||
java.sql.Date.valueOf(entryDate),
|
||||
shareAmount, safeCharge, totalAmount,
|
||||
txnId, officeId, currencyCode, note);
|
||||
|
||||
// ── Build GL entries summary for response ──────────────────────────
|
||||
final List<Map<String, Object>> glEntries = new ArrayList<>();
|
||||
glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode));
|
||||
glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode));
|
||||
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
|
||||
glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode));
|
||||
}
|
||||
|
||||
// ── Response ───────────────────────────────────────────────────────
|
||||
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("transactionId", txnId);
|
||||
resp.put("shareAccountId", shareAccountId);
|
||||
resp.put("accountNo", accountNo);
|
||||
resp.put("clientName", clientName);
|
||||
resp.put("collectionDate", entryDate.toString());
|
||||
resp.put("collectedBy", collectorName);
|
||||
resp.put("shareAmount", shareAmount);
|
||||
resp.put("chargeAmount", safeCharge);
|
||||
resp.put("totalAmount", totalAmount);
|
||||
resp.put("currencyCode", currencyCode);
|
||||
resp.put("glEntries", glEntries);
|
||||
|
||||
return Response.ok(gson.toJson(resp)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — Check collection status for a share account
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
@Path("/{shareAccountId}")
|
||||
public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
try {
|
||||
final Map<String, Object> rec = jdbcTemplate.queryForMap(
|
||||
"SELECT scc.share_account_id, sa.account_no, "
|
||||
+ " mc.display_name AS client_name, "
|
||||
+ " scc.collected_by_name, scc.collection_date, "
|
||||
+ " scc.share_amount, scc.charge_amount, scc.total_amount, "
|
||||
+ " scc.gl_transaction_id, scc.currency_code, scc.note "
|
||||
+ "FROM m_share_cashier_collection scc "
|
||||
+ "JOIN m_share_account sa ON sa.id = scc.share_account_id "
|
||||
+ "JOIN m_client mc ON mc.id = sa.client_id "
|
||||
+ "WHERE scc.share_account_id = ?",
|
||||
shareAccountId);
|
||||
|
||||
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("collected", true);
|
||||
resp.put("shareAccountId", shareAccountId);
|
||||
resp.put("accountNo", rec.get("account_no"));
|
||||
resp.put("clientName", rec.get("client_name"));
|
||||
resp.put("collectedBy", rec.get("collected_by_name"));
|
||||
resp.put("collectionDate", rec.get("collection_date") != null
|
||||
? rec.get("collection_date").toString() : null);
|
||||
resp.put("shareAmount", rec.get("share_amount"));
|
||||
resp.put("chargeAmount", rec.get("charge_amount"));
|
||||
resp.put("totalAmount", rec.get("total_amount"));
|
||||
resp.put("transactionId", rec.get("gl_transaction_id"));
|
||||
resp.put("currencyCode", rec.get("currency_code"));
|
||||
resp.put("note", rec.get("note"));
|
||||
return Response.ok(gson.toJson(resp)).build();
|
||||
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("collected", false);
|
||||
resp.put("shareAccountId", shareAccountId);
|
||||
return Response.ok(gson.toJson(resp)).build();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private Map<String, Object> loadAccount(final Long shareAccountId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap(
|
||||
"SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, "
|
||||
+ " sa.cashier_collected, sa.currency_code, "
|
||||
+ " mc.office_id, mc.display_name AS client_name "
|
||||
+ "FROM m_share_account sa "
|
||||
+ "JOIN m_client mc ON mc.id = sa.client_id "
|
||||
+ "WHERE sa.id = ?",
|
||||
shareAccountId);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.account.notfound",
|
||||
"Share account with id " + shareAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveGl(final Long productId, final int financialAccountType) {
|
||||
try {
|
||||
return jdbcTemplate.queryForObject(
|
||||
"SELECT gl_account_id FROM acc_product_mapping "
|
||||
+ "WHERE product_id = ? AND product_type = ? AND financial_account_type = ?",
|
||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||
} catch (IncorrectResultSizeDataAccessException e) {
|
||||
// covers both EmptyResultDataAccessException (0 rows) and multiple rows
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveGlName(final Long glAccountId) {
|
||||
if (glAccountId == null) return "Unknown GL";
|
||||
try {
|
||||
return jdbcTemplate.queryForObject(
|
||||
"SELECT name FROM acc_gl_account WHERE id = ?",
|
||||
String.class, glAccountId);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return "GL-" + glAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId,
|
||||
final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long shareAccountId,
|
||||
final long userId, final LocalDateTime now, final String currencyCode,
|
||||
final String description) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry "
|
||||
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, "
|
||||
+ " created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId,
|
||||
java.sql.Date.valueOf(entryDate), typeEnum, amount, description,
|
||||
shareAccountId, // entity_id — links entry to the share account
|
||||
userId, userId, now, now,
|
||||
java.sql.Date.valueOf(entryDate),
|
||||
java.sql.Date.valueOf(entryDate),
|
||||
toUtc(now),
|
||||
toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||
*
|
||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
||||
*/
|
||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private Map<String, Object> glEntryMap(final String account, final String type,
|
||||
final BigDecimal amount, final String currency) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("account", account);
|
||||
m.put("type", type);
|
||||
m.put("amount", amount);
|
||||
m.put("currency", currency);
|
||||
return m;
|
||||
}
|
||||
|
||||
private String sanitiseNote(final String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
final String trimmed = raw.trim();
|
||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
|
||||
private int toInt(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).intValue() : 0;
|
||||
}
|
||||
|
||||
private Long getLong(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
|
||||
}
|
||||
|
||||
private BigDecimal getBigDecimal(final JsonObject json, final String key,
|
||||
final String fieldLabel) {
|
||||
if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO;
|
||||
try {
|
||||
return json.get(key).getAsBigDecimal();
|
||||
} catch (NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.sharecollection.field.invalid.number",
|
||||
"Field '" + fieldLabel + "' must be a valid number.");
|
||||
}
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -340,7 +340,7 @@ public class ChequeBookIssuanceChargeService {
|
||||
private Long getCurrentControlGlId(final Long currentAccountId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca "
|
||||
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||
}
|
||||
|
||||
@@ -389,8 +389,12 @@ public class ChequeBookIssuanceChargeService {
|
||||
|
||||
/** 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) {
|
||||
// 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
|
||||
// stale, unrelated journal entry.
|
||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||
glTxnId);
|
||||
if (!rowIds.isEmpty()) {
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService {
|
||||
if ("CURRENT".equalsIgnoreCase(accountType)) {
|
||||
return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca "
|
||||
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+5
-1
@@ -192,8 +192,12 @@ public class ClearingJournalService {
|
||||
}
|
||||
|
||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
||||
// 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
|
||||
// stale, unrelated journal entry.
|
||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1", Long.class,
|
||||
glTxnId);
|
||||
if (!rowIds.isEmpty()) {
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
|
||||
|
||||
+277
-105
@@ -116,6 +116,12 @@ public class ClerkDisbursementRequestApiResource {
|
||||
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
|
||||
requestType = "DISBURSEMENT";
|
||||
}
|
||||
// What this GL leg is actually for — a code value under "GLEntryType" (Admin > System >
|
||||
// Manage Codes). A COLLECTION credits a GL account the counter picks by hand, the same
|
||||
// ambiguity a cashier Receipt has (see CashierDepositRequestApiResource and
|
||||
// 0565_convert_entry_type_to_code_value.xml). ShareAdvanceCollectionApiResource checks this
|
||||
// before adopting one of these rows as a share collection.
|
||||
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
|
||||
|
||||
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
|
||||
throw apiError(400, "At least one entry is required");
|
||||
@@ -145,13 +151,47 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
||||
throw apiError(400, "Each entry must have an account number");
|
||||
}
|
||||
// Whatever the counter searched with to find this account (often a short suffix, not the
|
||||
// full number) is not what belongs on this record permanently — replace it here, once, at
|
||||
// submit time, with the real number looked up from the id the search actually resolved.
|
||||
// Leaves the entry's own accountNo untouched only when it carries no id to look up from
|
||||
// (a GL leg's account/glAccountId is set, so this only bites when the search itself never
|
||||
// matched anything, which submit-time validation elsewhere is already responsible for).
|
||||
final String entryAccountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||
final boolean entryIsGl = "GL".equals(entryAccountType);
|
||||
final String realAccountNo = entryIsGl
|
||||
? (entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
|
||||
? glCodeOf(entry.get("glAccountId").getAsLong())
|
||||
: null)
|
||||
: (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
||||
? nonGlAccountNoOf(entryAccountType, entry.get("savingsAccountId").getAsLong())
|
||||
: null);
|
||||
if (realAccountNo != null) {
|
||||
entry.addProperty("accountNo", realAccountNo);
|
||||
}
|
||||
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
|
||||
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
|
||||
// working, since nothing about a restricted customer should prevent them being paid.
|
||||
// A GL leg carries no savingsAccountId and the guard ignores it.
|
||||
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
|
||||
// m_current_account -- checking it as a savings id would test some unrelated savings account.
|
||||
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
|
||||
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
|
||||
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
|
||||
: KycRestrictionGuard.Operation.CREDIT;
|
||||
if ("CURRENT".equals(entryAccountType)) {
|
||||
kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation);
|
||||
} else {
|
||||
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation);
|
||||
}
|
||||
}
|
||||
// A Dr leg against a savings account is a withdrawal, and the clerk who raises it should
|
||||
// not learn the account can't pay out until a manager tries to approve it and the whole
|
||||
// request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would
|
||||
// refuse this later -- inactive, closed, or still inside its lock-in period -- is checked
|
||||
// here too, against the same columns, so the clerk sees the real reason at entry time.
|
||||
if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType)
|
||||
&& entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||
validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt);
|
||||
}
|
||||
if ("Dr".equals(dbCr)) {
|
||||
totalDr = totalDr.add(entryAmt);
|
||||
@@ -180,21 +220,27 @@ public class ClerkDisbursementRequestApiResource {
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
// Same correction as each entry above, for the request's own top-level GL — and the entries
|
||||
// are stored from the mutated array (each accountNo now the real one), not the original
|
||||
// as-submitted JSON string.
|
||||
final String realGlAccountNo = Optional.ofNullable(glCodeOf(glAccountId)).orElse(glAccountNo);
|
||||
final String correctedCreditEntriesJson = gson.toJson(creditEntries);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_clerk_disbursement_request
|
||||
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
|
||||
credit_entries, payment_type_id, payment_type_name, description,
|
||||
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type, entry_type_cv_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
glAccountId, glAccountNo, glAccountName, txnAmount,
|
||||
glAccountId, realGlAccountNo, glAccountName, txnAmount,
|
||||
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
|
||||
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
||||
correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
activityLog, Timestamp.from(Instant.now()), requestType
|
||||
activityLog, Timestamp.from(Instant.now()), requestType, entryTypeCvId
|
||||
);
|
||||
|
||||
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
|
||||
@@ -282,7 +328,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
+ " credit_entries, payment_type_name, description,"
|
||||
+ " clerk_id, clerk_name, clerk_username,"
|
||||
+ " manager_id, manager_name, manager_username,"
|
||||
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
|
||||
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
|
||||
+ " FROM m_clerk_disbursement_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -349,7 +395,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount,"
|
||||
+ " credit_entries, payment_type_name, description, clerk_name,"
|
||||
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
|
||||
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id"
|
||||
+ " FROM m_clerk_disbursement_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -473,11 +519,17 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final List<String> withdrawalSavingsTxnRefs = new ArrayList<>();
|
||||
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
|
||||
Long collectionTargetGlId = null;
|
||||
// The real acc_gl_journal_entry.transaction_id this approval posts -- captured here because
|
||||
// nothing else on the row remembers it otherwise (txn_refs only holds the account-side
|
||||
// transaction ids). Whoever needs the actual GL posting afterwards (Share Advance Collection
|
||||
// adopting a tagged voucher, a GL detail view) reads it back from gl_transaction_id.
|
||||
String approvedGlTxnId = null;
|
||||
|
||||
// Savings-control GL data per account for the direct combined journal entry.
|
||||
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
|
||||
// disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode).
|
||||
final List<GlEntry> collectionGlEntries = new ArrayList<>();
|
||||
final List<GlEntry> collectionCreditGlEntries = new ArrayList<>();
|
||||
final List<GlEntry> disbursementGlEntries = new ArrayList<>();
|
||||
|
||||
// Compensation list: each successfully posted Fineract transaction gets an undo lambda.
|
||||
@@ -499,8 +551,11 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
|
||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
|
||||
final Long accountId = entry.get("savingsAccountId").getAsLong();
|
||||
final boolean isCurrent = "CURRENT".equals(
|
||||
entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
|
||||
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
|
||||
// exist in both m_current_account and m_savings_account at once, and a bank
|
||||
// transfer posts and moves money immediately, with no maker-checker step to catch
|
||||
// a wrong label before it does.
|
||||
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
|
||||
|
||||
final Long ctrlGl = isCurrent
|
||||
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
|
||||
@@ -538,6 +593,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
|
||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
|
||||
}
|
||||
approvedGlTxnId = btGlTxnId;
|
||||
|
||||
// Audit log in m_bank_transfer_log
|
||||
final String btTxnRefsStr = String.join(",", txnRefs);
|
||||
@@ -597,8 +653,9 @@ public class ClerkDisbursementRequestApiResource {
|
||||
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
|
||||
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
|
||||
} else {
|
||||
// Collection: CR GL is the target for the combined savings debit entry
|
||||
// Collection: each CR GL takes its own amount in the one combined entry
|
||||
collectionTargetGlId = glId;
|
||||
collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null));
|
||||
}
|
||||
}
|
||||
txnRefs.add("GL:" + acNo);
|
||||
@@ -606,7 +663,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
}
|
||||
|
||||
final Long accountId = entry.get("savingsAccountId").getAsLong();
|
||||
final boolean isCurrentAccount = isCurrentAccountById(accountId);
|
||||
final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo);
|
||||
String txnId = null;
|
||||
|
||||
if ("Cr".equals(dbCr)) {
|
||||
@@ -643,28 +700,26 @@ public class ClerkDisbursementRequestApiResource {
|
||||
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
|
||||
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
|
||||
} catch (IdempotentCommandProcessSucceedException idempotent) {
|
||||
txnId = extractResourceIdFromResponse(idempotent.getResponse());
|
||||
// Collection: a direct book debit, exactly as a bank transfer debits. It used to run a
|
||||
// cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never
|
||||
// moved) was then hidden by flagging it reversed, and a current account's auto-entry was
|
||||
// not even hidden, crediting CashVault instead of the target GL. Now nothing is posted
|
||||
// here; the one combined entry below is the only GL posting.
|
||||
final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
|
||||
if (ctrlGl == null) {
|
||||
throw new RuntimeException("No control GL mapped for account " + acNo
|
||||
+ ". Map it on the account's product Accounting tab.");
|
||||
}
|
||||
if (txnId != null) {
|
||||
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
|
||||
txnRefs.add(txnId);
|
||||
if (!isCurrentAccount) {
|
||||
final String fullTxnId = "S" + txnId;
|
||||
withdrawalSavingsTxnRefs.add(txnId);
|
||||
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
||||
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
|
||||
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
|
||||
// post a direct DR SavingsControl / CR targetGL combined entry.
|
||||
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
|
||||
if (savingsCtrlGl != null) {
|
||||
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
|
||||
}
|
||||
}
|
||||
final long debitTxnId = isCurrentAccount
|
||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||
txnRefs.add(String.valueOf(debitTxnId));
|
||||
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
||||
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
|
||||
if (isCurrentAccount) {
|
||||
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
|
||||
} else {
|
||||
undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,35 +738,26 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (disbGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
|
||||
}
|
||||
approvedGlTxnId = disbGlTxnId;
|
||||
}
|
||||
|
||||
// ── Collection GL entry ───────────────────────────────────────────────
|
||||
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
|
||||
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
|
||||
// We mark those auto-entries reversed (hidden from GL reports) and post
|
||||
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
|
||||
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
|
||||
if (!collectionGlEntries.isEmpty()
|
||||
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
|
||||
markTxnGlEntriesReversed(collectionGlEntries);
|
||||
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
|
||||
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
|
||||
final String collGlTxnId = postCombinedJournalEntry(
|
||||
collectionGlEntries,
|
||||
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
|
||||
officeId, currencyCode, txnDateStr,
|
||||
buildGlEntryComment("Collection", collectionGlEntries, description));
|
||||
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
|
||||
if (collGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
|
||||
}
|
||||
} else {
|
||||
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
|
||||
if (cashVaultGlId != null) {
|
||||
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
|
||||
officeId, currencyCode, txnDateStr, description);
|
||||
}
|
||||
// One combined entry and nothing else: DR each account's own product control GL / CR each
|
||||
// target GL. The accounts were debited directly above, so there is no auto-posted entry to
|
||||
// hide and no CashVault leg to bridge.
|
||||
if (!collectionGlEntries.isEmpty()) {
|
||||
if (collectionCreditGlEntries.isEmpty()) {
|
||||
throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + ".");
|
||||
}
|
||||
final String collGlTxnId = postCombinedJournalEntry(
|
||||
collectionGlEntries, collectionCreditGlEntries,
|
||||
officeId, currencyCode, txnDateStr,
|
||||
buildGlEntryComment("Collection", collectionGlEntries, description));
|
||||
// Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view.
|
||||
if (collGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
|
||||
}
|
||||
approvedGlTxnId = collGlTxnId;
|
||||
}
|
||||
} // end else (disbursement/collection mode)
|
||||
|
||||
@@ -756,11 +802,11 @@ public class ClerkDisbursementRequestApiResource {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_clerk_disbursement_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
txn_refs = ?, activity_log = ?, actioned_at = ?
|
||||
txn_refs = ?, gl_transaction_id = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id);
|
||||
txnRefsStr, approvedGlTxnId, updatedLog, Timestamp.from(Instant.now()), id);
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
@@ -916,8 +962,11 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final Long accountId = e.get("savingsAccountId").getAsLong();
|
||||
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
|
||||
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
|
||||
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
|
||||
final boolean isCurrent = "CURRENT".equals(acTyp);
|
||||
// Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can
|
||||
// exist in both m_current_account and m_savings_account at once, and a bank transfer
|
||||
// posts and moves money immediately, with no maker-checker step to catch a wrong
|
||||
// label before it does.
|
||||
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
|
||||
|
||||
final Long ctrlGl = isCurrent
|
||||
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
|
||||
@@ -943,8 +992,8 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final Long accountId = e.get("savingsAccountId").getAsLong();
|
||||
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
|
||||
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
|
||||
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
|
||||
final boolean isCurrent = "CURRENT".equals(acTyp);
|
||||
// Not trusted from entry.accountType alone -- see isCurrentAccountById.
|
||||
final boolean isCurrent = isCurrentAccountById(accountId, acNo);
|
||||
|
||||
final Long ctrlGl = isCurrent
|
||||
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
|
||||
@@ -1107,6 +1156,79 @@ public class ClerkDisbursementRequestApiResource {
|
||||
* A failure here does not fail the approval: every transaction is posted by this point, and the
|
||||
* money has moved. The problem is logged, and the voucher keeps its number without sides.
|
||||
*/
|
||||
/** The GL account's own code, looked up fresh from its id -- never trust a caller-supplied string
|
||||
* for what belongs on a voucher's GL-side line. Null-safe so a possibly-missing id passes through. */
|
||||
private String glCodeOf(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** The account's own account number, looked up fresh from its id -- same reasoning as
|
||||
* {@link #glCodeOf}, for the non-GL side of a voucher line. The id travels in this entry's
|
||||
* {@code savingsAccountId} field regardless of what kind of account it actually names (this
|
||||
* request's JSON has no separate field per account type), but "CURRENT" accounts live in their
|
||||
* own {@code m_current_account} table with their own id space -- looking every non-GL id up
|
||||
* against {@code m_savings_account} would silently return an unrelated savings account's number
|
||||
* whenever the two id spaces happened to collide, which is worse than the truncated-search-string
|
||||
* bug this method exists to fix. Every other non-GL type (savings, fixed deposit, recurring
|
||||
* deposit) is a {@code m_savings_account} row distinguished only by {@code deposit_type_enum}. */
|
||||
private String nonGlAccountNoOf(final String accountType, final Long accountId) {
|
||||
if (accountId == null) {
|
||||
return null;
|
||||
}
|
||||
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
|
||||
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
/**
|
||||
* The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not
|
||||
* active, or still inside its lock-in period -- checked here against the same columns, so the
|
||||
* clerk sees the real reason when the request is raised rather than a generic failure once a
|
||||
* manager tries to approve it. Balance is checked too, against the account's own minimum, since
|
||||
* an entry that can never post is no more usable sitting in the approval queue than one refused
|
||||
* outright.
|
||||
*/
|
||||
private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived,
|
||||
min_required_balance, enforce_min_required_balance
|
||||
FROM m_savings_account WHERE id = ?
|
||||
""", savingsAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere
|
||||
}
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
final String accountNo = (String) row.get("account_no");
|
||||
final Number statusEnum = (Number) row.get("status_enum");
|
||||
if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) {
|
||||
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active.");
|
||||
}
|
||||
final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived");
|
||||
if (lockedUntil != null) {
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate lockedUntilDate = lockedUntil.toLocalDate();
|
||||
if (today.isBefore(lockedUntilDate)) {
|
||||
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo
|
||||
+ ": account is locked-in until " + lockedUntilDate + ".");
|
||||
}
|
||||
}
|
||||
final BigDecimal balance = (BigDecimal) row.get("account_balance_derived");
|
||||
final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance"));
|
||||
final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null
|
||||
? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO;
|
||||
if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) {
|
||||
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance ("
|
||||
+ balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ").");
|
||||
}
|
||||
}
|
||||
|
||||
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
|
||||
final String requestType) {
|
||||
|
||||
@@ -1124,12 +1246,28 @@ public class ClerkDisbursementRequestApiResource {
|
||||
|
||||
for (final JsonElement el : creditEntries) {
|
||||
final JsonObject entry = el.getAsJsonObject();
|
||||
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||
final boolean isGlEntry = "GL".equals(accountType);
|
||||
|
||||
// For a Disbursement/Collection, the society's own GL side is added once as a single
|
||||
// total further down (from sourceGlId, the actually-resolved id) -- not read per entry
|
||||
// here. A Collection's payload names its GL leg inside creditEntries too (so the
|
||||
// amounts balance for the request's own DR/CR validation at submit time), but if this
|
||||
// loop also turned that same entry into a line, the GL account would be posted twice on
|
||||
// one voucher: once here (wrongly signed, since entryIsDebit below ignores this entry's
|
||||
// own dbCr and applies isCollection to every entry in the array) and again as the
|
||||
// society's side, with counterpartyTotal inflated by the GL entry's own amount on top of
|
||||
// it. A bank transfer has no separate society side and every entry, GL or not, is a real
|
||||
// leg of the transfer, so nothing is skipped there.
|
||||
if (!isBankTransfer && isGlEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
|
||||
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
|
||||
final String enteredAccountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
|
||||
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
|
||||
? entry.get("accountName").getAsString()
|
||||
: accountNo;
|
||||
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
|
||||
: enteredAccountNo;
|
||||
|
||||
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
|
||||
? entry.get("glAccountId").getAsLong()
|
||||
@@ -1138,6 +1276,14 @@ public class ClerkDisbursementRequestApiResource {
|
||||
? entry.get("savingsAccountId").getAsLong()
|
||||
: null;
|
||||
|
||||
// Whatever the counter searched with to find this account (often a short suffix, not
|
||||
// the full number) is never what belongs on the slip. The id is what was actually
|
||||
// resolved and posted against, so look its real number up from the database rather
|
||||
// than trust the string the client happened to submit alongside it -- the id is the
|
||||
// only part of this entry that can't be typo'd or truncated.
|
||||
final String accountNo = isGlEntry ? Optional.ofNullable(glCodeOf(glId)).orElse(enteredAccountNo)
|
||||
: Optional.ofNullable(nonGlAccountNoOf(accountType, savingsId)).orElse(enteredAccountNo);
|
||||
|
||||
// A bank transfer states each side on the entry itself; the other two directions
|
||||
// put every counterparty on the side opposite the society's GL.
|
||||
final boolean entryIsDebit = isBankTransfer
|
||||
@@ -1163,7 +1309,10 @@ public class ClerkDisbursementRequestApiResource {
|
||||
// between them — so its entries already balance on their own.
|
||||
if (!isBankTransfer) {
|
||||
final String glName = (String) record.get("gl_account_name");
|
||||
final String glNo = (String) record.get("gl_account_no");
|
||||
// Same reasoning as above: gl_account_no is whatever string was stored on this request
|
||||
// at submit time, not necessarily this GL account's real code. sourceGlId is what was
|
||||
// actually resolved and posted against.
|
||||
final String glNo = Optional.ofNullable(glCodeOf(sourceGlId)).orElse((String) record.get("gl_account_no"));
|
||||
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
|
||||
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
|
||||
}
|
||||
@@ -1183,7 +1332,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
|
||||
description, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason,
|
||||
txn_refs, error_message, activity_log, submitted_at, actioned_at
|
||||
txn_refs, error_message, activity_log, submitted_at, actioned_at, entry_type_cv_id
|
||||
FROM m_clerk_disbursement_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -1218,10 +1367,25 @@ public class ClerkDisbursementRequestApiResource {
|
||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
}
|
||||
|
||||
private boolean isCurrentAccountById(final Long accountId) {
|
||||
/**
|
||||
* 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 at once -- two unrelated accounts, possibly two different
|
||||
* clients, sharing nothing but a coincidental number. An id-only check here has already sent a
|
||||
* real deposit into a stranger's current account instead of the savings account the entry
|
||||
* actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match
|
||||
* is only trusted once the account_no on that current-account row confirms it. Pass null to skip
|
||||
* the check where the caller has no account_no to compare against.
|
||||
*/
|
||||
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
|
||||
try {
|
||||
if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
|
||||
Integer.class, accountId, expectedAccountNo);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
@@ -1242,6 +1406,18 @@ public class ClerkDisbursementRequestApiResource {
|
||||
try { return new BigDecimal(raw); } catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
|
||||
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
|
||||
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
|
||||
private Long resolveEntryTypeCvId(final Long provided) {
|
||||
if (provided != null) {
|
||||
return provided;
|
||||
}
|
||||
return jdbcTemplate.queryForObject(
|
||||
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
|
||||
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
|
||||
Long.class);
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action,
|
||||
final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
@@ -1321,9 +1497,12 @@ public class ClerkDisbursementRequestApiResource {
|
||||
|
||||
private Long findCashVaultGlId(final List<String> txnRefs, final int typeEnum) {
|
||||
for (final String txnRef : txnRefs) {
|
||||
// ORDER BY id DESC: if transaction_id text is ever reused (e.g. after a sequence
|
||||
// reset), always prefer the most recently posted matching row over a stale one.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT account_id FROM acc_gl_journal_entry"
|
||||
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
|
||||
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1",
|
||||
"S" + txnRef, typeEnum);
|
||||
if (!rows.isEmpty()) {
|
||||
return ((Number) rows.get(0).get("account_id")).longValue();
|
||||
@@ -1332,7 +1511,15 @@ public class ClerkDisbursementRequestApiResource {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void postGlOffsetEntry(
|
||||
/**
|
||||
* @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs
|
||||
* to remember which posting was theirs — the collection fallback below, most importantly
|
||||
* — is not left with nothing to record. Returning it here rather than discarding it, as
|
||||
* this method used to, was the entire reason {@code gl_transaction_id} stayed blank on
|
||||
* every clerk collection that fell back to this path: nothing downstream (Share Advance
|
||||
* Collection's GL-detail popup among them) had anything to look the posting up by.
|
||||
*/
|
||||
private String postGlOffsetEntry(
|
||||
final Long drGlAccountId, final Long crGlAccountId,
|
||||
final BigDecimal amount, final Long officeId,
|
||||
final String currencyCode, final String txnDateStr, final String comments) {
|
||||
@@ -1368,7 +1555,9 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.build();
|
||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
// The disbursement register raises its own voucher; suppress the journal command's own.
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
return result != null ? result.getTransactionId() : null;
|
||||
}
|
||||
|
||||
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||
@@ -1402,39 +1591,16 @@ public class ClerkDisbursementRequestApiResource {
|
||||
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
|
||||
*/
|
||||
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
|
||||
// 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).
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT account_id FROM acc_gl_journal_entry"
|
||||
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
|
||||
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1",
|
||||
fullTxnId, typeEnum);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all Fineract auto-created journal entries for these transactions as reversed=true.
|
||||
* Reversed entries are excluded from GL reports — only our direct combined entry is visible.
|
||||
* Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails.
|
||||
*/
|
||||
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
|
||||
for (final GlEntry e : entries) {
|
||||
if (e.txnId != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
|
||||
e.txnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */
|
||||
private void unmarkTxnGlEntriesReversed(final List<GlEntry> entries) {
|
||||
for (final GlEntry e : entries) {
|
||||
if (e.txnId != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
|
||||
e.txnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "—".
|
||||
@@ -1442,9 +1608,15 @@ public class ClerkDisbursementRequestApiResource {
|
||||
*/
|
||||
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
||||
final List<GlEntry> entries, final int typeEnum) {
|
||||
// Take only the most recently posted `entries.size()` rows for this transaction_id text,
|
||||
// then restore ascending id order. If the id text is ever reused (e.g. after a sequence
|
||||
// reset), this keeps the positional zip below scoped to the rows this call itself just
|
||||
// posted, instead of silently pulling in an older, unrelated combined entry's rows.
|
||||
final List<Long> rowIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
|
||||
Long.class, glTxnId, typeEnum);
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT ?",
|
||||
Long.class, glTxnId, typeEnum, entries.size());
|
||||
java.util.Collections.reverse(rowIds);
|
||||
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
||||
final Long savingsAccId = entries.get(i).savingsAccountId;
|
||||
if (savingsAccId != null) {
|
||||
@@ -1545,7 +1717,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT apm.gl_account_id FROM m_current_account ca "
|
||||
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1",
|
||||
currentAccountId);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||
}
|
||||
|
||||
+41
-6
@@ -50,6 +50,8 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -797,20 +799,31 @@ public class ClerkClosureRequestApiResource {
|
||||
}
|
||||
try {
|
||||
final Map<String, Object> control = voucherService.savingsControlAccount(savingsAccountId);
|
||||
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||
final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name"))
|
||||
: (String) control.get("name");
|
||||
final String accountNo = (String) record.get("account_no");
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
|
||||
// This is the member's own savings account leg, not a GL posting -- debitGl(controlGlId,
|
||||
// ...) named the shared Savings Control GL account here, mismatched against the member's
|
||||
// own account number on the same line (the same bug class as the share-withdrawal voucher).
|
||||
lines.add(VoucherLine.debitSavings(savingsAccountId, memberSideName, accountNo, closingAmount));
|
||||
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
|
||||
if (roundingDirection > 0) {
|
||||
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
|
||||
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
|
||||
lines.add(VoucherLine.debitGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
|
||||
}
|
||||
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
|
||||
// The same Cash at Vault / Cash at Teller account postRoundingAdjustment() actually posts
|
||||
// to (resolveCashGlAccount()) — named and coded from the ledger rather than the fixed
|
||||
// "Cash in Hand" / no-code placeholder this used to show, which said nothing when a reader
|
||||
// wanted to confirm this voucher against the real entry.
|
||||
final Map<String, Object> cashGlForVoucher = resolveCashGlAccount();
|
||||
final Long cashGlIdForVoucher = ((Number) cashGlForVoucher.get("id")).longValue();
|
||||
lines.add(VoucherLine.creditGl(cashGlIdForVoucher, (String) cashGlForVoucher.get("name"), glCodeOf(cashGlIdForVoucher),
|
||||
cashierDisbursedAmount));
|
||||
if (roundingDirection < 0) {
|
||||
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
|
||||
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
|
||||
lines.add(VoucherLine.creditGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
|
||||
}
|
||||
|
||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
|
||||
@@ -845,7 +858,7 @@ public class ClerkClosureRequestApiResource {
|
||||
// Same business date as the closure this is adjusting, so the two never land on
|
||||
// different days in the day book.
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime();
|
||||
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CLOSURE_ROUNDING);
|
||||
|
||||
final Long cashGlId = ((Number) cashGl.get("id")).longValue();
|
||||
// type_enum: 2 = DEBIT, 1 = CREDIT.
|
||||
@@ -893,6 +906,28 @@ public class ClerkClosureRequestApiResource {
|
||||
* Falls back to that code where the product has no mapping yet — every product created before
|
||||
* the row existed is in that position, and a closure must not fail over a few paise.
|
||||
*/
|
||||
/** The GL account's own code, for a voucher's GL-side line — never the account/party this entry
|
||||
* happens to relate to. Null-safe so a possibly-missing id passes through. */
|
||||
private String glCodeOf(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** The GL account's own configured name, for the party label on a voucher's GL-side line — fixed
|
||||
* labels like "Round Off Account" stood in for whatever the product/tenant's own chart of
|
||||
* accounts actually maps the rounding/cash GL to, so a voucher read as posting against a party
|
||||
* that doesn't exist in the ledger whenever the real mapped account was named differently. */
|
||||
private String glNameOf(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return "GL Account";
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
|
||||
.findFirst().orElse("GL Account");
|
||||
}
|
||||
|
||||
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
|
||||
if (savingsAccountId != null) {
|
||||
final List<Long> mapped = jdbcTemplate.queryForList("""
|
||||
|
||||
+560
-158
@@ -38,18 +38,23 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.ShareAccountingSuppression;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -64,6 +69,8 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -72,9 +79,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
|
||||
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
|
||||
* server-side executes the real Fineract "redeemshares" command against the share account, or
|
||||
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
|
||||
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
|
||||
* redemption is a book-entry, not a cash handover).
|
||||
* rejects it. Settlement is always a SAVINGS book-transfer to the member's linked savings account —
|
||||
* approval alone is terminal, see {@link #creditLinkedSavingsAccount} — never a cash payout at the
|
||||
* teller counter (no cash denomination/disbursement leg; a share redemption is a book-entry, not a
|
||||
* cash handover).
|
||||
*/
|
||||
@Path("/v1/clerk/share-withdrawal-requests")
|
||||
@Component
|
||||
@@ -100,24 +108,28 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
|
||||
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
|
||||
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode",
|
||||
"settlement_gl_account_id" };
|
||||
|
||||
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
|
||||
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
|
||||
// pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
|
||||
// instead of silently posting its settlement with none at all.
|
||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
|
||||
|
||||
// ── Sundry pass-through for the redemption settlement ───────────────────
|
||||
// ── GL posting for the redemption and its settlement ────────────────────
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int CREDIT = 1;
|
||||
private static final int DEBIT = 2;
|
||||
|
||||
// m_savings_account.status_enum: 300 = active.
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
// acc_product_mapping.financial_account_type for share products.
|
||||
private static final int FA_SHARES_REFERENCE = 1;
|
||||
private static final int FA_SHARES_SUSPENSE = 2;
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
private static final int FA_SHARES_EQUITY = 4;
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
// What Fineract's own bridge prefixes a share transaction's journal entries with
|
||||
// (AccountingProcessorHelper.SHARE_TRANSACTION_IDENTIFIER). Reused so an entry this class posts
|
||||
// in the platform's place groups under the id everything else already looks for.
|
||||
private static final String SHARE_TXN_ID_PREFIX = "SH";
|
||||
|
||||
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
|
||||
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
||||
private static final int FA_SAVINGS_CONTROL = 2;
|
||||
@@ -142,6 +154,16 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
|
||||
// 'SAVINGS' (default, and what every request submitted before this field existed carries)
|
||||
// settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to
|
||||
// whichever GL account the clerk picked on the CR row instead (a settlement that never
|
||||
// touches a member's own account) — see the settlement branch in approve(). 'NONE' is not
|
||||
// accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own
|
||||
// chosen GL account turns out to be the redemption's own Sundry account.
|
||||
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
|
||||
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
|
||||
: settlementModeRaw.trim().toUpperCase();
|
||||
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
|
||||
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
|
||||
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
|
||||
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
|
||||
@@ -169,8 +191,31 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
if (meetingDate == null || meetingDate.isBlank()) {
|
||||
throw apiError(400, "Date of Meeting is required for a share withdrawal");
|
||||
}
|
||||
if (!"SAVINGS".equals(settlementMode) && !"GL".equals(settlementMode)) {
|
||||
throw apiError(400, "Settlement Mode must be 'SAVINGS' or 'GL'");
|
||||
}
|
||||
if ("GL".equals(settlementMode)) {
|
||||
if (settlementGlAccountId == null) {
|
||||
throw apiError(400, "Settlement GL Account is required for a GL settlement");
|
||||
}
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId);
|
||||
// A redemption that settles nowhere further credits the product's own Sundry GL --
|
||||
// picking that same account as the settlement target here would name Sundry as both the
|
||||
// source and the destination of the one entry, a self-cancelling posting with no real
|
||||
// movement that no real CBS ledger allows. Rather than reject this
|
||||
// outright, treat it as what the clerk actually means: "stop here, the value stays in
|
||||
// Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher
|
||||
// for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone.
|
||||
final Long productIdForSundryCheck = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?",
|
||||
Long.class, shareAccountId);
|
||||
final Long glSundryForCheck = resolveGl(productIdForSundryCheck, FA_SHARES_SUSPENSE);
|
||||
if (glSundryForCheck != null && glSundryForCheck.equals(settlementGlAccountId)) {
|
||||
settlementMode = "NONE";
|
||||
settlementGlAccountId = null;
|
||||
}
|
||||
}
|
||||
if (settlementPaymentTypeId == null) {
|
||||
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
|
||||
throw apiError(400, "Settlement Payment Type is required — pick how the settlement should be credited");
|
||||
}
|
||||
validateSettlementPaymentType(settlementPaymentTypeId);
|
||||
|
||||
@@ -180,21 +225,23 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
INSERT INTO m_clerk_share_withdrawal_request
|
||||
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
|
||||
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
|
||||
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot,
|
||||
settlement_mode, settlement_gl_account_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
|
||||
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
|
||||
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
|
||||
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot,
|
||||
settlementMode, settlementGlAccountId);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
|
||||
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
|
||||
* of what the clerk picked in the UI.
|
||||
* A SAVINGS or GL settlement is always a book-transfer — never a cash payout at the teller
|
||||
* counter — so cash-flagged payment types are rejected here regardless of what the clerk picked
|
||||
* in the UI.
|
||||
*/
|
||||
private void validateSettlementPaymentType(final Long paymentTypeId) {
|
||||
final Map<String, Object> paymentType = jdbcTemplate
|
||||
@@ -400,9 +447,15 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
|
||||
: null;
|
||||
}
|
||||
checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
// A GL settlement never touches a savings account, so this check is reported as
|
||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||
// savings is a perfectly valid GL-settlement case.
|
||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||
checks.add(glSettlementMode
|
||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
|
||||
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
|
||||
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
|
||||
@@ -478,19 +531,31 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
}
|
||||
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
|
||||
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
if (savingsProductId != null && settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
|
||||
SELECT gl.name AS glName FROM acc_product_mapping apm
|
||||
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
|
||||
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
|
||||
glMappingOk = mapping != null;
|
||||
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
|
||||
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
|
||||
// Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a
|
||||
// plain savings deposit command, which resolves its GL leg from the savings product's own
|
||||
// Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement
|
||||
// never goes near that mapping at all; it credits the clerk's chosen GL account directly,
|
||||
// so this check is reported not-applicable instead.
|
||||
if (glSettlementMode) {
|
||||
final Object glAccountIdObj = record.get("settlement_gl_account_id");
|
||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||
} else {
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
if (savingsProductId != null && settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
|
||||
SELECT gl.name AS glName FROM acc_product_mapping apm
|
||||
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
|
||||
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
|
||||
glMappingOk = mapping != null;
|
||||
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
|
||||
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
|
||||
}
|
||||
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
|
||||
}
|
||||
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
|
||||
|
||||
if (clientId != null) {
|
||||
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
|
||||
@@ -654,7 +719,13 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
// The command is run for what it does to the share account -- the holding it reduces and the
|
||||
// transaction it records -- not for its accounting. Left to itself it would credit the
|
||||
// product's fixed "Shares Reference" account, which is a stand-in for a counterparty it has
|
||||
// no way of knowing; this register does know it, because the clerk keyed it, and posts the
|
||||
// entry itself below against the account the money actually reached.
|
||||
final CommandProcessingResult result = ShareAccountingSuppression
|
||||
.withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
|
||||
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
|
||||
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
|
||||
@@ -662,30 +733,65 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||
|
||||
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
|
||||
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
|
||||
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
|
||||
// "give cash back over the counter" assumption breaks that the moment the settlement below
|
||||
// lands on a different GL than this leg did.
|
||||
if (shareTransactionId != null) {
|
||||
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
|
||||
currentUser.getId());
|
||||
// Settlement: within this SAME approval transaction, the redemption value is booked to
|
||||
// wherever the clerk said it should go. Where that destination is known here -- a linked
|
||||
// savings account, or a GL the clerk picked -- the native redeem posting's own Cr Shares
|
||||
// Reference (Cash at Vault) leg is redirected straight onto it below, so the ledger reads as
|
||||
// the single entry the clerk actually keyed: Dr Share Capital / Cr <destination>.
|
||||
final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS");
|
||||
final Settlement settlement;
|
||||
final GlSettlement glSettlement;
|
||||
final String settlementNote;
|
||||
// Where the redemption credit lands, and what to call it on the journal line. Resolved per
|
||||
// mode, then applied once below.
|
||||
final Long destinationGl;
|
||||
final String destinationNarration;
|
||||
if ("NONE".equals(settlementMode)) {
|
||||
// The clerk deliberately chose to settle nowhere further: Share Sundry is the
|
||||
// destination, not a staging post, and the value genuinely rests there until some later
|
||||
// entry moves it. raiseSundryOnlyVoucher() below gives that entry its own standalone
|
||||
// voucher instead of leaving it visible only as "Also posted" under some other voucher.
|
||||
settlement = null;
|
||||
glSettlement = null;
|
||||
final Map<String, Object> noneAcct = loadShareAccountGlContext(shareAccountId);
|
||||
destinationGl = requireShareGl(toLong(noneAcct.get("product_id")), FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
destinationNarration = "Share redemption held in Sundry pending settlement";
|
||||
settlementNote = "Redemption booked to Share Sundry — no further settlement leg.";
|
||||
} else if ("GL".equals(settlementMode)) {
|
||||
settlement = null;
|
||||
final Object glAccountIdObj = record.get("settlement_gl_account_id");
|
||||
final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null;
|
||||
glSettlement = resolveGlSettlement(shareTransactionId, settlementGlAccountId);
|
||||
destinationGl = glSettlement != null ? glSettlement.glAccountId() : null;
|
||||
destinationNarration = "Share redemption settled to GL account";
|
||||
settlementNote = glSettlement != null
|
||||
? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId())
|
||||
: "Share transaction not found — GL settlement could not be booked.";
|
||||
} else {
|
||||
glSettlement = null;
|
||||
settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber);
|
||||
destinationGl = settlement != null ? settlement.savingsControlGlId() : null;
|
||||
destinationNarration = "Share redemption settled to savings";
|
||||
settlementNote = settlement != null
|
||||
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
|
||||
: "No linked savings account on this share account — settlement must be completed manually.";
|
||||
}
|
||||
|
||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
|
||||
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
|
||||
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
|
||||
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
||||
? ((Number) storedSettlementPaymentTypeId).longValue()
|
||||
: null;
|
||||
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
|
||||
resolutionNumber, settlementPaymentTypeId);
|
||||
// One posting, not two: the native redeem's Cr Cash at Vault leg is replaced by a credit to
|
||||
// the destination itself, under the native posting's own transaction_id. The ledger then
|
||||
// holds exactly the entry the clerk keyed -- Dr Share Capital / Cr <destination> -- instead
|
||||
// of routing it through Sundry and back out again, which left two transaction groups and a
|
||||
// pass-through balance that always netted to zero.
|
||||
//
|
||||
// Where no destination could be resolved (no linked savings account, or the share
|
||||
// transaction could not be read back), the native Cr leg is left exactly as Fineract posted
|
||||
// it rather than being reversed with nothing put in its place, which would leave the Dr
|
||||
// Share Capital leg standing alone and the entry out of balance.
|
||||
if (shareTransactionId != null && destinationGl != null) {
|
||||
postRedemptionEntry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
|
||||
currentUser.getId(), destinationGl, destinationNarration);
|
||||
}
|
||||
|
||||
final String settlementNote = settlement != null
|
||||
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
|
||||
: "No linked savings account on this share account — settlement must be completed manually.";
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
|
||||
settlementNote);
|
||||
|
||||
@@ -696,7 +802,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
settlement_amount = ?, settlement_savings_transaction_id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
|
||||
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
|
||||
Timestamp.from(Instant.now()),
|
||||
settlement != null ? settlement.amount() : (glSettlement != null ? glSettlement.amount() : null),
|
||||
settlement != null ? settlement.savingsTransactionId() : null, id);
|
||||
if (approvedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
@@ -712,7 +819,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
|
||||
// redemption settled into a member's savings account left nothing on the voucher register to
|
||||
// read it back from. The GL entries were always written — it is the slip that was missing.
|
||||
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
|
||||
final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser)
|
||||
: "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareTransactionId, record, glSettlement, currentUser)
|
||||
: raiseShareWithdrawalVoucher(id, shareAccountId, shareTransactionId, record, settlement, currentUser);
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
@@ -723,7 +832,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
if (shareTxnId != null) {
|
||||
res.addProperty("shareTransactionId", shareTxnId);
|
||||
}
|
||||
if (settlement != null) {
|
||||
if (glSettlement != null) {
|
||||
res.addProperty("settlementAmount", glSettlement.amount());
|
||||
} else if (settlement != null) {
|
||||
res.addProperty("settlementAmount", settlement.amount());
|
||||
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
|
||||
} else {
|
||||
@@ -754,7 +865,11 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
|
||||
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
|
||||
.withJson(closePayload.toString()).build();
|
||||
commandsSourceWritePlatformService.logCommandSource(closeCommand);
|
||||
// Suppressed for the same reason as the redemption above: anything this posts would go
|
||||
// against the product's fixed "Shares Reference" account. There is nothing left for it to
|
||||
// post in any case -- this runs only once the holding has reached zero, so the redemption
|
||||
// that emptied it has already been booked, in full, against the account the money reached.
|
||||
ShareAccountingSuppression.withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(closeCommand));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -771,32 +886,34 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
* savings credit have all already succeeded in this transaction, and losing the slip is worth
|
||||
* less than rolling back a member's money.
|
||||
*/
|
||||
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
|
||||
final Settlement settlement, final AppUser currentUser) {
|
||||
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Long shareTransactionId,
|
||||
final Map<?, ?> record, final Settlement settlement, final AppUser currentUser) {
|
||||
|
||||
if (!voucherService.shouldRaiseVoucher() || settlement == null
|
||||
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
||||
shareAccountId);
|
||||
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||
final Long glShare = redeemDebitGlOf(shareTransactionId);
|
||||
if (glShare == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Long savingsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
|
||||
|
||||
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
|
||||
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
|
||||
final String savingsAccountNo = savingsAccountId == null ? null
|
||||
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
|
||||
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
|
||||
lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), settlement.amount()));
|
||||
// This leg is the member's own savings account receiving the redemption proceeds, not a
|
||||
// second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL
|
||||
// account here, mismatched against the member's own account number on the same line.
|
||||
lines.add(VoucherLine.creditSavings(savingsAccountId, memberName, savingsAccountNo, settlement.amount()));
|
||||
|
||||
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
|
||||
// savings account, which is the receipt series — the share account is the source, not a
|
||||
@@ -807,6 +924,14 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
|
||||
settlement.savingsTransactionId());
|
||||
}
|
||||
// Every other request type that raises a voucher stores its own voucher_no back on
|
||||
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
|
||||
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
|
||||
// invisible everywhere a register looks it up by its owning request, understating the
|
||||
// branch's Payment total and the Day Register's expected cash by exactly this amount.
|
||||
if (voucherNo != null) {
|
||||
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
|
||||
}
|
||||
return voucherNo;
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
|
||||
@@ -814,7 +939,177 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||
private record Settlement(BigDecimal amount, Long savingsTransactionId, Long savingsControlGlId) {}
|
||||
|
||||
private record GlSettlement(BigDecimal amount, Long glAccountId) {}
|
||||
|
||||
/**
|
||||
* The GL account the native redeem command debited — the share capital side of the redemption,
|
||||
* which is what a voucher's Dr line has to name now that the credit goes straight to its
|
||||
* destination and no Sundry leg stands between them.
|
||||
*
|
||||
* <p>
|
||||
* Read back from what was actually posted rather than resolved from a mapping constant: this
|
||||
* class has never posted that leg itself (only Fineract's own redeem command does), so reading it
|
||||
* back is correct whichever GL the product's Shares Equity mapping happens to point at.
|
||||
*
|
||||
* <p>
|
||||
* Scoped to the live Dr row for this share transaction, newest first: the "SH"+id transaction_id
|
||||
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
|
||||
* reset id sequence in a dev environment can violate.
|
||||
*/
|
||||
private Long redeemDebitGlOf(final Long shareTransactionId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT account_id FROM acc_gl_journal_entry
|
||||
WHERE share_transaction_id = ? AND type_enum = ? AND reversed = false
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", Long.class, shareTransactionId, DEBIT).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the amount to settle and names the GL account the clerk picked on the CR row of the
|
||||
* Fund Transfer — the GL counterpart to {@link #creditLinkedSavingsAccount} for a settlement that
|
||||
* never touches a member's own account (e.g. crediting a "DD Payable" holding account, so a
|
||||
* second, independent manual entry can later move the value out to the actual demand draft).
|
||||
*
|
||||
* <p>
|
||||
* Nothing is posted here. The caller redirects the native redeem posting's own credit leg onto
|
||||
* this account, so the redemption reads as the one entry the clerk keyed — Dr Share Capital / Cr
|
||||
* that GL — rather than as a Sundry hop that nets to zero.
|
||||
*
|
||||
* <p>
|
||||
* Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded —
|
||||
* the redemption value posts to the paisa, same as the linked-savings path.
|
||||
*/
|
||||
private GlSettlement resolveGlSettlement(final Long shareTransactionId, final Long settlementGlAccountId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
if (settlementGlAccountId == null) {
|
||||
throw apiError(409, "No settlement GL account was recorded on this request.");
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForList("""
|
||||
SELECT sat.amount AS settlementAmount FROM m_share_account_transactions sat WHERE sat.id = ?
|
||||
""", shareTransactionId).stream().findFirst().orElse(null);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return new GlSettlement((BigDecimal) row.get("settlementAmount"), settlementGlAccountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The voucher for an approved GL redemption: Dr Share Capital, Cr the clerk-chosen settlement
|
||||
* GL account — the same two accounts the redemption actually posted to, both named and coded
|
||||
* from the ledger since both are real GL accounts here (unlike the linked-savings path, there is
|
||||
* no member-account leg on this side at all).
|
||||
*/
|
||||
private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareTransactionId, final Map<?, ?> record,
|
||||
final GlSettlement glSettlement, final AppUser currentUser) {
|
||||
if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null
|
||||
|| glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final Long glShare = redeemDebitGlOf(shareTransactionId);
|
||||
final Long glTarget = glSettlement.glAccountId();
|
||||
if (glShare == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), glSettlement.amount()));
|
||||
lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount()));
|
||||
|
||||
// PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits
|
||||
// a member's own account -- the value is moving out of share capital toward an eventual
|
||||
// payout (e.g. a second, independent manual entry crediting a "DD Payable" account out to
|
||||
// the actual draft), which is the payment series, not a receipt.
|
||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
|
||||
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
// Every other request type that raises a voucher stores its own voucher_no back on
|
||||
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
|
||||
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
|
||||
// invisible everywhere a register looks it up by its owning request.
|
||||
if (voucherNo != null) {
|
||||
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
|
||||
}
|
||||
return voucherNo;
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not raise a GL voucher for share withdrawal request {}", requestId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The voucher for a request that settles nowhere further than leg 1 itself (settlement mode
|
||||
* 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #postRedemptionEntry} just
|
||||
* posted, given its own standalone voucher instead of only being visible as "Also posted" under
|
||||
* some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}).
|
||||
*
|
||||
* <p>
|
||||
* Read back from {@code acc_gl_journal_entry} by the native transaction's own transaction_id
|
||||
* (the same technique {@code relatedGlEntries()} uses) rather than re-resolving the Shares
|
||||
* Equity GL directly — there is no {@code FA_SHARES_EQUITY} constant in this class because that
|
||||
* leg has never been posted by this class itself, only by Fineract's own native redeem command;
|
||||
* reading back what was actually posted is correct regardless of which GL that turns out to be.
|
||||
*
|
||||
* <p>
|
||||
* Capped to the 2 most recently inserted matching rows (highest id), not every row under that
|
||||
* transaction_id: {@code "SH" + shareTransactionId} is only unique so long as
|
||||
* {@code m_share_account_transactions.id} never repeats, which a reset/reseeded id sequence in a
|
||||
* dev/test environment can violate — an older, unrelated redemption that happened to land on the
|
||||
* same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total
|
||||
* shown here even though only one pair was ever posted for this approval. This call runs
|
||||
* immediately after {@link #postRedemptionEntry} in the same approve(), so the highest-id pair
|
||||
* is always the one it just inserted.
|
||||
*/
|
||||
private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) {
|
||||
if (!voucherService.shouldRaiseVoucher() || shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final List<String> nativeTxnIds = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", String.class,
|
||||
shareTransactionId);
|
||||
if (nativeTxnIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT je.type_enum, je.amount, ga.id AS gl_id, ga.name AS gl_name, ga.gl_code
|
||||
FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id
|
||||
WHERE je.transaction_id = ? AND je.reversed = false
|
||||
ORDER BY je.id DESC
|
||||
LIMIT 2
|
||||
""", nativeTxnIds.get(0));
|
||||
if (rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// Restore Dr-then-Cr reading order after the DESC/LIMIT above.
|
||||
java.util.Collections.reverse(rows);
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Long glId = ((Number) r.get("gl_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
final boolean isDebit = ((Number) r.get("type_enum")).intValue() == DEBIT;
|
||||
lines.add(isDebit ? VoucherLine.debitGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount)
|
||||
: VoucherLine.creditGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount));
|
||||
}
|
||||
|
||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
|
||||
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
if (voucherNo != null) {
|
||||
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
|
||||
}
|
||||
return voucherNo;
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not raise a Sundry-only voucher for share withdrawal request {}", requestId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits the redemption value to the share account's linked savings account, if one exists.
|
||||
@@ -827,14 +1122,19 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
|
||||
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
|
||||
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
|
||||
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
|
||||
* to be left to the payment type's own mapping in the hope it would land on the same account
|
||||
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
|
||||
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
|
||||
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
|
||||
* <b>reversed in full</b>, and nothing is posted in its place here: the caller redirects the redemption's own
|
||||
* credit leg straight onto the Savings Control GL returned below, so the ledger carries one entry — Dr Share
|
||||
* Capital / Cr Savings Control — instead of a pair routed through Sundry and back out again.
|
||||
*
|
||||
* <p>
|
||||
* The debit side used to be left to the payment type's own Fund Source mapping in the hope it would land on the
|
||||
* account the redemption leg had credited, netting to zero; when that mapping pointed anywhere else (as it did the
|
||||
* day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually met, leaving
|
||||
* Sundry debited for money that was never credited to it. Naming the destination explicitly, and posting only
|
||||
* once, removes that class of mismatch entirely.
|
||||
*/
|
||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||
final String resolutionNumber) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -853,52 +1153,102 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
|
||||
|
||||
final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
|
||||
: jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
|
||||
SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
|
||||
payload.addProperty("transactionAmount", settlementAmount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", paymentTypeId);
|
||||
}
|
||||
payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
final Long savingsTransactionId = result.getResourceId();
|
||||
|
||||
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
|
||||
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
|
||||
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
|
||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
|
||||
|
||||
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
|
||||
// Office comes from the share account the redemption was posted against, not from a join
|
||||
// through the savings account's client -- a savings account can be held by a group, where
|
||||
// that join yields nothing and the insert below would fail on a null office.
|
||||
final Long officeId = toLong(loadShareAccountGlContext(shareAccountId).get("office_id"));
|
||||
if (officeId == null) {
|
||||
throw apiError(409, "The share account has no office on it, so the settlement transaction cannot be booked.");
|
||||
}
|
||||
|
||||
final Long savingsTransactionId = directCreditSavingsAccount(savingsAccountId, officeId, settlementAmount, requestedDateStr,
|
||||
resolutionNumber);
|
||||
|
||||
return new Settlement(settlementAmount, savingsTransactionId, glSavingsControl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits the member's savings account as a book transfer: the transaction row and the balance,
|
||||
* written directly, with no journal entry of its own.
|
||||
*
|
||||
* <p>
|
||||
* The obvious way to do this is Fineract's own deposit command, and that is what this used to
|
||||
* call. But that command posts its own Dr Cash at Vault / Cr Savings Control pair on the
|
||||
* assumption that notes came over the counter, which they did not — the money is moving out of
|
||||
* share capital inside one approval. Reversing that pair afterwards worked, yet left two dead
|
||||
* rows in the ledger for every redemption and a Cash at Vault leg that had to be explained to
|
||||
* anyone reading the table. Writing the transaction directly means those rows are never created
|
||||
* at all, and the redemption's own entry — Dr Share Capital / Cr Savings Control, posted by the
|
||||
* caller — is the only accounting the event produces.
|
||||
*
|
||||
* <p>
|
||||
* This is the same technique the disbursement register already uses for its own book transfers
|
||||
* ({@code ClerkDisbursementRequestApiResource.directCreditSavingsAccount()}), for the same
|
||||
* reason, and carries the same trade-off: the native command's interest recalculation, lien and
|
||||
* overdraft checks and business events are all skipped. That is acceptable here because a
|
||||
* redemption settlement is a plain credit of a known amount into an active account on the
|
||||
* approval date, which is exactly the case those extras do not apply to.
|
||||
*
|
||||
* <p>
|
||||
* The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
|
||||
* which is what this writes.
|
||||
*/
|
||||
private Long directCreditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
|
||||
final String requestedDateStr, final String resolutionNumber) {
|
||||
final Map<String, Object> acct = jdbcTemplate
|
||||
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (acct == null) {
|
||||
throw apiError(409, "The linked savings account " + savingsAccountId + " no longer exists.");
|
||||
}
|
||||
final int statusEnum = ((Number) acct.get("status_enum")).intValue();
|
||||
if (statusEnum != SAVINGS_STATUS_ACTIVE) {
|
||||
throw apiError(409, "The linked savings account is not active, so the redemption cannot be settled into it.");
|
||||
}
|
||||
final BigDecimal currentBalance = acct.get("account_balance_derived") != null
|
||||
? (BigDecimal) acct.get("account_balance_derived")
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(requestedDateStr.substring(0, 10));
|
||||
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
final long userId = context.authenticatedUser().getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHRDMSTL-" + System.nanoTime();
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share redemption settled to savings | " + accountNo);
|
||||
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share redemption settlement credited to savings | " + accountNo);
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(con -> {
|
||||
// transaction_type_enum 1 = deposit.
|
||||
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
|
||||
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
|
||||
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
|
||||
+ "running_balance_derived, is_lien_transaction, "
|
||||
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" });
|
||||
ps.setLong(1, savingsAccountId);
|
||||
ps.setLong(2, officeId);
|
||||
ps.setDate(3, sqlDate);
|
||||
ps.setBigDecimal(4, amount);
|
||||
ps.setDate(5, sqlDate);
|
||||
ps.setString(6, refNo);
|
||||
ps.setBigDecimal(7, currentBalance.add(amount));
|
||||
ps.setLong(8, userId);
|
||||
ps.setLong(9, userId);
|
||||
ps.setObject(10, now);
|
||||
ps.setObject(11, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
final long savingsTransactionId = Objects.requireNonNull(kh.getKey()).longValue();
|
||||
|
||||
return new Settlement(settlementAmount, savingsTransactionId);
|
||||
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
|
||||
+ "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
|
||||
|
||||
LOG.info("Share redemption settled into savings account {} as transaction {} (Resolution No. {})", savingsAccountId,
|
||||
savingsTransactionId, resolutionNumber);
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
||||
@@ -969,58 +1319,65 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sundry pass-through helpers ──────────────────────────────────────────
|
||||
// ── The redemption's own accounting ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
|
||||
* Posts the redemption as one balanced entry against the account the money actually reached.
|
||||
*
|
||||
* <p>
|
||||
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
|
||||
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
|
||||
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
|
||||
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
|
||||
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
|
||||
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
|
||||
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
|
||||
* the same way every other share-capital movement in this codebase does.
|
||||
* Share capital is debited and the destination credited, and that is the whole entry: Dr Share
|
||||
* Equity / Cr the linked savings account's control GL, or the GL the clerk picked, or Share Sundry
|
||||
* where the clerk chose to settle nowhere further. No "Shares Reference" account appears anywhere
|
||||
* in it. Fineract's own bridge would have credited that account instead — a placeholder for a
|
||||
* counterparty it cannot know — so the command that creates the share transaction runs inside
|
||||
* {@link ShareAccountingSuppression#withOwnShareEntries} and posts nothing of its own.
|
||||
*
|
||||
* <p>
|
||||
* A redemption charge, where the product has one, is income and is credited separately: share
|
||||
* capital carries the full amount plus the charge, the member receives the amount, and the
|
||||
* difference lands in Income from Fees. With no charge configured the third line is simply absent.
|
||||
*
|
||||
* <p>
|
||||
* The transaction_id is the one the native command would have used ({@code "SH" + id}), so the
|
||||
* entry groups and reconciles exactly as every other share posting in the platform does, and
|
||||
* anything reading a share transaction's entries by that id keeps working unchanged.
|
||||
*/
|
||||
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
|
||||
final long userId) {
|
||||
private void postRedemptionEntry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
|
||||
final long userId, final Long destinationGlId, final String narration) {
|
||||
final Map<String, Object> txn = jdbcTemplate
|
||||
.queryForList("SELECT amount, COALESCE(charge_amount, 0) AS charge_amount FROM m_share_account_transactions WHERE id = ?",
|
||||
shareTransactionId)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (txn == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal amount = (BigDecimal) txn.get("amount");
|
||||
final BigDecimal charge = (BigDecimal) txn.get("charge_amount");
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
|
||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
|
||||
final Map<String, Object> reversedLeg = jdbcTemplate
|
||||
.queryForList(
|
||||
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
|
||||
+ " AND type_enum = ? AND reversed = false",
|
||||
shareTransactionId, glReference, CREDIT)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (reversedLeg == null) {
|
||||
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
|
||||
// unexpected accounting configuration), so there is no leg here to reroute.
|
||||
return;
|
||||
}
|
||||
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
|
||||
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
|
||||
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
|
||||
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
|
||||
// instead of the two reading as the one balanced entry they actually are.
|
||||
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
|
||||
|
||||
jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
|
||||
+ " AND type_enum = ? AND reversed = false",
|
||||
shareTransactionId, glReference, CREDIT);
|
||||
final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Equity");
|
||||
final boolean hasCharge = charge != null && charge.compareTo(BigDecimal.ZERO) > 0;
|
||||
|
||||
final String nativeTxnId = SHARE_TXN_ID_PREFIX + shareTransactionId;
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share redemption held in Sundry pending settlement | " + accountNo);
|
||||
|
||||
insertEntry(officeId, glEquity, DEBIT, hasCharge ? amount.add(charge) : amount, nativeTxnId, entryDate, shareAccountId,
|
||||
shareTransactionId, userId, now, currencyCode, "Share redemption | " + accountNo);
|
||||
insertEntry(officeId, destinationGlId, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
|
||||
currencyCode, narration + " | " + accountNo);
|
||||
if (hasCharge) {
|
||||
final Long glFees = requireShareGl(productId, FA_INCOME_FROM_FEES, "Income from Fees");
|
||||
insertEntry(officeId, glFees, CREDIT, charge, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now,
|
||||
currencyCode, "Share redemption charge | " + accountNo);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
|
||||
@@ -1050,6 +1407,39 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/** The GL account's own code, for the {@code accountNo} printed on a voucher's GL-side line --
|
||||
* never the account/party this GL entry happens to relate to, which is what the "name" already
|
||||
* carries. Null-safe so a caller can pass a possibly-missing gl account id straight through. */
|
||||
private String glCodeOf(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
|
||||
} catch (final IncorrectResultSizeDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The GL account's own configured name (e.g. "SUNDRY CREDITORS A/C"), for the party label on a
|
||||
* voucher's GL-side line -- fixed labels like "Share Sundry" or "Cash in Hand" used to stand in
|
||||
* for whatever the tenant's own chart of accounts actually maps to (shares-suspense, cash at
|
||||
* vault), so a voucher read as crediting/debiting a party that doesn't exist in the ledger at all
|
||||
* whenever the real mapped account was named differently. Null-safe: a missing/unmapped account
|
||||
* falls back to a neutral label rather than one of those specific fixed names, which would be
|
||||
* actively wrong when reused for a different GL account (as glCodeOf's callers do). */
|
||||
private String glNameOf(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return "GL Account";
|
||||
}
|
||||
try {
|
||||
final String name = jdbcTemplate.queryForObject("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
|
||||
return name != null ? name : "GL Account";
|
||||
} catch (final IncorrectResultSizeDataAccessException e) {
|
||||
return "GL Account";
|
||||
}
|
||||
}
|
||||
|
||||
private Long savingsControlGlOf(final Long savingsAccountId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT apm.gl_account_id FROM m_savings_account sa
|
||||
@@ -1059,19 +1449,31 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual
|
||||
* entry normally would: these rows stand in for the ones the platform's own bridge would have
|
||||
* posted, so they have to be indistinguishable from them to everything that goes looking.
|
||||
*
|
||||
* <p>
|
||||
* The voucher builders here match on {@code share_transaction_id}; Fineract's own
|
||||
* {@code revertShareAccountJournalEntries} matches on {@code transaction_id} <i>and</i>
|
||||
* {@code entity_type_enum = SHARES}, and would silently skip a row missing that column, leaving
|
||||
* the accounting standing after the transaction behind it had been reverted.
|
||||
*/
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||
final String description) {
|
||||
final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId,
|
||||
final LocalDateTime now, final String currencyCode, final String description) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_id, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||
PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate),
|
||||
java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+56
-8
@@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
|
||||
String txnId = null;
|
||||
if (savingsAccountId != null) {
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
: executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
@@ -484,7 +484,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
|
||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||
// debited and the till that pays it out is credited.
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount);
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
@@ -735,28 +735,61 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
* Records the two sides of an approved withdrawal on its voucher.
|
||||
*
|
||||
* Money leaves the member and is paid out over the counter, so the member's account is debited
|
||||
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
|
||||
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
|
||||
* and the till is credited. The till side can't be resolved from config the way a Share or
|
||||
* Closure withdrawal's cash leg can: which GL Fineract's native withdrawal command actually
|
||||
* credits depends on the request's own payment type (Cash, Cheque, DD, ...) via its Fund Source
|
||||
* mapping, which this class does not re-derive. Reading back what was actually posted for this
|
||||
* transaction (see resolveTillGlFromSavingsTxn()) names it accurately for any payment type;
|
||||
* "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a current
|
||||
* account withdrawal, or a tenant with accounting off).
|
||||
*
|
||||
* A failure here does not fail the approval: the withdrawal is already posted and the member
|
||||
* already paid. The problem is logged, and the voucher keeps its number without sides.
|
||||
*/
|
||||
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
||||
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount,
|
||||
final String savingsTxnId) {
|
||||
final String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final Map<String, Object> tillGl = resolveTillGlFromSavingsTxn(savingsTxnId);
|
||||
voucherService.addLines(voucherNo,
|
||||
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
|
||||
(String) record.get("account_no"), amount),
|
||||
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
|
||||
tillGl == null ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
|
||||
: VoucherLine.creditGl(((Number) tillGl.get("id")).longValue(), (String) tillGl.get("name"),
|
||||
(String) tillGl.get("code"), amount)));
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The GL account Fineract's native withdrawal command actually credited for this savings
|
||||
* transaction — looked up by the {@code savings_transaction_id} FK on {@code
|
||||
* acc_gl_journal_entry} rather than assumed, since the real posting depends on the withdrawal's
|
||||
* own payment type. Returns null (falls back to a generic label) for a current-account
|
||||
* withdrawal, which has no savings transaction to match, or a tenant with accounting off. */
|
||||
private Map<String, Object> resolveTillGlFromSavingsTxn(final String savingsTxnId) {
|
||||
if (savingsTxnId == null) {
|
||||
return null;
|
||||
}
|
||||
final Long id;
|
||||
try {
|
||||
id = Long.valueOf(savingsTxnId);
|
||||
} catch (final NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT g.id, g.name, g.gl_code AS code FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account g ON g.id = je.account_id
|
||||
WHERE je.savings_transaction_id = ? AND je.type_enum = 1 AND je.reversed = false
|
||||
LIMIT 1
|
||||
""", id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
@@ -839,10 +872,25 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private boolean isCurrentAccountById(final Long accountId) {
|
||||
/**
|
||||
* 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 at once -- two unrelated accounts, possibly two different
|
||||
* clients, sharing nothing but a coincidental number. An id-only check here can send a real
|
||||
* withdrawal against a stranger's current account instead of the savings account the request
|
||||
* actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id
|
||||
* match is only trusted once the account_no on that current-account row confirms it. Pass null
|
||||
* to skip the check where the caller has no account_no to compare against.
|
||||
*/
|
||||
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
|
||||
try {
|
||||
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
|
||||
Integer.class, accountId, expectedAccountNo);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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.client.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
|
||||
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/risk-review/behaviour-windows")
|
||||
@Component
|
||||
@Tag(name = "Client Behaviour Windows", description = "What each customer's money did over the review period, and the rating that suggests.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientBehaviourWindowApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientBehaviourWindowReadPlatformService readPlatformService;
|
||||
private final ToApiJsonSerializer<ClientBehaviourWindowData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "The latest behaviour window for each customer",
|
||||
description = "One row per customer, carrying the period's figures and the rating the policy suggests from them. Computed nightly; the screen shows what the suggestion was drawn from rather than recomputing it.")
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser();
|
||||
final List<ClientBehaviourWindowData> windows = this.readPlatformService.retrieveLatestPerClient();
|
||||
return this.toApiJsonSerializer.serialize(windows);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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.client.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* One customer's review period as the Risk Review screen shows it.
|
||||
*
|
||||
* <p>
|
||||
* The figures travel with the suggestion rather than being recomputed by the screen. A recommendation an officer is
|
||||
* asked to approve has to show what it was drawn from - months later, the same row must still explain itself - and a
|
||||
* second calculation in the browser could disagree with the one the policy actually used.
|
||||
*/
|
||||
public record ClientBehaviourWindowData(Long clientId, String clientName, String accountNo, String officeName,
|
||||
LocalDate windowFrom, LocalDate windowTo, Integer txnCount, Integer distinctMonths, BigDecimal creditTotal,
|
||||
BigDecimal debitTotal, BigDecimal largestTxn, Integer cashTxnCount, BigDecimal cashTotal, Integer largeTxnCount,
|
||||
BigDecimal declaredAnnualIncome, BigDecimal incomeRatio, Integer alertCount, Integer strCaseCount,
|
||||
Integer riskCategoryAtReview, String suggestedDirection, Integer suggestedCategory, String suggestionReason)
|
||||
implements Serializable {
|
||||
}
|
||||
+20
-2
@@ -97,6 +97,20 @@ public final class ClientKycApiConstants {
|
||||
public static final String form15ApplicableParam = "form15Applicable";
|
||||
public static final String form15TypeParam = "form15Type";
|
||||
public static final String form15FYParam = "form15FY";
|
||||
/**
|
||||
* Customer facts the risk re-rating policy tests against.
|
||||
*
|
||||
* <p>
|
||||
* A politically exposed person is not downgraded because their activity looks ordinary - that is precisely the
|
||||
* risk - and the same holds for a sanctions or adverse-media match. The declared figures are what "turnover out of
|
||||
* keeping with the customer's stated income" is measured against.
|
||||
*/
|
||||
public static final String isPepParam = "isPep";
|
||||
public static final String sanctionsMatchParam = "sanctionsMatch";
|
||||
public static final String adverseMediaMatchParam = "adverseMediaMatch";
|
||||
public static final String declaredAnnualIncomeParam = "declaredAnnualIncome";
|
||||
public static final String declaredAnnualTurnoverParam = "declaredAnnualTurnover";
|
||||
|
||||
public static final String isExceptionParam = "isException";
|
||||
public static final String exceptionReasonParam = "exceptionReason";
|
||||
public static final String exceptionResolvedParam = "exceptionResolved";
|
||||
@@ -117,7 +131,9 @@ public final class ClientKycApiConstants {
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
|
||||
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
|
||||
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
||||
|
||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
@@ -133,7 +149,9 @@ public final class ClientKycApiConstants {
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
|
||||
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
|
||||
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
||||
|
||||
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
+7
@@ -122,6 +122,13 @@ public final class ClientKycData {
|
||||
private final String form15Type;
|
||||
private final String form15Fy;
|
||||
|
||||
// ── Risk profile ─────────────────────────────────────────────────────────
|
||||
private final Boolean isPep;
|
||||
private final Boolean sanctionsMatch;
|
||||
private final Boolean adverseMediaMatch;
|
||||
private final java.math.BigDecimal declaredAnnualIncome;
|
||||
private final java.math.BigDecimal declaredAnnualTurnover;
|
||||
|
||||
// ── CKYCRR ────────────────────────────────────────────────────────────────
|
||||
private final String ckycNumber;
|
||||
private final LocalDate ckycUploadedOn;
|
||||
|
||||
+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.client.jobs.clientbehaviourwindow;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class ClientBehaviourWindowJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private ClientBehaviourWindowService clientBehaviourWindowService;
|
||||
|
||||
@Bean
|
||||
public ClientBehaviourWindowTasklet clientBehaviourWindowTasklet() {
|
||||
return new ClientBehaviourWindowTasklet(clientBehaviourWindowService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step clientBehaviourWindowStep() {
|
||||
return new StepBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository)
|
||||
.tasklet(clientBehaviourWindowTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job clientBehaviourWindowJob() {
|
||||
return new JobBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository).start(clientBehaviourWindowStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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.client.jobs.clientbehaviourwindow;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Recomputes every customer's behaviour window and the rating each one suggests.
|
||||
*
|
||||
* <p>
|
||||
* The job only ever writes a suggestion. Moving a rating remains a decision an officer makes and a checker approves,
|
||||
* which is what a re-rating is: a judgement about a customer, recorded by someone who is accountable for it.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ClientBehaviourWindowTasklet implements Tasklet {
|
||||
|
||||
private final ClientBehaviourWindowService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
service.computeForAllClients();
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Serves the most recent behaviour window for each customer, with the customer resolved.
|
||||
*
|
||||
* <p>
|
||||
* Only the latest window per customer: the table keeps each night's computation, and a review screen showing a
|
||||
* customer once per night would be unreadable within a week.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ClientBehaviourWindowReadPlatformService {
|
||||
|
||||
private static final String SQL = """
|
||||
SELECT w.client_id, c.display_name AS client_name, c.account_no, o.name AS office_name,
|
||||
w.window_from, w.window_to, w.txn_count, w.distinct_months, w.credit_total, w.debit_total, w.largest_txn,
|
||||
w.cash_txn_count, w.cash_total, w.large_txn_count, w.declared_annual_income, w.income_ratio,
|
||||
w.alert_count, w.str_case_count, w.risk_category_at_review,
|
||||
w.suggested_direction, w.suggested_category, w.suggestion_reason
|
||||
FROM m_client_behaviour_window w
|
||||
JOIN m_client c ON c.id = w.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
-- The latest window per customer. DISTINCT ON keeps the first row of each client group, and the ORDER BY
|
||||
-- is what makes that the newest one.
|
||||
WHERE w.id IN (SELECT DISTINCT ON (client_id) id FROM m_client_behaviour_window
|
||||
ORDER BY client_id, window_to DESC, computed_on DESC, id DESC)
|
||||
-- A window describes the rating the customer held when the job ran. Once an officer moves that rating
|
||||
-- the window is describing a customer who no longer exists, and serving it would have the screen go on
|
||||
-- asking for a change that has already been applied and approved. Suppressed until tonight's run
|
||||
-- recomputes it against the rating now on file.
|
||||
AND (w.risk_category_at_review IS NULL OR k.risk_category IS NULL
|
||||
OR w.risk_category_at_review = k.risk_category)
|
||||
ORDER BY CASE w.suggested_direction WHEN 'UP' THEN 0 WHEN 'DOWN' THEN 1 ELSE 2 END,
|
||||
c.display_name
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public List<ClientBehaviourWindowData> retrieveLatestPerClient() {
|
||||
return jdbcTemplate.query(SQL, (rs, rowNum) -> new ClientBehaviourWindowData(rs.getLong("client_id"),
|
||||
rs.getString("client_name"), rs.getString("account_no"), rs.getString("office_name"),
|
||||
rs.getDate("window_from") == null ? null : rs.getDate("window_from").toLocalDate(),
|
||||
rs.getDate("window_to") == null ? null : rs.getDate("window_to").toLocalDate(),
|
||||
(Integer) rs.getObject("txn_count"), (Integer) rs.getObject("distinct_months"), rs.getBigDecimal("credit_total"),
|
||||
rs.getBigDecimal("debit_total"),
|
||||
rs.getBigDecimal("largest_txn"), (Integer) rs.getObject("cash_txn_count"), rs.getBigDecimal("cash_total"),
|
||||
(Integer) rs.getObject("large_txn_count"), rs.getBigDecimal("declared_annual_income"),
|
||||
rs.getBigDecimal("income_ratio"), (Integer) rs.getObject("alert_count"),
|
||||
(Integer) rs.getObject("str_case_count"), (Integer) rs.getObject("risk_category_at_review"),
|
||||
rs.getString("suggested_direction"), (Integer) rs.getObject("suggested_category"),
|
||||
rs.getString("suggestion_reason")));
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindow;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindowRepository;
|
||||
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.BehaviourFacts;
|
||||
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.Recommendation;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Computes what each customer's money did over a period, and asks the policy what that means for their rating.
|
||||
*
|
||||
* <p>
|
||||
* Run nightly. The figures are aggregated in the database rather than by loading each customer's transactions into
|
||||
* memory: a book of any size would otherwise read every transaction row it has ever written, once per customer, every
|
||||
* night.
|
||||
*
|
||||
* <p>
|
||||
* Two windows are computed per customer, not one. An upgrade asks what the customer is doing now and reads a short
|
||||
* window; a downgrade asks whether a year of conduct supports removing a rating. Computing only the longer window
|
||||
* would delay every upgrade by months, and only the shorter would lower ratings on a quiet fortnight.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ClientBehaviourWindowService {
|
||||
|
||||
/**
|
||||
* One customer's aggregated activity for one window.
|
||||
*
|
||||
* <p>
|
||||
* Every count and total is computed in SQL; nothing here re-reads transactions.
|
||||
*/
|
||||
private record Aggregate(Long clientId, Integer currentCategory, int txnCount, int distinctMonths, BigDecimal creditTotal,
|
||||
BigDecimal debitTotal, BigDecimal largestTxn, int cashTxnCount, BigDecimal cashTotal, int largeTxnCount,
|
||||
int ctrTxnCount, int mediumAlertCount, int highAlertCount, int openStrCount, int strInBlockWindow, boolean pep,
|
||||
boolean sanctionsMatch, boolean adverseMedia, boolean graveRuleFired, boolean kycOverdue,
|
||||
Integer daysInCurrentCategory, BigDecimal declaredIncome, BigDecimal declaredTurnover) {}
|
||||
|
||||
/**
|
||||
* Everything the policy tests, for every active customer, in one pass.
|
||||
*
|
||||
* <p>
|
||||
* Each subquery is scoped to the same window so the figures describe one period. Alerts are counted by their worst
|
||||
* matched severity rather than by the rule the alert was raised under, because a statutory CTR outranks a
|
||||
* behavioural rule when the governing rule is picked - counting the governing rule would lose the high-severity
|
||||
* finding underneath it.
|
||||
*/
|
||||
private static final String AGGREGATE_SQL = """
|
||||
SELECT c.id AS client_id,
|
||||
k.risk_category AS current_category,
|
||||
COALESCE(t.txn_count, 0) AS txn_count,
|
||||
COALESCE(t.distinct_months, 0) AS distinct_months,
|
||||
COALESCE(t.credit_total, 0) AS credit_total,
|
||||
COALESCE(t.debit_total, 0) AS debit_total,
|
||||
COALESCE(t.largest_txn, 0) AS largest_txn,
|
||||
COALESCE(t.cash_txn_count, 0) AS cash_txn_count,
|
||||
COALESCE(t.cash_total, 0) AS cash_total,
|
||||
COALESCE(t.large_txn_count, 0) AS large_txn_count,
|
||||
COALESCE(t.ctr_txn_count, 0) AS ctr_txn_count,
|
||||
COALESCE(a.medium_alerts, 0) AS medium_alerts,
|
||||
COALESCE(a.high_alerts, 0) AS high_alerts,
|
||||
COALESCE(a.grave_rule, false) AS grave_rule,
|
||||
COALESCE(s.open_str, 0) AS open_str,
|
||||
COALESCE(s.str_in_block_window, 0) AS str_in_block_window,
|
||||
COALESCE(k.is_pep, false) AS pep,
|
||||
COALESCE(k.sanctions_match, false) AS sanctions_match,
|
||||
COALESCE(k.adverse_media_match, false) AS adverse_media,
|
||||
CASE WHEN k.expiry_date IS NOT NULL AND k.expiry_date < ? THEN true ELSE false END AS kyc_overdue,
|
||||
h.days_in_category,
|
||||
k.declared_annual_income,
|
||||
k.declared_annual_turnover
|
||||
FROM m_client c
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
-- Savings activity in the window, by client.
|
||||
LEFT JOIN (
|
||||
SELECT sa.client_id,
|
||||
count(*) AS txn_count,
|
||||
count(DISTINCT to_char(tx.transaction_date, 'YYYY-MM')) AS distinct_months,
|
||||
sum(CASE WHEN tx.transaction_type_enum = 1 THEN tx.amount ELSE 0 END) AS credit_total,
|
||||
sum(CASE WHEN tx.transaction_type_enum = 2 THEN tx.amount ELSE 0 END) AS debit_total,
|
||||
max(tx.amount) AS largest_txn,
|
||||
count(*) FILTER (WHERE pt.is_cash_payment) AS cash_txn_count,
|
||||
COALESCE(sum(tx.amount) FILTER (WHERE pt.is_cash_payment), 0) AS cash_total,
|
||||
count(*) FILTER (WHERE tx.amount >= ?) AS large_txn_count,
|
||||
count(*) FILTER (WHERE pt.is_cash_payment AND tx.amount >= ?) AS ctr_txn_count
|
||||
FROM m_savings_account_transaction tx
|
||||
JOIN m_savings_account sa ON sa.id = tx.savings_account_id
|
||||
LEFT JOIN m_payment_detail pd ON pd.id = tx.payment_detail_id
|
||||
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE tx.is_reversed = false
|
||||
AND tx.transaction_type_enum IN (1, 2)
|
||||
AND tx.transaction_date BETWEEN ? AND ?
|
||||
GROUP BY sa.client_id
|
||||
) t ON t.client_id = c.id
|
||||
-- Alerts in the window, graded by the worst rule each matched.
|
||||
LEFT JOIN (
|
||||
SELECT al.client_id,
|
||||
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 2) AS medium_alerts,
|
||||
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 3) AS high_alerts,
|
||||
bool_or(ar.rule_code IS NOT NULL) AS grave_rule
|
||||
FROM m_aml_alert al
|
||||
LEFT JOIN m_aml_alert_rule ar
|
||||
ON ar.alert_id = al.id
|
||||
AND (ar.rule_code LIKE 'STRUCTUR%' OR ar.rule_code LIKE 'PEP%'
|
||||
OR ar.rule_code LIKE 'SANCTION%' OR ar.rule_code LIKE 'ADVERSE_MEDIA%'
|
||||
OR ar.rule_code LIKE 'HIGH_RISK_COUNTRY%' OR ar.rule_code LIKE 'WILFUL_DEFAULTER%')
|
||||
WHERE al.raised_on >= ? AND al.raised_on < ?
|
||||
GROUP BY al.client_id
|
||||
) a ON a.client_id = c.id
|
||||
-- STR cases: open ones hold a rating at High; recent concluded ones block a downgrade.
|
||||
LEFT JOIN (
|
||||
SELECT sc.client_id,
|
||||
count(*) FILTER (WHERE sc.status = 'OPEN') AS open_str,
|
||||
count(*) FILTER (WHERE sc.created_on_utc >= ?) AS str_in_block_window
|
||||
FROM m_aml_str_case sc
|
||||
GROUP BY sc.client_id
|
||||
) s ON s.client_id = c.id
|
||||
-- How long the customer has held the rating they are on, from the last recorded change.
|
||||
LEFT JOIN (
|
||||
SELECT client_id, (? - max(changed_on)::date) AS days_in_category
|
||||
FROM m_client_risk_category_history
|
||||
GROUP BY client_id
|
||||
) h ON h.client_id = c.id
|
||||
WHERE c.status_enum = 300
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ClientBehaviourWindowRepository windowRepository;
|
||||
private final RiskMovementPolicyService policyService;
|
||||
|
||||
/** ₹10,00,000 - the CTR line, and the figure "large transaction" means here. */
|
||||
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
|
||||
|
||||
/**
|
||||
* Computes tonight's windows for every active customer.
|
||||
*
|
||||
* <p>
|
||||
* The upgrade window is written for every customer; the downgrade window only for those rated Medium or High,
|
||||
* since there is nothing below Low to move to. The suggestion is taken from whichever window the policy acted on,
|
||||
* so the figures shown beside a recommendation are the ones it was drawn from.
|
||||
*/
|
||||
@Transactional
|
||||
public int computeForAllClients() {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
windowRepository.deleteByWindowTo(today);
|
||||
|
||||
final int upgradeDays = policyService.windowDaysFor("UPGRADE_WINDOW_DAYS", 90);
|
||||
final int highToMediumDays = policyService.windowDaysFor("HIGH_TO_MEDIUM_WINDOW_DAYS", 365);
|
||||
final int mediumToLowDays = policyService.windowDaysFor("MEDIUM_TO_LOW_WINDOW_DAYS", 270);
|
||||
final int strBlockMonths = policyService.windowDaysFor("STR_BLOCK_MONTHS", 24);
|
||||
|
||||
final List<Aggregate> shortWindow = aggregate(today.minusDays(upgradeDays), today, strBlockMonths);
|
||||
|
||||
int written = 0;
|
||||
for (final Aggregate shortAgg : shortWindow) {
|
||||
final Integer category = shortAgg.currentCategory();
|
||||
// A downgrade is judged over the window that band requires; Low has nothing below it.
|
||||
final int longDays = category != null && category == RiskMovementPolicyService.HIGH ? highToMediumDays : mediumToLowDays;
|
||||
final Aggregate longAgg = category != null && category > RiskMovementPolicyService.LOW
|
||||
? one(today.minusDays(longDays), today, strBlockMonths, shortAgg.clientId())
|
||||
: null;
|
||||
|
||||
final Recommendation shortResult = policyService.evaluate(factsOf(shortAgg));
|
||||
// The short window decides upgrades and the standing facts; anything else is a question for the long one.
|
||||
final boolean actedOnShort = RiskMovementPolicyService.UP.equals(shortResult.direction()) || longAgg == null;
|
||||
final Aggregate acted = actedOnShort ? shortAgg : longAgg;
|
||||
final Recommendation result = actedOnShort ? shortResult : policyService.evaluate(factsOf(longAgg));
|
||||
|
||||
final LocalDate from = actedOnShort ? today.minusDays(upgradeDays) : today.minusDays(longDays);
|
||||
final ClientBehaviourWindow window = ClientBehaviourWindow.of(acted.clientId(), from, today);
|
||||
window.recordActivity(acted.txnCount(), acted.creditTotal(), acted.debitTotal(), acted.largestTxn(),
|
||||
acted.cashTxnCount(), acted.cashTotal(), acted.largeTxnCount());
|
||||
window.recordDistinctMonths(acted.distinctMonths());
|
||||
window.recordMonitoring(acted.mediumAlertCount(), acted.openStrCount(), acted.currentCategory());
|
||||
window.recordDeclared(acted.declaredIncome(), acted.declaredTurnover(),
|
||||
acted.creditTotal() == null ? null : acted.creditTotal().add(acted.debitTotal()));
|
||||
window.recordSuggestion(result.direction(), result.suggestedCategory(), result.reason());
|
||||
windowRepository.save(window);
|
||||
written++;
|
||||
}
|
||||
log.info("Client behaviour windows computed for {} clients as at {}", written, today);
|
||||
return written;
|
||||
}
|
||||
|
||||
private BehaviourFacts factsOf(final Aggregate a) {
|
||||
final BigDecimal periodTotal = a.creditTotal().add(a.debitTotal());
|
||||
return new BehaviourFacts(a.currentCategory(), a.txnCount(), a.distinctMonths(), a.cashTotal(), periodTotal,
|
||||
a.largestTxn(), a.ctrTxnCount(), a.mediumAlertCount(), a.highAlertCount(), a.openStrCount(),
|
||||
a.strInBlockWindow(), a.pep(), a.sanctionsMatch(), a.adverseMedia(), a.graveRuleFired(), a.kycOverdue(),
|
||||
a.daysInCurrentCategory());
|
||||
}
|
||||
|
||||
private List<Aggregate> aggregate(final LocalDate from, final LocalDate to, final int strBlockMonths) {
|
||||
return query(AGGREGATE_SQL, from, to, strBlockMonths, null);
|
||||
}
|
||||
|
||||
private Aggregate one(final LocalDate from, final LocalDate to, final int strBlockMonths, final Long clientId) {
|
||||
final List<Aggregate> rows = query(AGGREGATE_SQL + " AND c.id = ? ", from, to, strBlockMonths, clientId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private List<Aggregate> query(final String sql, final LocalDate from, final LocalDate to, final int strBlockMonths,
|
||||
final Long clientId) {
|
||||
final List<Object> args = new ArrayList<>(List.of(to, CTR_THRESHOLD, CTR_THRESHOLD, from, to, from.atStartOfDay(),
|
||||
to.plusDays(1).atStartOfDay(), to.minusMonths(strBlockMonths).atStartOfDay(), to));
|
||||
if (clientId != null) {
|
||||
args.add(clientId);
|
||||
}
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new Aggregate(rs.getLong("client_id"),
|
||||
rs.getObject("current_category") == null ? null : rs.getInt("current_category"), rs.getInt("txn_count"),
|
||||
rs.getInt("distinct_months"), rs.getBigDecimal("credit_total"), rs.getBigDecimal("debit_total"),
|
||||
rs.getBigDecimal("largest_txn"), rs.getInt("cash_txn_count"), rs.getBigDecimal("cash_total"),
|
||||
rs.getInt("large_txn_count"), rs.getInt("ctr_txn_count"), rs.getInt("medium_alerts"), rs.getInt("high_alerts"),
|
||||
rs.getInt("open_str"), rs.getInt("str_in_block_window"), rs.getBoolean("pep"), rs.getBoolean("sanctions_match"),
|
||||
rs.getBoolean("adverse_media"), rs.getBoolean("grave_rule"), rs.getBoolean("kyc_overdue"),
|
||||
rs.getObject("days_in_category") == null ? null : rs.getInt("days_in_category"),
|
||||
rs.getBigDecimal("declared_annual_income"), rs.getBigDecimal("declared_annual_turnover")),
|
||||
args.toArray());
|
||||
}
|
||||
}
|
||||
+5
@@ -132,6 +132,11 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.form15Applicable(kyc.getForm15Applicable())
|
||||
.form15Type(kyc.getForm15Type())
|
||||
.form15Fy(kyc.getForm15Fy())
|
||||
.isPep(kyc.isPep())
|
||||
.sanctionsMatch(kyc.isSanctionsMatch())
|
||||
.adverseMediaMatch(kyc.isAdverseMediaMatch())
|
||||
.declaredAnnualIncome(kyc.getDeclaredAnnualIncome())
|
||||
.declaredAnnualTurnover(kyc.getDeclaredAnnualTurnover())
|
||||
.ckycNumber(kyc.getCkycNumber())
|
||||
.ckycUploadedOn(kyc.getCkycUploadedOn())
|
||||
.ckycBatchId(kyc.getCkycBatchId())
|
||||
|
||||
+55
-2
@@ -108,8 +108,20 @@ public class KycRestrictionGuard {
|
||||
checkKycExpiry(savingsAccountId, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same two restrictions for a current account, read off the current account and its own current product
|
||||
* (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's.
|
||||
*/
|
||||
public void checkCurrentAccount(final Long currentAccountId, final Operation operation) {
|
||||
checkCurrentAccountDormancy(currentAccountId, operation);
|
||||
enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation);
|
||||
}
|
||||
|
||||
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
|
||||
final Long daysOverdue = daysOverdue(savingsAccountId);
|
||||
enforceKycStage(daysOverdue(savingsAccountId), operation);
|
||||
}
|
||||
|
||||
private void enforceKycStage(final Long daysOverdue, final Operation operation) {
|
||||
if (daysOverdue == null) {
|
||||
return;
|
||||
}
|
||||
@@ -158,6 +170,28 @@ public class KycRestrictionGuard {
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
|
||||
""", savingsAccountId);
|
||||
refuseIfDormant(rows, operation, "savings");
|
||||
}
|
||||
|
||||
private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) {
|
||||
if (currentAccountId == null || operation == Operation.CREDIT) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT cp.days_to_dormancy,
|
||||
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
|
||||
- GREATEST(ca.activatedon_date,
|
||||
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
|
||||
WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL),
|
||||
ca.activatedon_date)) AS days_idle
|
||||
FROM m_current_account ca
|
||||
JOIN m_current_product cp ON cp.id = ca.product_id
|
||||
WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0
|
||||
""", currentAccountId);
|
||||
refuseIfDormant(rows, operation, "current");
|
||||
}
|
||||
|
||||
private void refuseIfDormant(final List<Map<String, Object>> rows, final Operation operation, final String accountKind) {
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -168,11 +202,30 @@ public class KycRestrictionGuard {
|
||||
}
|
||||
final long years = threshold.longValue() / 365;
|
||||
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
|
||||
throw new PlatformDataIntegrityException("error.msg." + accountKind + ".account.dormant." + operation.name().toLowerCase(),
|
||||
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
|
||||
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
|
||||
}
|
||||
|
||||
private Long currentAccountDaysOverdue(final Long currentAccountId) {
|
||||
if (currentAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
|
||||
FROM m_current_account ca
|
||||
JOIN m_client_kyc k ON k.client_id = ca.client_id
|
||||
WHERE ca.id = ? AND k.expiry_date IS NOT NULL
|
||||
ORDER BY k.expiry_date DESC
|
||||
LIMIT 1
|
||||
""", currentAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
|
||||
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
|
||||
*
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameter;
|
||||
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Decides where a customer's rating should sit, given a period of their behaviour.
|
||||
*
|
||||
* <p>
|
||||
* Two principles shape every rule here, and they are not symmetric.
|
||||
*
|
||||
* <p>
|
||||
* <b>A rating never moves on one transaction.</b> Not up and not down. A single shop purchase does not argue for
|
||||
* lowering a High rating, and a single large deposit does not make an ordinary customer suspicious - it may be a
|
||||
* property sale. Both are alerts worth an officer's attention, and neither is a pattern. The minimum counts below are
|
||||
* the same test the transaction rules already apply to themselves.
|
||||
*
|
||||
* <p>
|
||||
* <b>Suspicion moves faster than confidence.</b> A concern is about what the customer is doing now, so upgrades read a
|
||||
* short window. Removing a High rating is a judgement that an earlier concern no longer holds, which needs a year of
|
||||
* evidence spread across months rather than a quiet fortnight. The one exception is the set of facts that are not
|
||||
* about behaviour at all - an STR, a sanctions hit, a PEP determination - which move a rating immediately because no
|
||||
* amount of ordinary transacting answers them.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class RiskMovementPolicyService {
|
||||
|
||||
public static final int LOW = 1;
|
||||
public static final int MEDIUM = 2;
|
||||
public static final int HIGH = 3;
|
||||
|
||||
public static final String UP = "UP";
|
||||
public static final String DOWN = "DOWN";
|
||||
public static final String HOLD = "HOLD";
|
||||
|
||||
private final RiskPolicyParameterRepository parameterRepository;
|
||||
|
||||
/** One customer's period, as the policy needs to see it. */
|
||||
public record BehaviourFacts(Integer currentCategory, int txnCount, int distinctMonths, BigDecimal cashTotal,
|
||||
BigDecimal periodTotal, BigDecimal largestTxn, int ctrTxnCount, int mediumAlertCount, int highAlertCount,
|
||||
int openStrCount, int strCountInBlockWindow, boolean pep, boolean sanctionsMatch, boolean adverseMedia,
|
||||
boolean graveRuleFired, boolean kycOverdue, Integer daysInCurrentCategory) {}
|
||||
|
||||
/** What the policy concluded, and the sentence an officer reads. */
|
||||
public record Recommendation(String direction, Integer suggestedCategory, String reason) {}
|
||||
|
||||
public Recommendation evaluate(final BehaviourFacts f) {
|
||||
final Map<String, BigDecimal> p = parameters();
|
||||
|
||||
// ── Facts that are not about behaviour ──────────────────────────────────────────────────
|
||||
// These move a rating on their own. No quantity of ordinary transacting answers a sanctions match, and an STR
|
||||
// is the bank's own statement that it found the customer's activity suspicious.
|
||||
if (f.sanctionsMatch()) {
|
||||
return high(f, "Sanctions or watch-list match — rating held at High irrespective of activity.");
|
||||
}
|
||||
if (f.pep()) {
|
||||
return high(f, "Politically exposed person — High is the standing rating, not a conclusion about this period.");
|
||||
}
|
||||
if (f.adverseMedia()) {
|
||||
return high(f, "Adverse media or regulatory action on record — rating held at High.");
|
||||
}
|
||||
if (f.openStrCount() > 0) {
|
||||
return high(f, "An STR case is open — rating held at High until the Principal Officer concludes it.");
|
||||
}
|
||||
|
||||
// ── Upgrades ────────────────────────────────────────────────────────────────────────────
|
||||
// Read before the downgrade tests: a customer whose activity has grown is not a candidate for lowering, and
|
||||
// saying so is more useful than "insufficient activity to lower".
|
||||
final Recommendation up = considerUpgrade(f, p);
|
||||
if (up != null) {
|
||||
return up;
|
||||
}
|
||||
|
||||
// ── Downgrades ──────────────────────────────────────────────────────────────────────────
|
||||
return considerDowngrade(f, p);
|
||||
}
|
||||
|
||||
private Recommendation considerUpgrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
|
||||
final int current = f.currentCategory() == null ? LOW : f.currentCategory();
|
||||
if (current >= HIGH) {
|
||||
return null;
|
||||
}
|
||||
// A pattern, even to raise a rating. One large transaction is an alert for an officer to look at, not grounds
|
||||
// for re-rating the customer who made it.
|
||||
if (f.txnCount() < intOf(p, "MIN_TXN_FOR_UPGRADE", 5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current == MEDIUM) {
|
||||
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
|
||||
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
|
||||
}
|
||||
if (f.graveRuleFired()) {
|
||||
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
|
||||
}
|
||||
if (atLeast(f.cashTotal(), p.get("MED_TO_HIGH_CASH_TOTAL"))) {
|
||||
return raise(HIGH, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Low, or unrated and treated as Low.
|
||||
if (f.graveRuleFired()) {
|
||||
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
|
||||
}
|
||||
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
|
||||
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
|
||||
}
|
||||
if (atLeast(f.cashTotal(), p.get("LOW_TO_MED_CASH_TOTAL"))) {
|
||||
return raise(MEDIUM, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
|
||||
}
|
||||
if (f.ctrTxnCount() >= intOf(p, "LOW_TO_MED_CTR_COUNT", 3)) {
|
||||
return raise(MEDIUM, "%d CTR-reportable transactions in the review window.".formatted(f.ctrTxnCount()));
|
||||
}
|
||||
if (f.mediumAlertCount() >= intOf(p, "LOW_TO_MED_ALERT_COUNT", 3)) {
|
||||
return raise(MEDIUM, "%d alerts of medium severity or worse in the review window.".formatted(f.mediumAlertCount()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Recommendation considerDowngrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
|
||||
if (f.currentCategory() == null) {
|
||||
// An unrated customer who is transacting is a gap in the book, not a quiet account. Saying only "no rating
|
||||
// on record" leaves an officer nothing to act on, so the size of what went through is named: an unrated
|
||||
// customer who has moved reportable sums needs rating now, not at the next periodic review.
|
||||
final boolean reportable = f.ctrTxnCount() > 0 || atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"));
|
||||
return new Recommendation(HOLD, null, reportable
|
||||
? "No rating on record, and %s passed through the account in the window — the customer must be rated before this activity can be judged."
|
||||
.formatted(inr(f.periodTotal()))
|
||||
: "No rating on record — the customer must be rated before behaviour can be compared against it.");
|
||||
}
|
||||
final int current = f.currentCategory();
|
||||
if (current <= LOW) {
|
||||
return hold(f, "Already at the lowest rating.");
|
||||
}
|
||||
|
||||
// Blocks. Each is a reason the period's figures cannot speak for the customer, so they are tested before the
|
||||
// figures are.
|
||||
if (f.strCountInBlockWindow() > 0) {
|
||||
return hold(f, "An STR was filed within the last %d months — rating held.".formatted(intOf(p, "STR_BLOCK_MONTHS", 24)));
|
||||
}
|
||||
if (f.graveRuleFired()) {
|
||||
return hold(f, "Structuring or an equivalent pattern on record — small amounts are not a reason to lower this.");
|
||||
}
|
||||
if (f.kycOverdue()) {
|
||||
return hold(f, "KYC is overdue or documents have expired — the rating cannot be lowered on an unverified record.");
|
||||
}
|
||||
|
||||
final boolean toMedium = current == HIGH;
|
||||
final int minTxn = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_TXN" : "MEDIUM_TO_LOW_MIN_TXN", toMedium ? 20 : 12);
|
||||
final int minMonths = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_MONTHS" : "MEDIUM_TO_LOW_MIN_MONTHS", toMedium ? 6 : 5);
|
||||
final int windowDays = intOf(p, toMedium ? "HIGH_TO_MEDIUM_WINDOW_DAYS" : "MEDIUM_TO_LOW_WINDOW_DAYS", toMedium ? 365 : 270);
|
||||
|
||||
if (f.txnCount() < minTxn) {
|
||||
return hold(f, "Only %d transactions in the window; %d are needed to lower a %s rating.".formatted(f.txnCount(), minTxn,
|
||||
label(current)));
|
||||
}
|
||||
// Spread as well as count: twenty transactions in one week is not a year of steady conduct.
|
||||
if (f.distinctMonths() < minMonths) {
|
||||
return hold(f, "Activity falls in %d months; %d separate months are needed.".formatted(f.distinctMonths(), minMonths));
|
||||
}
|
||||
if (f.daysInCurrentCategory() != null && f.daysInCurrentCategory() < windowDays) {
|
||||
return hold(f, "Only %d days at %s; %d are needed before the rating is reconsidered."
|
||||
.formatted(f.daysInCurrentCategory(), label(current), windowDays));
|
||||
}
|
||||
if (f.highAlertCount() > 0) {
|
||||
return hold(f, "%d high-severity alert(s) in the window — the period is not clean.".formatted(f.highAlertCount()));
|
||||
}
|
||||
|
||||
// Size. Volume alone holds a rating up: a customer moving large sums is not low-risk merely for doing it
|
||||
// without tripping a rule.
|
||||
if (atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"))) {
|
||||
return hold(f, "Turnover of %s in the window holds the rating where it is.".formatted(inr(f.periodTotal())));
|
||||
}
|
||||
if (!toMedium) {
|
||||
// Medium to Low is the stricter of the two: Low is the least-monitored band.
|
||||
if (!below(f.largestTxn(), p.get("SMALL_TXN_MAX")) || !below(f.periodTotal(), p.get("SMALL_TOTAL_MAX"))) {
|
||||
return hold(f, "Activity is larger than Low-rated behaviour — largest %s, %s in total."
|
||||
.formatted(inr(f.largestTxn()), inr(f.periodTotal())));
|
||||
}
|
||||
}
|
||||
|
||||
// One band at a time. High to Low in a single step would skip the year at Medium that is the evidence for it.
|
||||
final int suggested = current - 1;
|
||||
return new Recommendation(DOWN, suggested,
|
||||
"%d transactions across %d months, %s in total, no high-severity alerts — %s is proportionate."
|
||||
.formatted(f.txnCount(), f.distinctMonths(), inr(f.periodTotal()), label(suggested)));
|
||||
}
|
||||
|
||||
private Recommendation high(final BehaviourFacts f, final String reason) {
|
||||
final boolean alreadyHigh = f.currentCategory() != null && f.currentCategory() == HIGH;
|
||||
return new Recommendation(alreadyHigh ? HOLD : UP, HIGH, reason);
|
||||
}
|
||||
|
||||
private Recommendation raise(final int category, final String reason) {
|
||||
return new Recommendation(UP, category, reason);
|
||||
}
|
||||
|
||||
private Recommendation hold(final BehaviourFacts f, final String reason) {
|
||||
return new Recommendation(HOLD, f.currentCategory(), reason);
|
||||
}
|
||||
|
||||
private Map<String, BigDecimal> parameters() {
|
||||
return parameterRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(RiskPolicyParameter::getParameterKey, RiskPolicyParameter::getParameterValue, (a, b) -> a));
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter's value, or the seeded default.
|
||||
*
|
||||
* <p>
|
||||
* The fallback is not decoration: a tenant whose parameter table has not been seeded must still apply the policy,
|
||||
* rather than reading a missing row as zero and lowering every rating that asked for "at least 0 transactions".
|
||||
*/
|
||||
private int intOf(final Map<String, BigDecimal> p, final String key, final int fallback) {
|
||||
final BigDecimal value = p.get(key);
|
||||
return value == null ? fallback : value.intValue();
|
||||
}
|
||||
|
||||
private boolean atLeast(final BigDecimal value, final BigDecimal threshold) {
|
||||
return value != null && threshold != null && value.compareTo(threshold) >= 0;
|
||||
}
|
||||
|
||||
private boolean below(final BigDecimal value, final BigDecimal threshold) {
|
||||
return value != null && threshold != null && value.compareTo(threshold) < 0;
|
||||
}
|
||||
|
||||
private String label(final int category) {
|
||||
return switch (category) {
|
||||
case LOW -> "Low";
|
||||
case MEDIUM -> "Medium";
|
||||
case HIGH -> "High";
|
||||
default -> "Unrated";
|
||||
};
|
||||
}
|
||||
|
||||
private String inr(final BigDecimal amount) {
|
||||
return amount == null ? "nil" : "INR " + amount.setScale(0, java.math.RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
/** Exposed so the job can name the windows it must compute without repeating the keys. */
|
||||
public int windowDaysFor(final String key, final int fallback) {
|
||||
return intOf(parameters(), key, fallback);
|
||||
}
|
||||
|
||||
public List<String> downgradeWindowKeys() {
|
||||
return List.of("HIGH_TO_MEDIUM_WINDOW_DAYS", "MEDIUM_TO_LOW_WINDOW_DAYS");
|
||||
}
|
||||
}
|
||||
+38
-13
@@ -47,6 +47,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -89,6 +91,24 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Slf4j
|
||||
public class DividendClaimRequestApiResource {
|
||||
|
||||
/**
|
||||
* The next blank to draw a dividend draft on: the lowest unused number in the register.
|
||||
*
|
||||
* Ordered by the printed number, never by row id. Row id is insertion order, which matches the
|
||||
* series only for the first book registered — a later book allocates from the middle and hands
|
||||
* out numbers out of sequence, which is what makes an unused number in a run impossible to
|
||||
* account for. The numeric cast is guarded because a series need not be plain digits; a prefixed
|
||||
* number sorts as text after the numeric ones, matching how the leaflet register displays it.
|
||||
*
|
||||
* SKIP LOCKED so two concurrent claims take different leaflets rather than one waiting on the
|
||||
* other's transaction.
|
||||
*/
|
||||
private static final String LOWEST_AVAILABLE_LEAFLET_SQL = """
|
||||
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||
ORDER BY CASE WHEN dd_number ~ '^[0-9]+$' THEN CAST(dd_number AS BIGINT) END ASC NULLS LAST, dd_number ASC
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
@@ -189,11 +209,12 @@ public class DividendClaimRequestApiResource {
|
||||
.orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has "
|
||||
+ "just taken it. Go back and choose another leaflet."))
|
||||
// No leaflet named (a caller that is not the leaflets screen): fall back to the
|
||||
// oldest available, which is the order a series is meant to be consumed in.
|
||||
: jdbcTemplate.queryForList("""
|
||||
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
""").stream().findFirst().orElseThrow(() -> apiError(409,
|
||||
// lowest unused number, which is the order a series is meant to be consumed in.
|
||||
// Ordered by the printed number rather than by row id — id is insertion order, and
|
||||
// the two diverge as soon as a second book is registered, which hands out numbers
|
||||
// from the middle of a series instead of its start.
|
||||
: jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL)
|
||||
.stream().findFirst().orElseThrow(() -> apiError(409,
|
||||
"No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."));
|
||||
final Long leafletId = ((Number) leaflet.get("id")).longValue();
|
||||
final String ddNumber = (String) leaflet.get("dd_number");
|
||||
@@ -428,11 +449,9 @@ public class DividendClaimRequestApiResource {
|
||||
final Map<String, Object> record, final BigDecimal amount, final String currencyCode, final LocalDate today,
|
||||
final AppUser currentUser) {
|
||||
|
||||
// Oldest series first, and locked so two concurrent approvals cannot take the same leaflet.
|
||||
final Map<String, Object> leaflet = jdbcTemplate.queryForList("""
|
||||
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
""").stream().findFirst()
|
||||
// Lowest unused number first, and locked so two concurrent approvals cannot take the same
|
||||
// leaflet.
|
||||
final Map<String, Object> leaflet = jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL).stream().findFirst()
|
||||
.orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory."));
|
||||
final Long leafletId = ((Number) leaflet.get("id")).longValue();
|
||||
final String ddNumber = (String) leaflet.get("dd_number");
|
||||
@@ -520,7 +539,7 @@ public class DividendClaimRequestApiResource {
|
||||
final Long ddPayableAccountId = ddPayableAccount();
|
||||
|
||||
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
|
||||
final String transactionId = "DIVDD" + claimId;
|
||||
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLAIM);
|
||||
// Stamped with the financial year the dividend was DECLARED for, not the year it is being
|
||||
// claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot
|
||||
// say how much of each year's declaration has actually been claimed.
|
||||
@@ -571,12 +590,18 @@ public class DividendClaimRequestApiResource {
|
||||
final Map<String, Object> debitGl = glAccountForVoucher(debitGlId);
|
||||
final Map<String, Object> creditGl = glAccountForVoucher(creditGlId);
|
||||
|
||||
voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
|
||||
VoucherService.PREFIX_DEBIT,
|
||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId,
|
||||
currentUser, VoucherService.PREFIX_DEBIT,
|
||||
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
|
||||
.withDescription(description),
|
||||
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
||||
.withDescription(description)));
|
||||
// Every other maker-checker request that raises a voucher stores its own voucher_no back
|
||||
// on itself, which is how "My Voucher"/"My Work" finds it -- without this, the voucher
|
||||
// existed in m_voucher/m_voucher_line but was invisible to the manager who approved it.
|
||||
if (voucherNo != null) {
|
||||
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET voucher_no = ? WHERE id = ?", voucherNo, claimId);
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
|
||||
}
|
||||
|
||||
+22
-7
@@ -1111,19 +1111,34 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
||||
try {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Map<String, Object> portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
|
||||
final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
|
||||
|
||||
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
|
||||
final String accountNo = loan.getAccountNumber();
|
||||
final String partyName = party != null ? party : (String) portfolio.get("name");
|
||||
|
||||
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
|
||||
: VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
|
||||
// This is the borrower's own loan account leg, not a GL posting -- debitGl/creditGl(
|
||||
// portfolioGlId, ...) named the shared Loan Portfolio GL account here, mismatched against
|
||||
// this borrower's own loan account number on the same line.
|
||||
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitLoan(loan.getId(), partyName, accountNo, amount)
|
||||
: VoucherLine.creditLoan(loan.getId(), partyName, accountNo, amount);
|
||||
|
||||
// The till. The loan module does not resolve a GL for the cashier's drawer, so the line
|
||||
// names it without an id rather than guessing at one.
|
||||
final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
|
||||
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
|
||||
// The till. Which GL the native disbursement/repayment command actually posted here
|
||||
// depends on the transaction's own payment type (Cash, Cheque, DD, ...) via its Fund
|
||||
// Source mapping, so it's read back from what was actually posted rather than assumed --
|
||||
// "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a
|
||||
// non-cash payment type routed elsewhere, or accounting off for this tenant).
|
||||
final Map<String, Object> tillGl = this.voucherService.tillGlFromLoanTransaction(transactionId, isDisbursement);
|
||||
final VoucherLine cashSide;
|
||||
if (tillGl.isEmpty()) {
|
||||
cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
|
||||
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
|
||||
} else {
|
||||
final Long tillGlId = ((Number) tillGl.get("id")).longValue();
|
||||
final String tillGlName = (String) tillGl.get("name");
|
||||
final String tillGlCode = (String) tillGl.get("gl_code");
|
||||
cashSide = isDisbursement ? VoucherLine.creditGl(tillGlId, tillGlName, tillGlCode, amount)
|
||||
: VoucherLine.debitGl(tillGlId, tillGlName, tillGlCode, amount);
|
||||
}
|
||||
|
||||
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
|
||||
|
||||
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
/**
|
||||
* 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.ossreturn.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE).
|
||||
*
|
||||
* <h3>What these returns are</h3>
|
||||
*
|
||||
* OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and
|
||||
* loss account on the same footing. Both are quarterly, both are denominated in thousands of
|
||||
* rupees, and both are filed through RBI's CIMS portal.
|
||||
*
|
||||
* <h3>Where every figure comes from</h3>
|
||||
*
|
||||
* Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role
|
||||
* decide the figure, and the ledger decides its value:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Balance-sheet roles</b> (OSS 1) take the closing balance as at the quarter end - every
|
||||
* journal entry on or before that date, on the account's own natural side.</li>
|
||||
* <li><b>Earnings roles</b> (OSS 2) take the movement over the quarter, because an income or
|
||||
* expense line reports what was earned or spent in the period, not a balance carried forward.</li>
|
||||
* <li><b>Counts</b> (depositors, borrowers, members) come from the portfolio tables, since a
|
||||
* headcount has no ledger balance to read.</li>
|
||||
* </ul>
|
||||
*
|
||||
* The bank's own identity - name, RBI bank code, regional office, signatories - is read from
|
||||
* {@code m_institution_profile}, the single place the institution's particulars are maintained.
|
||||
* Nothing about the bank is written into this file.
|
||||
*
|
||||
* <h3>Blank is not zero</h3>
|
||||
*
|
||||
* A role with no accounts pinned to it reports {@code null} and says so in
|
||||
* {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the
|
||||
* bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and
|
||||
* the two must not be filed as though they were the same statement.
|
||||
*/
|
||||
@Path("/v1/ossreturn")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class OssReturnApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
|
||||
|
||||
/** Returns are denominated in thousands of rupees; the ledger is in rupees. */
|
||||
private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000);
|
||||
|
||||
/**
|
||||
* acc_gl_account.classification_enum. Only the debit-natured classifications are named, since
|
||||
* they are what decides which way round a balance is read; everything else is credit-natured.
|
||||
*/
|
||||
private static final int ASSET = 1;
|
||||
private static final int INCOME = 4;
|
||||
private static final int EXPENSE = 5;
|
||||
|
||||
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
|
||||
private static final int POSTABLE_ACCOUNT = 1;
|
||||
|
||||
/**
|
||||
* Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever
|
||||
* income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong.
|
||||
*/
|
||||
private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__";
|
||||
|
||||
/** m_savings_account.status_enum for an active account. */
|
||||
private static final int SAVINGS_ACTIVE = 300;
|
||||
|
||||
/** m_loan.loan_status_id for an active loan. */
|
||||
private static final int LOAN_ACTIVE = 300;
|
||||
|
||||
/**
|
||||
* m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head
|
||||
* covers both fixed and recurring deposits, so the two are counted together there.
|
||||
*/
|
||||
private static final int SAVINGS_DEPOSIT = 100;
|
||||
private static final List<Integer> TERM_DEPOSIT_TYPES = List.of(200, 300);
|
||||
|
||||
/** How RBI prints dates on the General Information sheet. */
|
||||
private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── OSS 1 — RAL ───────────────────────────
|
||||
|
||||
/**
|
||||
* OSS 1 - Return on Assets and Liabilities, return code R070.
|
||||
*
|
||||
* @param quarterEndDateParam
|
||||
* any date in the quarter being reported; the quarter end is derived from it, so a
|
||||
* caller never has to know which day a quarter ends on. Defaults to the current
|
||||
* business date.
|
||||
*/
|
||||
@GET
|
||||
@Path("oss1")
|
||||
public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
|
||||
final Quarter quarter = Quarter.containing(requested);
|
||||
|
||||
final Map<String, Object> response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter,
|
||||
officeId);
|
||||
response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note",
|
||||
"Balance-sheet figures are the closing position as at " + quarter.end
|
||||
+ "; every journal entry on or before that date is included."));
|
||||
|
||||
// Liabilities and assets, in the order the RBI workbook prints them. Each line names the
|
||||
// role whose mapped accounts produce it; a line with no role of its own is a subtotal and
|
||||
// is summed from the lines it covers, never mapped separately.
|
||||
final List<Map<String, Object>> liabilities = new ArrayList<>();
|
||||
final RoleFigures figures = new RoleFigures(quarter, officeId);
|
||||
|
||||
liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures));
|
||||
liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures));
|
||||
liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures));
|
||||
|
||||
// The year's own result, which the ledger has not appropriated to reserves yet. RBI prints
|
||||
// it as its own head for exactly that reason: between appropriations the balance sheet is
|
||||
// short by what the bank has earned or lost so far, and without this line the return would
|
||||
// not balance even though the books do. Derived from the income and expense accounts rather
|
||||
// than mapped, since it is not a balance any account carries.
|
||||
final BigDecimal currentYearResult = currentYearResult(quarter, officeId);
|
||||
liabilities.add(derivedLine(
|
||||
currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year"
|
||||
: "3 iv. Operating Deficit in Current year",
|
||||
CURRENT_YEAR_RESULT, currentYearResult));
|
||||
|
||||
liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
|
||||
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT));
|
||||
liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures));
|
||||
liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures));
|
||||
liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null)));
|
||||
liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures));
|
||||
liabilities.add(
|
||||
countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES)));
|
||||
liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures));
|
||||
liabilities.add(countLine("No of Savings Depositors",
|
||||
countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT))));
|
||||
liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures));
|
||||
liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures));
|
||||
liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures));
|
||||
// The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total
|
||||
// names the head and leaves them out rather than counting the same money twice.
|
||||
liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES",
|
||||
"OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS",
|
||||
"OSS1_OTHER_LIABILITIES"));
|
||||
|
||||
final List<Map<String, Object>> assets = new ArrayList<>();
|
||||
assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures));
|
||||
assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures));
|
||||
assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures));
|
||||
assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures));
|
||||
assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end)));
|
||||
assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures));
|
||||
assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures));
|
||||
assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS",
|
||||
"OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS"));
|
||||
|
||||
response.put("liabilities", liabilities);
|
||||
response.put("assets", assets);
|
||||
|
||||
// Liabilities must equal assets. Reported rather than asserted: an imbalance is a real
|
||||
// condition of the books that the filer has to see, not something to hide behind an error.
|
||||
final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities");
|
||||
final BigDecimal totalAssets = amountOf(assets, "12. Total Assets");
|
||||
response.put("totalLiabilities", totalLiabilities);
|
||||
response.put("totalAssets", totalAssets);
|
||||
response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0);
|
||||
response.put("imbalance", totalAssets.subtract(totalLiabilities));
|
||||
|
||||
response.put("unmetRequirements", figures.unmet());
|
||||
response.put("computable", figures.anyConfigured());
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────── OSS 2 — RoE ───────────────────────────
|
||||
|
||||
/**
|
||||
* OSS 2 - Return on Earnings, return code R071.
|
||||
*
|
||||
* Earnings lines report the movement over the quarter rather than a closing balance, so the
|
||||
* period matters here in a way it does not on OSS 1: the same account read as a balance would
|
||||
* carry forward every earlier quarter's income as well.
|
||||
*/
|
||||
@GET
|
||||
@Path("oss2")
|
||||
public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate requested = parseOrBusinessDate(quarterEndDateParam);
|
||||
final Quarter quarter = Quarter.containing(requested);
|
||||
|
||||
final Map<String, Object> response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId);
|
||||
response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note",
|
||||
"Earnings are the movement over " + quarter.start + " to " + quarter.end
|
||||
+ ", not a closing balance: an income or expense line reports what was earned or spent "
|
||||
+ "in the quarter."));
|
||||
|
||||
final RoleFigures figures = new RoleFigures(quarter, officeId);
|
||||
|
||||
final List<Map<String, Object>> earnings = new ArrayList<>();
|
||||
earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES",
|
||||
figures));
|
||||
earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING",
|
||||
figures));
|
||||
earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures));
|
||||
earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits",
|
||||
"OSS2_INTEREST_ON_BANK_BALANCES", figures));
|
||||
earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES",
|
||||
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES"));
|
||||
earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE",
|
||||
figures));
|
||||
earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures));
|
||||
earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures));
|
||||
|
||||
final List<Map<String, Object>> expenses = new ArrayList<>();
|
||||
expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures));
|
||||
expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures));
|
||||
expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures));
|
||||
expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures));
|
||||
expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures));
|
||||
expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures));
|
||||
expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures));
|
||||
|
||||
response.put("earnings", earnings);
|
||||
response.put("expenses", expenses);
|
||||
|
||||
// Item 1 is the sum of items 2 to 5, so total income counts the four components and the
|
||||
// standalone heads - never item 1 as well, which would count that interest twice.
|
||||
final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES",
|
||||
"OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES",
|
||||
"OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME");
|
||||
final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS",
|
||||
"OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE",
|
||||
"OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES");
|
||||
response.put("totalIncome", totalIncome);
|
||||
response.put("totalExpenditure", totalExpenditure);
|
||||
response.put("operatingProfit", totalIncome.subtract(totalExpenditure));
|
||||
|
||||
response.put("unmetRequirements", figures.unmet());
|
||||
response.put("computable", figures.anyConfigured());
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────── shared response scaffolding ───────────────────────────
|
||||
|
||||
/**
|
||||
* The General Information block every OSS return carries, built from the institution's own
|
||||
* particulars so OSS 1 and OSS 2 can never state a different bank from one another.
|
||||
*/
|
||||
private Map<String, Object> baseResponse(final String returnCode, final String returnName, final Quarter quarter,
|
||||
final Long officeId) {
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("returnCode", returnCode);
|
||||
response.put("returnName", returnName);
|
||||
response.put("reportingFrequency", "Quarterly");
|
||||
|
||||
final Map<String, Object> profile = institutionProfile();
|
||||
final List<Map<String, Object>> generalInfo = new ArrayList<>();
|
||||
generalInfo.add(info("Return Name", returnName));
|
||||
generalInfo.add(info("Return Code", returnCode));
|
||||
generalInfo.add(info("Bank Name", text(profile.get("bank_name"))));
|
||||
generalInfo.add(info("Bank Code", text(profile.get("bank_code"))));
|
||||
generalInfo.add(info("RO Name", text(profile.get("city"))));
|
||||
generalInfo.add(info("Institution Type", text(profile.get("bank_type"))));
|
||||
generalInfo.add(info("Reporting Frequency", "Quarterly"));
|
||||
generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE)));
|
||||
generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE)));
|
||||
response.put("generalInfo", generalInfo);
|
||||
|
||||
response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate",
|
||||
quarter.end.toString()));
|
||||
response.put("officeId", officeId);
|
||||
response.put("officeName", officeName(officeId));
|
||||
response.put("signatories", signatories(profile, quarter));
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The institution's particulars. Absent columns are tolerated rather than fatal: the return
|
||||
* names what is missing on the General Information sheet, which is more useful than refusing to
|
||||
* compute figures that are themselves correct.
|
||||
*/
|
||||
private Map<String, Object> institutionProfile() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT bank_name, bank_code, bank_type, city, state,
|
||||
authorized_person_name, authorized_person_designation, authorized_person_email,
|
||||
contact_phone
|
||||
FROM m_institution_profile WHERE id = 1
|
||||
""");
|
||||
return rows.isEmpty() ? Map.of() : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signatory block, taken from the institution's authorised person. Dated to the quarter
|
||||
* end rather than to today, so a return reprinted later still reads as filed for its own
|
||||
* period.
|
||||
*/
|
||||
private List<Map<String, Object>> signatories(final Map<String, Object> profile, final Quarter quarter) {
|
||||
final Map<String, Object> signatory = new LinkedHashMap<>();
|
||||
signatory.put("heading", "Authorised Reporting Official");
|
||||
signatory.put("name", text(profile.get("authorized_person_name")));
|
||||
signatory.put("designation", text(profile.get("authorized_person_designation")));
|
||||
signatory.put("email", text(profile.get("authorized_person_email")));
|
||||
signatory.put("telephoneOffice", text(profile.get("contact_phone")));
|
||||
signatory.put("place", text(profile.get("city")));
|
||||
signatory.put("date", quarter.end.format(RBI_DATE));
|
||||
return List.of(signatory);
|
||||
}
|
||||
|
||||
private String officeName(final Long officeId) {
|
||||
if (officeId == null) {
|
||||
return "All offices (consolidated)";
|
||||
}
|
||||
final List<String> names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class,
|
||||
officeId);
|
||||
return names.isEmpty() ? "Office " + officeId : names.get(0);
|
||||
}
|
||||
|
||||
private static Map<String, Object> info(final String label, final String value) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
// An unset particular is reported as such rather than as an empty cell, so a reviewer can
|
||||
// see that it needs maintaining in the institution profile.
|
||||
out.put("value", value.isBlank() ? "Not set in Institution Profile" : value);
|
||||
out.put("configured", !value.isBlank());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String text(final Object value) {
|
||||
return value == null ? "" : value.toString().trim();
|
||||
}
|
||||
|
||||
// ─────────────────────────── the quarter ───────────────────────────
|
||||
|
||||
/** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */
|
||||
private static final class Quarter {
|
||||
|
||||
final LocalDate start;
|
||||
final LocalDate end;
|
||||
final String label;
|
||||
|
||||
private Quarter(final LocalDate start, final LocalDate end, final String label) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
static Quarter containing(final LocalDate date) {
|
||||
final int month = date.getMonthValue();
|
||||
final int startMonth = ((month - 1) / 3) * 3 + 1;
|
||||
final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1);
|
||||
final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth());
|
||||
return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear());
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── figures from the ledger ───────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves return lines to ledger figures, remembering which roles were unmapped or partly
|
||||
* unusable so the return can report them once at the end.
|
||||
*/
|
||||
private final class RoleFigures {
|
||||
|
||||
private final Quarter quarter;
|
||||
private final Long officeId;
|
||||
private final List<String> reasons = new ArrayList<>();
|
||||
private boolean anyConfigured;
|
||||
|
||||
RoleFigures(final Quarter quarter, final Long officeId) {
|
||||
this.quarter = quarter;
|
||||
this.officeId = officeId;
|
||||
}
|
||||
|
||||
/** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */
|
||||
RoleFigure closing(final String roleKey) {
|
||||
return figureFor(roleKey, null, quarter.end, officeId, this);
|
||||
}
|
||||
|
||||
/** Movement over the quarter - for the earnings lines of OSS 2. */
|
||||
RoleFigure movement(final String roleKey) {
|
||||
return figureFor(roleKey, quarter.start, quarter.end, officeId, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Named by the line as the return prints it rather than by the role key: the reader is
|
||||
* looking at a return, and "Investments" is what they will look for on the configuration
|
||||
* screen, not OSS1_INVESTMENTS.
|
||||
*/
|
||||
void noteUnmapped(final String roleKey) {
|
||||
reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing.");
|
||||
}
|
||||
|
||||
void noteExcluded(final String roleKey, final List<String> excluded) {
|
||||
reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded)
|
||||
+ ", mapped but no longer postable. The figure shown is short by them.");
|
||||
}
|
||||
|
||||
void noteConfigured() {
|
||||
anyConfigured = true;
|
||||
}
|
||||
|
||||
boolean anyConfigured() {
|
||||
return anyConfigured;
|
||||
}
|
||||
|
||||
List<String> unmet() {
|
||||
return reasons;
|
||||
}
|
||||
}
|
||||
|
||||
/** A role's figure, plus whether it was configured at all - zero and unset are different. */
|
||||
private static final class RoleFigure {
|
||||
|
||||
final String roleKey;
|
||||
boolean configured;
|
||||
BigDecimal amount = BigDecimal.ZERO;
|
||||
final List<Map<String, Object>> accounts = new ArrayList<>();
|
||||
final List<String> excluded = new ArrayList<>();
|
||||
|
||||
RoleFigure(final String roleKey) {
|
||||
this.roleKey = roleKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums the GL accounts pinned to a role.
|
||||
*
|
||||
* With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a
|
||||
* from-date it is the movement between the two, which is what an earnings line reports.
|
||||
*
|
||||
* Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL
|
||||
* would shrink the figure with nothing on the return to say an account had dropped out, so
|
||||
* they are carried through and named instead.
|
||||
*/
|
||||
private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate,
|
||||
final Long officeId, final RoleFigures owner) {
|
||||
final RoleFigure figure = new RoleFigure(roleKey);
|
||||
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
|
||||
FROM m_rbi_reserve_gl_config c
|
||||
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
WHERE c.config_key = ?
|
||||
ORDER BY a.gl_code
|
||||
""", roleKey);
|
||||
if (accounts.isEmpty()) {
|
||||
owner.noteUnmapped(roleKey);
|
||||
return figure;
|
||||
}
|
||||
figure.configured = true;
|
||||
owner.noteConfigured();
|
||||
|
||||
for (final Map<String, Object> account : accounts) {
|
||||
final Long accountId = ((Number) account.get("id")).longValue();
|
||||
final int classification = ((Number) account.get("classification_enum")).intValue();
|
||||
final String glCode = text(account.get("gl_code"));
|
||||
|
||||
// A pinned account can stop being usable after it was mapped: disabled, or turned into
|
||||
// a header whose balance is the sum of its children and would double-count them.
|
||||
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
|
||||
final boolean header = account.get("account_usage") != null
|
||||
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
|
||||
if (disabled || header) {
|
||||
figure.excluded
|
||||
.add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)"));
|
||||
final Map<String, Object> skipped = new LinkedHashMap<>();
|
||||
skipped.put("glAccountId", accountId);
|
||||
skipped.put("glCode", glCode);
|
||||
skipped.put("name", text(account.get("name")));
|
||||
skipped.put("balance", null);
|
||||
skipped.put("excluded", disabled ? "disabled" : "header");
|
||||
figure.accounts.add(skipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId);
|
||||
figure.amount = figure.amount.add(balance);
|
||||
|
||||
final Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("glAccountId", accountId);
|
||||
detail.put("glCode", glCode);
|
||||
detail.put("name", text(account.get("name")));
|
||||
detail.put("balance", scaled(balance));
|
||||
figure.accounts.add(detail);
|
||||
}
|
||||
|
||||
if (!figure.excluded.isEmpty()) {
|
||||
owner.noteExcluded(roleKey, figure.excluded);
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's ledger figure, on its own natural side so a liability reports positive rather
|
||||
* than as a negative asset.
|
||||
*
|
||||
* Reversed entries and the reversal entries that cancel them are both excluded, so a reversal
|
||||
* nets to nothing rather than to twice the original.
|
||||
*/
|
||||
private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate,
|
||||
final LocalDate toDate, final Long officeId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit,
|
||||
COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND entry_date <= ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(accountId);
|
||||
args.add(java.sql.Date.valueOf(toDate));
|
||||
if (fromDate != null) {
|
||||
sql.append(" AND entry_date >= ?");
|
||||
args.add(java.sql.Date.valueOf(fromDate));
|
||||
}
|
||||
if (officeId != null) {
|
||||
sql.append(" AND office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
|
||||
final BigDecimal debit = toDecimal(row.get("total_debit"));
|
||||
final BigDecimal credit = toDecimal(row.get("total_credit"));
|
||||
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
|
||||
return debitNatured ? debit.subtract(credit) : credit.subtract(debit);
|
||||
}
|
||||
|
||||
/**
|
||||
* A role's name as the return prints it, for the caveats. Falls back to the key so a role
|
||||
* added without a name here degrades to something readable rather than to nothing.
|
||||
*/
|
||||
private static String lineName(final String roleKey) {
|
||||
return LINE_NAMES.getOrDefault(roleKey, roleKey);
|
||||
}
|
||||
|
||||
private static final Map<String, String> LINE_NAMES = new LinkedHashMap<>();
|
||||
static {
|
||||
LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital");
|
||||
LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves");
|
||||
LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss");
|
||||
LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)");
|
||||
LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts");
|
||||
LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits");
|
||||
LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits");
|
||||
LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits");
|
||||
LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings");
|
||||
LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions");
|
||||
LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand");
|
||||
LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks");
|
||||
LINE_NAMES.put("OSS1_INVESTMENTS", "Investments");
|
||||
LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances");
|
||||
LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets");
|
||||
LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets");
|
||||
|
||||
LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances");
|
||||
LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending");
|
||||
LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments");
|
||||
LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances");
|
||||
LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage");
|
||||
LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments");
|
||||
LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income");
|
||||
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits");
|
||||
LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings");
|
||||
LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund");
|
||||
LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting");
|
||||
LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation");
|
||||
LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies");
|
||||
LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses");
|
||||
}
|
||||
|
||||
/** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */
|
||||
private static BigDecimal toDecimal(final Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString());
|
||||
}
|
||||
|
||||
/** Rupees to the thousands the return is denominated in. */
|
||||
private static BigDecimal scaled(final BigDecimal rupees) {
|
||||
return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
// ─────────────────────────── headcounts ───────────────────────────
|
||||
|
||||
/**
|
||||
* Depositors as at a date - distinct clients holding a deposit account open on that date.
|
||||
* Counted per client rather than per account, because a customer with three accounts is one
|
||||
* depositor on the return.
|
||||
*
|
||||
* An account is open on the date if it was active by then and had not yet closed. Reading it
|
||||
* this way, rather than by today's status, is what lets an earlier quarter be reprinted and
|
||||
* still report the headcount that was true then.
|
||||
*/
|
||||
private Long countDepositors(final Long officeId, final LocalDate asOn, final List<Integer> depositTypes) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COUNT(DISTINCT sa.client_id)
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
WHERE sa.status_enum = ?
|
||||
AND sa.activatedon_date IS NOT NULL
|
||||
AND sa.activatedon_date <= ?
|
||||
AND (sa.closedon_date IS NULL OR sa.closedon_date > ?)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(SAVINGS_ACTIVE);
|
||||
args.add(java.sql.Date.valueOf(asOn));
|
||||
args.add(java.sql.Date.valueOf(asOn));
|
||||
if (depositTypes != null && !depositTypes.isEmpty()) {
|
||||
sql.append(" AND sa.deposit_type_enum IN (")
|
||||
.append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")");
|
||||
args.addAll(depositTypes);
|
||||
}
|
||||
if (officeId != null) {
|
||||
sql.append(" AND c.office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The financial year's result up to the quarter end: income earned less expenses incurred.
|
||||
*
|
||||
* Read across every income and expense account rather than the mapped ones, because this line
|
||||
* exists to close the gap between the balance sheet and the books. A figure limited to mapped
|
||||
* accounts would leave the return short by whatever was not mapped, which is the very problem
|
||||
* the line is here to solve.
|
||||
*
|
||||
* The year starts on 1 April, as the Indian financial year does.
|
||||
*/
|
||||
private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) {
|
||||
final LocalDate yearStart = financialYearStart(quarter.end);
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ?
|
||||
THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END
|
||||
ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END
|
||||
END), 0) AS result
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.classification_enum IN (?, ?)
|
||||
AND je.entry_date >= ? AND je.entry_date <= ?
|
||||
AND (je.reversed IS NULL OR je.reversed = false)
|
||||
AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(INCOME);
|
||||
args.add(INCOME);
|
||||
args.add(EXPENSE);
|
||||
args.add(java.sql.Date.valueOf(yearStart));
|
||||
args.add(java.sql.Date.valueOf(quarter.end));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND je.office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
|
||||
final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray());
|
||||
return result == null ? BigDecimal.ZERO : result;
|
||||
}
|
||||
|
||||
/** 1 April of the financial year a date falls in. */
|
||||
private static LocalDate financialYearStart(final LocalDate date) {
|
||||
final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
return LocalDate.of(year, 4, 1);
|
||||
}
|
||||
|
||||
/** Borrowers as at a date - distinct clients with a loan outstanding on that date. */
|
||||
private Long borrowerCount(final Long officeId, final LocalDate asOn) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COUNT(DISTINCT l.client_id)
|
||||
FROM m_loan l
|
||||
JOIN m_client c ON c.id = l.client_id
|
||||
WHERE l.loan_status_id = ?
|
||||
AND l.disbursedon_date IS NOT NULL
|
||||
AND l.disbursedon_date <= ?
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(LOAN_ACTIVE);
|
||||
args.add(java.sql.Date.valueOf(asOn));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND c.office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
// ─────────────────────────── line builders ───────────────────────────
|
||||
|
||||
private Map<String, Object> balanceLine(final String label, final String roleKey, final RoleFigures figures) {
|
||||
return line(label, figures.closing(roleKey));
|
||||
}
|
||||
|
||||
private Map<String, Object> movementLine(final String label, final String roleKey, final RoleFigures figures) {
|
||||
return line(label, figures.movement(roleKey));
|
||||
}
|
||||
|
||||
private static Map<String, Object> line(final String label, final RoleFigure figure) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("roleKey", figure.roleKey);
|
||||
out.put("configured", figure.configured);
|
||||
// Null rather than zero when unmapped: the screen must be able to tell "nothing mapped"
|
||||
// from "mapped and genuinely nil".
|
||||
out.put("amount", figure.configured ? scaled(figure.amount) : null);
|
||||
out.put("accounts", figure.accounts);
|
||||
out.put("subtotal", false);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A figure the return computes for itself rather than reading from mapped accounts.
|
||||
*
|
||||
* It carries a role key so the subtotals can name it like any other component, but that key is
|
||||
* not a configurable role: nothing can leave it unmapped, and nothing can map it wrong.
|
||||
*/
|
||||
private static Map<String, Object> derivedLine(final String label, final String roleKey,
|
||||
final BigDecimal rupees) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("roleKey", roleKey);
|
||||
out.put("configured", true);
|
||||
out.put("amount", scaled(rupees));
|
||||
out.put("accounts", List.of());
|
||||
out.put("derived", true);
|
||||
out.put("subtotal", false);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> countLine(final String label, final Long count) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("roleKey", null);
|
||||
out.put("configured", true);
|
||||
out.put("amount", count);
|
||||
out.put("accounts", List.of());
|
||||
out.put("count", true);
|
||||
out.put("subtotal", false);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A derived row, summed from the roles it names. Subtotals are never mapped to accounts of
|
||||
* their own - a total and its components mapped separately would disagree the moment one side
|
||||
* changed - and they name their components explicitly rather than summing whatever happens to
|
||||
* sit above them, so inserting a line cannot silently change a total.
|
||||
*/
|
||||
private static Map<String, Object> subtotal(final String label, final List<Map<String, Object>> section,
|
||||
final String... ofRoles) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("roleKey", null);
|
||||
out.put("configured", true);
|
||||
out.put("amount", sumOfRoles(section, ofRoles));
|
||||
out.put("accounts", List.of());
|
||||
out.put("components", List.of(ofRoles));
|
||||
out.put("subtotal", true);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The amount already computed for a labelled row, so a total is never derived twice. */
|
||||
private static BigDecimal amountOf(final List<Map<String, Object>> section, final String label) {
|
||||
for (final Map<String, Object> line : section) {
|
||||
if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
}
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums the named roles out of a section.
|
||||
*
|
||||
* A component that is unmapped contributes nothing, which is why the return still reports
|
||||
* {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by
|
||||
* whatever is not.
|
||||
*/
|
||||
private static BigDecimal sumOfRoles(final List<Map<String, Object>> section, final String... roles) {
|
||||
final Set<String> wanted = new LinkedHashSet<>(Arrays.asList(roles));
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> line : section) {
|
||||
// Subtotals and headcounts carry no role of their own, so their key is null. Matched by
|
||||
// the role rather than by a null-tolerant lookup, so a derived row can never be summed
|
||||
// into the total that derives it.
|
||||
final Object roleKey = line.get("roleKey");
|
||||
if (roleKey == null || !wanted.contains(roleKey.toString())) {
|
||||
continue;
|
||||
}
|
||||
if (line.get("amount") instanceof BigDecimal decimal) {
|
||||
total = total.add(decimal);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static LocalDate parseOrBusinessDate(final String param) {
|
||||
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
|
||||
}
|
||||
}
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* 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.rbireserve.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin screen backing API for RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR
|
||||
* percentages and the GL accounts the returns read their figures from.
|
||||
*
|
||||
* Both halves live on one screen because they are one decision: a percentage is meaningless without
|
||||
* knowing which accounts it applies to, and an administrator who sets one and forgets the other
|
||||
* produces a return that looks complete and is wrong.
|
||||
*
|
||||
* Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike
|
||||
* m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other
|
||||
* banks and each counts toward the balances it maintains.
|
||||
*/
|
||||
@Path("/v1/rbireserveconfig")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class RbiReserveConfigApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
|
||||
|
||||
/**
|
||||
* The roles a return can read, in the order the screen presents them. Held here rather than
|
||||
* seeded as rows because they are structural - a role only exists if code computes something
|
||||
* from it, so adding one is a code change either way.
|
||||
*/
|
||||
private static final Map<String, String> ROLE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
static {
|
||||
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
|
||||
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
|
||||
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
|
||||
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
|
||||
+ "Pin every such account - a bank typically banks with several.");
|
||||
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
|
||||
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
|
||||
+ "Pin the deposit accounts and any interest accrued on them.");
|
||||
ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS",
|
||||
"SLR-eligible investments - central and state government securities. "
|
||||
+ "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR.");
|
||||
|
||||
// OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its
|
||||
// closing balance at the quarter end.
|
||||
ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL",
|
||||
"Share capital paid up by members. OSS 1 liability head 1.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_RESERVES",
|
||||
"Reserve fund and other reserves - statutory, building, dividend equalisation, "
|
||||
+ "provisions for standard assets. OSS 1 liability head 2.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS",
|
||||
"Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT",
|
||||
"Subordinated debt counted as Tier II capital. OSS 1 liability head 5.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS",
|
||||
"All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; "
|
||||
+ "the term/savings/current heads below break this same total down and are not added to it.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM",
|
||||
"Term deposits, including recurring deposits and matured term deposits. "
|
||||
+ "A breakdown of OSS1_DEPOSITS, not an addition to it.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS",
|
||||
"Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT",
|
||||
"Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS",
|
||||
"Borrowings from banks and other institutions. OSS 1 liability head 7.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES",
|
||||
"Other liabilities and provisions - bills payable, interest accrued, sundry creditors.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND",
|
||||
"Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS",
|
||||
"Balances with banks - current, savings and fixed. "
|
||||
+ "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS",
|
||||
"All investments, SLR and non-SLR together. OSS 1 asset head 3.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES",
|
||||
"Loans and advances to members and others. OSS 1 asset head 4.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS",
|
||||
"Premises, furniture and other fixed assets, net of depreciation.");
|
||||
ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery.");
|
||||
|
||||
// OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an
|
||||
// earnings line reports what was earned or spent in the period.
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES",
|
||||
"Interest and discount earned on loans and advances. OSS 2 item 2.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING",
|
||||
"Interest on lending to banks - call and short notice money. OSS 2 item 3.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES",
|
||||
"Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE",
|
||||
"Commission, exchange and brokerage earned. OSS 2 item 6.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS",
|
||||
"Profit on sale of investments. Pin the income account only; losses belong under provisions.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST",
|
||||
"Salaries, allowances, provident fund and other staff cost. OSS 2 item 12.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS",
|
||||
"Provisions and contingencies, including bad debt provision and loss on investments.");
|
||||
ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16.");
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable name per role. Held next to the descriptions rather than in the screen, so a role
|
||||
* added here cannot show up in the UI as a raw key nobody wrote a label for.
|
||||
*/
|
||||
private static final Map<String, String> ROLE_LABELS = new LinkedHashMap<>();
|
||||
static {
|
||||
ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand");
|
||||
ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks");
|
||||
ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)");
|
||||
ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments");
|
||||
|
||||
ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital");
|
||||
ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves");
|
||||
ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss");
|
||||
ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)");
|
||||
ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts");
|
||||
ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits");
|
||||
ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits");
|
||||
ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits");
|
||||
ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings");
|
||||
ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions");
|
||||
ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand");
|
||||
ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks");
|
||||
ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments");
|
||||
ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances");
|
||||
ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets");
|
||||
ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets");
|
||||
|
||||
ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances");
|
||||
ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending");
|
||||
ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments");
|
||||
ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances");
|
||||
ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage");
|
||||
ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments");
|
||||
ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income");
|
||||
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits");
|
||||
ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings");
|
||||
ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF");
|
||||
ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting");
|
||||
ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation");
|
||||
ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies");
|
||||
ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses");
|
||||
}
|
||||
|
||||
/**
|
||||
* Which return each role belongs to, and how it is used there, so the screen can separate a
|
||||
* long list into the forms it affects and state the consequence of leaving one unmapped.
|
||||
*/
|
||||
private static final Map<String, String> ROLE_GROUPS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> ROLE_GROUP_LABELS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> ROLE_USAGE = new LinkedHashMap<>();
|
||||
static {
|
||||
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
|
||||
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
|
||||
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
|
||||
|
||||
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
|
||||
if (role.startsWith("OSS1_")) {
|
||||
ROLE_GROUPS.put(role, "OSS1");
|
||||
ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end");
|
||||
} else if (role.startsWith("OSS2_")) {
|
||||
ROLE_GROUPS.put(role, "OSS2");
|
||||
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
|
||||
} else {
|
||||
ROLE_GROUPS.put(role, "RESERVE");
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits
|
||||
* beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and
|
||||
* effective-dated so a refiled return reproduces the split that was in force.
|
||||
*/
|
||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
||||
|
||||
/** The four treatments a head can be given, each naming a branch the returns already have. */
|
||||
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO");
|
||||
|
||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
static {
|
||||
USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head");
|
||||
USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL");
|
||||
USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement");
|
||||
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
||||
}
|
||||
|
||||
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
|
||||
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
|
||||
static {
|
||||
BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY");
|
||||
BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED");
|
||||
BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED");
|
||||
BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED");
|
||||
}
|
||||
|
||||
/** Roles whose accounts carry a Demand/Time class. */
|
||||
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
|
||||
|
||||
private static final String DEMAND = "DEMAND";
|
||||
private static final String TIME = "TIME";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── read ───────────────────────────
|
||||
|
||||
@GET
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("rates", rates());
|
||||
out.put("glRoles", glRoles());
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
public String template() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
// Header accounts are excluded deliberately. A header carries no journal entry of its own -
|
||||
// its balance is the sum of its children - so pinning both a header and any child would
|
||||
// double-count that child. Only postable detail accounts are offered.
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, name, gl_code, classification_enum
|
||||
FROM acc_gl_account
|
||||
WHERE disabled = false AND account_usage = 1
|
||||
ORDER BY gl_code
|
||||
""");
|
||||
final List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (final Map<String, Object> a : accounts) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", a.get("id"));
|
||||
o.put("name", a.get("name"));
|
||||
o.put("glCode", a.get("gl_code"));
|
||||
o.put("classification", a.get("classification_enum"));
|
||||
options.add(o);
|
||||
}
|
||||
return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> rates() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, rate_type, percentage, effective_from, is_seed_default
|
||||
FROM m_rbi_reserve_rate
|
||||
ORDER BY rate_type, effective_from DESC
|
||||
""");
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", row.get("id"));
|
||||
o.put("rateType", row.get("rate_type"));
|
||||
o.put("percentage", row.get("percentage"));
|
||||
o.put("effectiveFrom", String.valueOf(row.get("effective_from")));
|
||||
// A placeholder shipped with the migration, not a figure this bank confirmed.
|
||||
o.put("seedDefault", row.get("is_seed_default"));
|
||||
out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every role, including those with nothing pinned - the screen must show the gaps. */
|
||||
private List<Map<String, Object>> glRoles() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class,
|
||||
a.name AS gl_account_name, a.gl_code
|
||||
FROM m_rbi_reserve_gl_config c
|
||||
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
ORDER BY c.config_key, a.gl_code, c.liability_class
|
||||
""");
|
||||
|
||||
// Seeded with every key the configuration knows, built in or added, so a mapping against a
|
||||
// head an administrator created lands in a bucket rather than being dropped as unknown.
|
||||
final Map<String, List<Map<String, Object>>> byKey = new LinkedHashMap<>();
|
||||
for (final String key : knownRoleKeys()) {
|
||||
byKey.put(key, new ArrayList<>());
|
||||
}
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final String key = (String) row.get("config_key");
|
||||
// A row whose key is no longer a known role is ignored rather than surfaced - it cannot
|
||||
// affect any figure, because nothing reads that key.
|
||||
final List<Map<String, Object>> bucket = byKey.get(key);
|
||||
if (bucket == null) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> account = new LinkedHashMap<>();
|
||||
account.put("id", row.get("id"));
|
||||
account.put("glAccountId", row.get("gl_account_id"));
|
||||
account.put("glAccountName", row.get("gl_account_name"));
|
||||
account.put("glCode", row.get("gl_code"));
|
||||
// The portion of the balance this mapping takes, and which head it falls under. Sent
|
||||
// back so the screen edits what is stored rather than re-deriving a default that would
|
||||
// overwrite a deliberate split on the next save.
|
||||
account.put("weightPercentage", row.get("weight_percentage"));
|
||||
account.put("liabilityClass", row.get("liability_class"));
|
||||
bucket.add(account);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map.Entry<String, String> role : ROLE_DESCRIPTIONS.entrySet()) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("configKey", role.getKey());
|
||||
o.put("description", role.getValue());
|
||||
o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey()));
|
||||
// Which return reads this role, so the screen can group a long list by the form it
|
||||
// affects rather than presenting every role as equally relevant to every return.
|
||||
final String group = ROLE_GROUPS.get(role.getKey());
|
||||
o.put("group", group);
|
||||
o.put("groupLabel", ROLE_GROUP_LABELS.get(group));
|
||||
o.put("usedBy", ROLE_USAGE.get(role.getKey()));
|
||||
// Built-in roles are wired into the arithmetic by name, so they cannot be renamed or
|
||||
// removed - the screen greys those controls rather than offering an action that would
|
||||
// have to fail.
|
||||
o.put("builtIn", true);
|
||||
o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO"));
|
||||
o.put("classified", LIABILITY_ROLES.contains(role.getKey()));
|
||||
o.put("accounts", byKey.get(role.getKey()));
|
||||
out.add(o);
|
||||
}
|
||||
// Heads an administrator added. Appended after the built-ins so the structural roles keep
|
||||
// their fixed order at the top of each group regardless of what was added later.
|
||||
for (final Map<String, Object> row : customRoleRows()) {
|
||||
final String key = (String) row.get("config_key");
|
||||
final String group = (String) row.get("role_group");
|
||||
final String usage = (String) row.get("role_usage");
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("configKey", key);
|
||||
o.put("description", row.get("description"));
|
||||
o.put("label", row.get("label"));
|
||||
o.put("group", group);
|
||||
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
|
||||
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
|
||||
o.put("builtIn", false);
|
||||
o.put("usage", usage);
|
||||
// A liability head carries the demand/time split; nothing else does.
|
||||
o.put("classified", "LIABILITY".equals(usage));
|
||||
o.put("roleId", row.get("id"));
|
||||
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
|
||||
out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Administrator-defined heads, in the order the screen presents them. */
|
||||
private List<Map<String, Object>> customRoleRows() {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id, config_key, label, description, role_group, role_usage, position
|
||||
FROM m_rbi_reserve_role
|
||||
ORDER BY role_group, position, label
|
||||
""");
|
||||
}
|
||||
|
||||
/** Every role key the configuration currently knows - built in or added. */
|
||||
private java.util.Set<String> knownRoleKeys() {
|
||||
final java.util.Set<String> keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet());
|
||||
for (final Map<String, Object> row : customRoleRows()) {
|
||||
keys.add((String) row.get("config_key"));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ─────────────────────────── write ───────────────────────────
|
||||
|
||||
/**
|
||||
* Replaces the whole set of accounts pinned to one role. Sending the full set rather than
|
||||
* add/remove deltas keeps the screen and the stored configuration in step even if two
|
||||
* administrators have the page open at once - the last save wins wholesale, instead of the two
|
||||
* interleaving into a set neither of them chose.
|
||||
*/
|
||||
@PUT
|
||||
@Path("gl/{configKey}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String updateGlRole(@PathParam("configKey") final String configKey, final String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
if (!knownRoleKeys().contains(configKey)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role",
|
||||
"'" + configKey + "' is not a known RBI reserve configuration role.");
|
||||
}
|
||||
final boolean classified = isClassifiedRole(configKey);
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||
|
||||
// Two shapes are accepted. "mappings" carries a weight and a class per account and is what
|
||||
// the screen sends; "glAccountIds" is the flat list the first version of this screen sent,
|
||||
// still honoured so an older client keeps working - its accounts are taken whole, which is
|
||||
// exactly what they meant before weights existed.
|
||||
final List<Mapping> mappings = new ArrayList<>();
|
||||
final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings")
|
||||
? root.getAsJsonObject().getAsJsonArray("mappings")
|
||||
: null;
|
||||
if (rows != null) {
|
||||
for (final JsonElement element : rows) {
|
||||
final com.google.gson.JsonObject row = element.getAsJsonObject();
|
||||
final Long accountId = row.get("glAccountId").getAsLong();
|
||||
final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull()
|
||||
? row.get("weightPercentage").getAsBigDecimal()
|
||||
: BigDecimal.valueOf(100);
|
||||
final String liabilityClass = classified && row.has("liabilityClass")
|
||||
&& !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null;
|
||||
mappings.add(new Mapping(accountId, weight, liabilityClass));
|
||||
}
|
||||
} else {
|
||||
final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root);
|
||||
if (ids != null) {
|
||||
for (final String id : ids) {
|
||||
mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final java.util.Set<String> seen = new java.util.LinkedHashSet<>();
|
||||
for (final Mapping mapping : mappings) {
|
||||
final Integer exists = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1",
|
||||
Integer.class, mapping.accountId);
|
||||
if (exists == null || exists == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found",
|
||||
"GL account " + mapping.accountId + " was not found, is disabled, or is a header account.");
|
||||
}
|
||||
// A weight outside 0-100 is not a split of a balance. Above 100 it would report more
|
||||
// than the account holds; below zero it would subtract a liability from the base.
|
||||
if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0
|
||||
|| mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range",
|
||||
"The portion taken from an account must be between 0 and 100 percent.");
|
||||
}
|
||||
if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass)
|
||||
&& !TIME.equals(mapping.liabilityClass)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class",
|
||||
"A liability must be classified as " + DEMAND + " or " + TIME + ".");
|
||||
}
|
||||
// The same account may appear twice under one role only if the two rows are different
|
||||
// heads - a demand slice and a time slice. Twice under the same head is a double count.
|
||||
if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping",
|
||||
"GL account " + mapping.accountId
|
||||
+ " is mapped to this role twice under the same head; it would be counted twice.");
|
||||
}
|
||||
}
|
||||
|
||||
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey);
|
||||
for (final Mapping mapping : mappings) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_rbi_reserve_gl_config
|
||||
(config_key, gl_account_id, weight_percentage, liability_class, created_on)
|
||||
VALUES (?, ?, ?, ?, now())
|
||||
""", configKey, mapping.accountId, mapping.weight, mapping.liabilityClass);
|
||||
}
|
||||
return retrieveAll();
|
||||
}
|
||||
|
||||
/** One account pinned to a role: which account, how much of it, and under which head. */
|
||||
private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {}
|
||||
|
||||
/** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */
|
||||
private boolean isClassifiedRole(final String configKey) {
|
||||
if (LIABILITY_ROLES.contains(configKey)) {
|
||||
return true;
|
||||
}
|
||||
final List<Map<String, Object>> found = jdbcTemplate
|
||||
.queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey);
|
||||
return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the
|
||||
* bank carries that the built-in roles do not name.
|
||||
*
|
||||
* The head needs no new code because it declares how it is consumed: a LIABILITY head enters the
|
||||
* base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head
|
||||
* counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a
|
||||
* branch the returns already have.
|
||||
*/
|
||||
@POST
|
||||
@Path("role")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String createRole(final String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||
final String label = this.fromJsonHelper.extractStringNamed("label", root);
|
||||
final String description = this.fromJsonHelper.extractStringNamed("description", root);
|
||||
final String group = this.fromJsonHelper.extractStringNamed("group", root);
|
||||
final String usage = this.fromJsonHelper.extractStringNamed("usage", root);
|
||||
|
||||
if (label == null || label.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label",
|
||||
"A name is required for the head.");
|
||||
}
|
||||
if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group",
|
||||
"The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + ".");
|
||||
}
|
||||
if (usage == null || !ROLE_USAGES.contains(usage)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage",
|
||||
"How the head is used must be one of " + ROLE_USAGES + ".");
|
||||
}
|
||||
|
||||
// The key is derived from the name rather than typed, so an administrator naming a head does
|
||||
// not also have to invent a stable identifier - and cannot collide with a built-in role by
|
||||
// choosing one of its names.
|
||||
final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_")
|
||||
.replaceAll("^_|_$", "");
|
||||
String key = base.length() > 50 ? base.substring(0, 50) : base;
|
||||
int suffix = 2;
|
||||
while (knownRoleKeys().contains(key)) {
|
||||
final String candidate = base + "_" + suffix++;
|
||||
key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate;
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_rbi_reserve_role
|
||||
(config_key, label, description, role_group, role_usage, position, created_on, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, now(), ?)
|
||||
""", key, label.trim(), description, group, usage, nextPosition(group),
|
||||
this.context.authenticatedUser().getId());
|
||||
return retrieveAll();
|
||||
}
|
||||
|
||||
/** Where a new head sits in its group: after the ones already there. */
|
||||
private int nextPosition(final String group) {
|
||||
final Integer max = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class,
|
||||
group);
|
||||
return (max == null ? 100 : max) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an administrator-defined head, together with the accounts pinned to it.
|
||||
*
|
||||
* Built-in roles are refused: the returns reference them by name, so a deleted one would not
|
||||
* leave a smaller return but a broken computation.
|
||||
*/
|
||||
@DELETE
|
||||
@Path("role/{roleId}")
|
||||
public String deleteRole(@PathParam("roleId") final Long roleId) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final List<Map<String, Object>> found = jdbcTemplate
|
||||
.queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found",
|
||||
"Head " + roleId + " was not found, or is a built-in role that cannot be removed.");
|
||||
}
|
||||
final String key = (String) found.get(0).get("config_key");
|
||||
// The mappings go with it. Left behind they would reference a head nothing reads, and would
|
||||
// silently reappear under a later head that happened to derive the same key.
|
||||
jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key);
|
||||
jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId);
|
||||
return retrieveAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a rate with effect from a date. Restating a rate for a date it already carries edits that
|
||||
* row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past
|
||||
* fortnight keeps reporting the rate that was actually in force then.
|
||||
*/
|
||||
@POST
|
||||
@Path("rate")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String upsertRate(final String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||
final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root);
|
||||
final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root);
|
||||
final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root);
|
||||
|
||||
if (rateType == null || !RATE_TYPES.contains(rateType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type",
|
||||
"Rate type must be one of " + RATE_TYPES + ".");
|
||||
}
|
||||
if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0
|
||||
|| percentage.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range",
|
||||
"Percentage must be between 0 and 100.");
|
||||
}
|
||||
if (effectiveFrom == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date",
|
||||
"An effective-from date is required.");
|
||||
}
|
||||
|
||||
final Integer exists = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class,
|
||||
rateType, java.sql.Date.valueOf(effectiveFrom));
|
||||
if (exists != null && exists > 0) {
|
||||
// Saving over a seeded placeholder makes it this bank's own figure, so the flag clears
|
||||
// and the returns stop captioning it as a default.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false
|
||||
WHERE rate_type = ? AND effective_from = ?
|
||||
""", percentage, rateType, java.sql.Date.valueOf(effectiveFrom));
|
||||
} else {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by,
|
||||
is_seed_default)
|
||||
VALUES (?, ?, ?, now(), ?, false)
|
||||
""", rateType, percentage, java.sql.Date.valueOf(effectiveFrom),
|
||||
this.context.authenticatedUser().getId());
|
||||
}
|
||||
return retrieveAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one effective-dated rate row. The last remaining row for a type is kept: with none at
|
||||
* all, every return of that type would stop computing, which is a harsher outcome than an
|
||||
* administrator deleting the wrong row expects.
|
||||
*/
|
||||
@DELETE
|
||||
@Path("rate/{rateId}")
|
||||
public String deleteRate(@PathParam("rateId") final Long rateId) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final List<Map<String, Object>> found = jdbcTemplate
|
||||
.queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found",
|
||||
"Rate " + rateId + " was not found.");
|
||||
}
|
||||
final String rateType = (String) found.get(0).get("rate_type");
|
||||
final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?",
|
||||
Integer.class, rateType);
|
||||
if (remaining != null && remaining <= 1) {
|
||||
throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row",
|
||||
"The only " + rateType + " rate on record cannot be removed - edit it instead.");
|
||||
}
|
||||
jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId);
|
||||
return retrieveAll();
|
||||
}
|
||||
}
|
||||
+892
@@ -0,0 +1,892 @@
|
||||
/**
|
||||
* 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.rbireserve.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly).
|
||||
*
|
||||
* <h3>What the returns say</h3>
|
||||
*
|
||||
* Both returns answer the same shape of question: against a liability base (Demand and Time
|
||||
* Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1
|
||||
* maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities.
|
||||
*
|
||||
* <h3>The lag basis, which is the part worth reading twice</h3>
|
||||
*
|
||||
* The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the
|
||||
* DTL of an earlier reporting Friday and compares it against what is actually held now - that lag
|
||||
* is what gives a bank time to arrange its position. So:
|
||||
*
|
||||
* <pre>
|
||||
* DTL as at the reporting Friday on or before (reporting date - 45 days)
|
||||
* Required = DTL x rate in force
|
||||
* Held as at the reporting date itself
|
||||
* Surplus = Held - Required
|
||||
* </pre>
|
||||
*
|
||||
* The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a
|
||||
* reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to
|
||||
* reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a
|
||||
* reviewer can see the raw 45-day date and the Friday actually used.
|
||||
*
|
||||
* <h3>Reading balances</h3>
|
||||
*
|
||||
* Balances are read the way every other report in this codebase reads them (see
|
||||
* BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND
|
||||
* the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the
|
||||
* original leg of that second kind would leave its counter entry standing alone and swing the
|
||||
* balance by the full amount in the wrong direction.
|
||||
*
|
||||
* <h3>When something is not configured</h3>
|
||||
*
|
||||
* A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a
|
||||
* total that silently treats it as zero. A CRR return that reports zero holdings because nobody
|
||||
* mapped the cash account is worse than one that says it cannot compute.
|
||||
*/
|
||||
@Path("/v1/rbireservereturn")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class RbiReserveReturnApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG";
|
||||
|
||||
/** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */
|
||||
private static final int DTL_LAG_DAYS = 45;
|
||||
|
||||
/**
|
||||
* The share of the requirement that must be held on EVERY day of the fortnight, not merely on
|
||||
* average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight
|
||||
* near-empty and make the average up on the closing day.
|
||||
*/
|
||||
private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90);
|
||||
|
||||
private static final int ASSET = 1;
|
||||
private static final int EXPENSE = 5;
|
||||
|
||||
/** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */
|
||||
private static final int POSTABLE_ACCOUNT = 1;
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
/** The two heads liabilities are reported under. */
|
||||
private static final String DEMAND = "DEMAND";
|
||||
private static final String TIME = "TIME";
|
||||
|
||||
/** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */
|
||||
private static final java.util.Set<String> LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES");
|
||||
|
||||
/** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */
|
||||
private static final String USAGE_LIABILITY = "LIABILITY";
|
||||
private static final String USAGE_ASSET_NET = "ASSET_NET";
|
||||
private static final String USAGE_MAINTAINED = "MAINTAINED";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Form 1 - CRR, filed twice a month.
|
||||
*
|
||||
* @param reportingDateParam
|
||||
* defaults to the current business date.
|
||||
*/
|
||||
@GET
|
||||
@Path("form1")
|
||||
public String form1(@QueryParam("reportingDate") final String reportingDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam);
|
||||
final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS);
|
||||
// A reporting Friday that falls on a holiday has no close of business to strike a balance
|
||||
// at, so the position is taken at the preceding working day instead.
|
||||
final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate);
|
||||
final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_1");
|
||||
response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)");
|
||||
response.put("frequency", "Fortnightly");
|
||||
response.put("reportingDate", reportingDate.toString());
|
||||
response.putAll(fortnightOf(reportingDate));
|
||||
|
||||
// The lag basis, spelled out so a reviewer can check it rather than trust it.
|
||||
final boolean shifted = !dtlDate.equals(nominalDtlDate);
|
||||
final Map<String, Object> basis = new LinkedHashMap<>();
|
||||
basis.put("lagDays", DTL_LAG_DAYS);
|
||||
basis.put("rawLagDate", rawLagDate.toString());
|
||||
basis.put("reportingFriday", nominalDtlDate.toString());
|
||||
basis.put("dtlAsOnDate", dtlDate.toString());
|
||||
basis.put("holdingsAsOnDate", reportingDate.toString());
|
||||
basis.put("shiftedForHoliday", shifted);
|
||||
basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " ("
|
||||
+ DTL_LAG_DAYS + " days before the reporting date)"
|
||||
+ (shifted
|
||||
? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the "
|
||||
+ "preceding working day " + dtlDate
|
||||
: "")
|
||||
+ "; balances maintained are taken as at " + reportingDate + ".");
|
||||
response.put("basis", basis);
|
||||
|
||||
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId);
|
||||
final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId);
|
||||
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId);
|
||||
|
||||
final Rate rate = rateInForce("CRR", reportingDate);
|
||||
response.put("rateType", "CRR");
|
||||
response.put("ratePercentage", rate == null ? null : rate.percentage);
|
||||
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
|
||||
|
||||
// Inter-bank assets, read on the DTL date rather than the reporting date: they are netted
|
||||
// off the liability base, so they belong to the same close of business the base is struck
|
||||
// at. Read on the reporting date they would net a later position against an earlier base.
|
||||
final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId);
|
||||
|
||||
// Heads the bank added itself - a reserve, a tax payable, anything its chart carries that
|
||||
// the four built-in roles do not name. Each declares how it is consumed, so they fold into
|
||||
// the same three totals below without the arithmetic knowing what any of them is called.
|
||||
final List<CustomHead> heads = customHeads("RESERVE", dtlDate, reportingDate, officeId);
|
||||
|
||||
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
|
||||
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
|
||||
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
|
||||
final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET));
|
||||
|
||||
// NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the
|
||||
// gross would overstate the requirement by the rate times these balances.
|
||||
//
|
||||
// Floored at zero. The subtraction can go negative where inter-bank assets exceed the
|
||||
// liabilities, and a negative base yields a negative required amount - which would make any
|
||||
// holding at all, including none, report as a surplus. There is no negative reserve
|
||||
// requirement: below zero the requirement is nil.
|
||||
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
|
||||
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate));
|
||||
for (final CustomHead head : heads) {
|
||||
if (USAGE_LIABILITY.equals(head.usage)) {
|
||||
lines.add(line(head.label, head.figure, dtlDate));
|
||||
}
|
||||
}
|
||||
// The two heads the liability total is built from, each stated on the face of the return as
|
||||
// RBI reports them rather than left implicit inside the total above.
|
||||
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate));
|
||||
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate));
|
||||
lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate));
|
||||
for (final CustomHead head : heads) {
|
||||
if (USAGE_ASSET_NET.equals(head.usage)) {
|
||||
lines.add(line("Less: " + head.label, head.figure, dtlDate));
|
||||
}
|
||||
}
|
||||
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate));
|
||||
lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate));
|
||||
lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate));
|
||||
for (final CustomHead head : heads) {
|
||||
// A head belonging to the Memorandum or an Annexure prints under that section's own
|
||||
// heading below, not here: listing it in both places would show one figure twice and
|
||||
// invite a reader to add it in.
|
||||
if (inNamedSection(head)) {
|
||||
continue;
|
||||
}
|
||||
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
|
||||
lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure,
|
||||
reportingDate));
|
||||
}
|
||||
}
|
||||
response.put("lines", lines);
|
||||
|
||||
final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured);
|
||||
response.put("computable", computable);
|
||||
|
||||
if (computable) {
|
||||
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED));
|
||||
response.put("dtlAmount", liabilities);
|
||||
response.put("demandLiabilities", dtl.configured ? demand : null);
|
||||
response.put("timeLiabilities", dtl.configured ? time : null);
|
||||
response.put("interBankAssets", netted);
|
||||
response.put("ndtlAmount", ndtl);
|
||||
response.put("requiredAmount", required);
|
||||
response.put("maintainedAmount", held);
|
||||
response.put("surplusOrShortfall", held.subtract(required));
|
||||
response.put("compliant", held.compareTo(required) >= 0);
|
||||
// What the bank actually holds, as a percentage of the same base the requirement uses -
|
||||
// directly comparable with the rate above.
|
||||
response.put("maintainedPercentage", percentageOf(held, ndtl));
|
||||
|
||||
// Compliance over the fortnight, not on one day. RBI requires the full requirement on
|
||||
// average across the fortnight AND a daily minimum on every single day of it, so a bank
|
||||
// that held nothing for thirteen days and topped up on the last one fails even though
|
||||
// its closing position looks ample.
|
||||
final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart"));
|
||||
final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd"));
|
||||
final List<String> maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS"));
|
||||
maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED));
|
||||
|
||||
// Only up to the reporting date: days after it have not happened, and averaging an
|
||||
// unfinished fortnight over its whole length would understate what is held so far.
|
||||
final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd;
|
||||
final Map<String, Object> daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles);
|
||||
|
||||
final BigDecimal average = (BigDecimal) daily.get("average");
|
||||
final BigDecimal lowest = (BigDecimal) daily.get("lowest");
|
||||
final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2,
|
||||
RoundingMode.HALF_UP);
|
||||
|
||||
daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd));
|
||||
daily.put("requiredAverage", required);
|
||||
daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT);
|
||||
daily.put("dailyMinimumAmount", dailyFloor);
|
||||
daily.put("averageMet", average.compareTo(required) >= 0);
|
||||
daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0);
|
||||
daily.put("averageShortfall", average.subtract(required));
|
||||
response.put("maintenance", daily);
|
||||
}
|
||||
// The Memorandum and the two Annexures Form A carries alongside the main statement. They are
|
||||
// sections of the return rather than extra lines on it, so they are returned separately and
|
||||
// print under their own headings rather than being mistaken for figures entering the CRR
|
||||
// arithmetic - which none of them do.
|
||||
response.put("memorandum", sectionOf(heads, "Memorandum - "));
|
||||
response.put("annexureA", sectionOf(heads, "Annexure A - "));
|
||||
response.put("annexureB", sectionOf(heads, "Annexure B - "));
|
||||
|
||||
response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks));
|
||||
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads belonging to one titled section of the return, with the prefix stripped from each
|
||||
* label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading.
|
||||
*
|
||||
* Selected by label prefix rather than by a column, because a section is a presentational
|
||||
* grouping: a head moves between sections by being renamed, and an administrator adding a head
|
||||
* to the Memorandum does so by naming it that way rather than by learning a second concept.
|
||||
*/
|
||||
/** Prefixes that move a head out of the main statement and into its own titled section. */
|
||||
private static final List<String> SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - ");
|
||||
|
||||
private static boolean inNamedSection(final CustomHead head) {
|
||||
if (head.label == null) {
|
||||
return false;
|
||||
}
|
||||
return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith);
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> sectionOf(final List<CustomHead> heads, final String prefix) {
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final CustomHead head : heads) {
|
||||
if (head.label == null || !head.label.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("label", head.label.substring(prefix.length()));
|
||||
row.put("configKey", head.figure.configKey);
|
||||
row.put("configured", head.figure.configured);
|
||||
row.put("amount", head.figure.configured ? head.figure.amount : null);
|
||||
row.put("accounts", head.figure.accounts);
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form 9 - SLR, filed at the end of the month.
|
||||
*
|
||||
* Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as
|
||||
* at the month end, with no lag, so the return states the position actually held at close.
|
||||
*/
|
||||
@GET
|
||||
@Path("form9")
|
||||
public String form9(@QueryParam("reportingDate") final String reportingDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate requested = parseOrBusinessDate(reportingDateParam);
|
||||
final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth());
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("formCode", "FORM_9");
|
||||
response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)");
|
||||
response.put("frequency", "Monthly");
|
||||
response.put("requestedDate", requested.toString());
|
||||
response.put("reportingDate", monthEnd.toString());
|
||||
response.put("monthStart", requested.withDayOfMonth(1).toString());
|
||||
|
||||
final Map<String, Object> basis = new LinkedHashMap<>();
|
||||
basis.put("dtlAsOnDate", monthEnd.toString());
|
||||
basis.put("holdingsAsOnDate", monthEnd.toString());
|
||||
basis.put("note", "SLR is reported on the month-end position: both the DTL base and the "
|
||||
+ "SLR-eligible investments are taken as at " + monthEnd + ".");
|
||||
response.put("basis", basis);
|
||||
|
||||
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId);
|
||||
final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId);
|
||||
final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId);
|
||||
final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId);
|
||||
|
||||
final Rate rate = rateInForce("SLR", monthEnd);
|
||||
response.put("rateType", "SLR");
|
||||
response.put("ratePercentage", rate == null ? null : rate.percentage);
|
||||
response.put("rateIsSeedDefault", rate != null && rate.seedDefault);
|
||||
|
||||
// The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the
|
||||
// base and the holdings are struck at one close of business.
|
||||
final List<CustomHead> heads = customHeads("RESERVE", monthEnd, monthEnd, officeId);
|
||||
|
||||
final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY));
|
||||
final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true));
|
||||
final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false));
|
||||
final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET));
|
||||
|
||||
// SLR is charged on the same NET base as CRR. Both sides are month-end here, so the
|
||||
// inter-bank figure is the one already read above rather than a second read at another date.
|
||||
final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO);
|
||||
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd));
|
||||
for (final CustomHead head : heads) {
|
||||
if (USAGE_LIABILITY.equals(head.usage)) {
|
||||
lines.add(line(head.label, head.figure, monthEnd));
|
||||
}
|
||||
}
|
||||
lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd));
|
||||
lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd));
|
||||
lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd));
|
||||
for (final CustomHead head : heads) {
|
||||
if (USAGE_ASSET_NET.equals(head.usage)) {
|
||||
lines.add(line("Less: " + head.label, head.figure, monthEnd));
|
||||
}
|
||||
}
|
||||
lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd));
|
||||
lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd));
|
||||
// Shown for completeness of the month-end liquid position; not added into SLR maintained.
|
||||
// A custom MAINTAINED head IS added: it was declared as counting toward the requirement,
|
||||
// and Form 9's requirement is the SLR one.
|
||||
lines.add(line("Cash in hand (memo)", cash, monthEnd));
|
||||
for (final CustomHead head : heads) {
|
||||
if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) {
|
||||
// Every such head is a memo on THIS return, including one marked MAINTAINED: only
|
||||
// SLR-eligible investments count toward SLR, so the label must not suggest the
|
||||
// figure was added into the maintained total when it was not.
|
||||
lines.add(line(head.label + " (memo)", head.figure, monthEnd));
|
||||
}
|
||||
}
|
||||
response.put("lines", lines);
|
||||
|
||||
final boolean computable = rate != null && dtl.configured && investments.configured;
|
||||
response.put("computable", computable);
|
||||
|
||||
if (computable) {
|
||||
final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
// SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT
|
||||
// added here the way it is on Form 1: it was declared as counting toward a requirement,
|
||||
// and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head
|
||||
// configured for CRR inflate the SLR position, which is the error this return exists to
|
||||
// detect. A bank with more eligible securities maps them to SLR_INVESTMENTS.
|
||||
final BigDecimal held = investments.amount;
|
||||
response.put("dtlAmount", liabilities);
|
||||
response.put("demandLiabilities", dtl.configured ? demand : null);
|
||||
response.put("timeLiabilities", dtl.configured ? time : null);
|
||||
response.put("interBankAssets", netted);
|
||||
response.put("ndtlAmount", ndtl);
|
||||
response.put("requiredAmount", required);
|
||||
response.put("maintainedAmount", held);
|
||||
response.put("surplusOrShortfall", held.subtract(required));
|
||||
response.put("compliant", held.compareTo(required) >= 0);
|
||||
response.put("maintainedPercentage", percentageOf(held, ndtl));
|
||||
}
|
||||
response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments));
|
||||
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────── computation ───────────────────────────
|
||||
|
||||
/** A role's balance, plus whether it was configured at all - zero and unset are different. */
|
||||
private static final class RoleFigure {
|
||||
|
||||
final String configKey;
|
||||
boolean configured;
|
||||
BigDecimal amount = BigDecimal.ZERO;
|
||||
/**
|
||||
* The same total split by liability class, for the roles where RBI reports the two heads
|
||||
* separately. Both stay zero on asset roles, where the distinction has no meaning.
|
||||
*/
|
||||
BigDecimal demandAmount = BigDecimal.ZERO;
|
||||
BigDecimal timeAmount = BigDecimal.ZERO;
|
||||
final List<Map<String, Object>> accounts = new ArrayList<>();
|
||||
/** Accounts pinned to the role but left out of the total, with why. */
|
||||
final List<String> excluded = new ArrayList<>();
|
||||
/**
|
||||
* Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the
|
||||
* return still adds up, but they cannot be placed under either head - reported so the gap is
|
||||
* visible rather than showing as a total its own two halves fall short of.
|
||||
*/
|
||||
final List<String> unclassified = new ArrayList<>();
|
||||
|
||||
RoleFigure(final String configKey) {
|
||||
this.configKey = configKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums the balances of every GL account pinned to a role, as at a date.
|
||||
*
|
||||
* Each account's balance is taken on its own natural side, so a liability role such as DTL
|
||||
* reports as a positive number rather than as a negative asset-side figure.
|
||||
*
|
||||
* A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI
|
||||
* treats part of a savings balance as a demand liability and the rest as time, so the same
|
||||
* account is mapped twice under one role - once per class, each with its own weight - and the
|
||||
* two slices are accumulated under their respective heads.
|
||||
*/
|
||||
private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) {
|
||||
final RoleFigure figure = new RoleFigure(configKey);
|
||||
|
||||
// Every account pinned to the role is read back, usable or not. Excluding the unusable ones
|
||||
// in SQL would shrink the maintained figure with nothing on the return to say an account had
|
||||
// dropped out, so they are carried through here and reported by unmet() instead.
|
||||
//
|
||||
// Keyed on the mapping row rather than the account: one account can appear more than once
|
||||
// under a role, and grouping by account would collapse a demand slice and a time slice into
|
||||
// a single row whose weight is neither.
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class,
|
||||
a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage
|
||||
FROM m_rbi_reserve_gl_config c
|
||||
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
WHERE c.config_key = ?
|
||||
ORDER BY a.gl_code, c.liability_class
|
||||
""", configKey);
|
||||
if (accounts.isEmpty()) {
|
||||
return figure;
|
||||
}
|
||||
figure.configured = true;
|
||||
|
||||
for (final Map<String, Object> account : accounts) {
|
||||
final Long accountId = ((Number) account.get("id")).longValue();
|
||||
final int classification = ((Number) account.get("classification_enum")).intValue();
|
||||
final String glCode = (String) account.get("gl_code");
|
||||
final String liabilityClass = (String) account.get("liability_class");
|
||||
// NOT NULL in the schema, but read defensively: a row that somehow arrived without one
|
||||
// must not multiply a balance by zero and silently drop it out of the return.
|
||||
final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED
|
||||
: (BigDecimal) account.get("weight_percentage");
|
||||
|
||||
// A pinned account can stop being usable after it was mapped: disabled, or turned into a
|
||||
// header whose balance is the sum of its children and would double-count them. Either
|
||||
// way its balance must not silently enter the total - the account is named on the return
|
||||
// as excluded, so the figure can be read for what it is.
|
||||
final boolean disabled = Boolean.TRUE.equals(account.get("disabled"));
|
||||
final boolean header = account.get("account_usage") != null
|
||||
&& ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT;
|
||||
if (disabled || header) {
|
||||
figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)"));
|
||||
final Map<String, Object> skipped = new LinkedHashMap<>();
|
||||
skipped.put("glAccountId", accountId);
|
||||
skipped.put("glCode", glCode);
|
||||
skipped.put("name", account.get("name"));
|
||||
skipped.put("balance", null);
|
||||
skipped.put("weightPercentage", weight);
|
||||
skipped.put("liabilityClass", liabilityClass);
|
||||
skipped.put("excluded", disabled ? "disabled" : "header");
|
||||
figure.accounts.add(skipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit,
|
||||
COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND entry_date <= ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate)));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
|
||||
final BigDecimal debit = (BigDecimal) row.get("total_debit");
|
||||
final BigDecimal credit = (BigDecimal) row.get("total_credit");
|
||||
final boolean debitNatured = classification == ASSET || classification == EXPENSE;
|
||||
final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit);
|
||||
|
||||
// The role takes its configured portion of the balance. Rounded to paise at the slice,
|
||||
// so the demand and time halves shown on the return are the very numbers that were
|
||||
// added together - rounding only the total would leave the two heads failing to sum to
|
||||
// it by a paisa, which on a filed return reads as an error.
|
||||
final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance
|
||||
: balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
figure.amount = figure.amount.add(contribution);
|
||||
if (DEMAND.equals(liabilityClass)) {
|
||||
figure.demandAmount = figure.demandAmount.add(contribution);
|
||||
} else if (TIME.equals(liabilityClass)) {
|
||||
figure.timeAmount = figure.timeAmount.add(contribution);
|
||||
} else if (LIABILITY_ROLES.contains(configKey)) {
|
||||
figure.unclassified.add(glCode + " — " + account.get("name"));
|
||||
}
|
||||
|
||||
final Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("glAccountId", accountId);
|
||||
detail.put("glCode", glCode);
|
||||
detail.put("name", account.get("name"));
|
||||
// Both the account's own balance and the slice taken from it. A reviewer checking a
|
||||
// weighted line against the ledger needs the figure it was derived from, not only the
|
||||
// result - otherwise a wrong weight and a wrong balance look identical on the return.
|
||||
detail.put("accountBalance", balance);
|
||||
detail.put("weightPercentage", weight);
|
||||
detail.put("liabilityClass", liabilityClass);
|
||||
detail.put("balance", contribution);
|
||||
figure.accounts.add(detail);
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/** An administrator-defined head, with the figure it evaluated to. */
|
||||
private static final class CustomHead {
|
||||
|
||||
final String label;
|
||||
final String usage;
|
||||
final RoleFigure figure;
|
||||
|
||||
CustomHead(final String label, final String usage, final RoleFigure figure) {
|
||||
this.label = label;
|
||||
this.usage = usage;
|
||||
this.figure = figure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads an administrator added to a return, each evaluated as at the date its treatment
|
||||
* calls for.
|
||||
*
|
||||
* Liabilities are read on the base date and assets on the holdings date, for the same reason the
|
||||
* built-in roles are: the base is struck at an earlier close of business than the position held
|
||||
* against it, and reading both at one date would compare figures from different days.
|
||||
*/
|
||||
/**
|
||||
* The keys of administrator-defined heads with one usage - so the daily average counts whatever
|
||||
* the bank declared as counting toward the requirement, not only the two built-in roles.
|
||||
*/
|
||||
private List<String> customKeysWithUsage(final String group, final String usage) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT config_key FROM m_rbi_reserve_role
|
||||
WHERE role_group = ? AND role_usage = ?
|
||||
ORDER BY position, id
|
||||
""", String.class, group, usage);
|
||||
}
|
||||
|
||||
private List<CustomHead> customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate,
|
||||
final Long officeId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT config_key, label, role_usage
|
||||
FROM m_rbi_reserve_role
|
||||
WHERE role_group = ?
|
||||
ORDER BY position, label
|
||||
""", group);
|
||||
final List<CustomHead> heads = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final String key = (String) row.get("config_key");
|
||||
final String usage = (String) row.get("role_usage");
|
||||
final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate
|
||||
: holdingsDate;
|
||||
final RoleFigure figure = figureFor(key, asOn, officeId);
|
||||
// A head with nothing mapped is left off the return entirely rather than printed as a
|
||||
// nil line. It was added but never finished, and an empty row invites the reader to
|
||||
// treat a gap in configuration as a balance of zero.
|
||||
if (figure.configured) {
|
||||
heads.add(new CustomHead((String) row.get("label"), usage, figure));
|
||||
}
|
||||
}
|
||||
return heads;
|
||||
}
|
||||
|
||||
/** The total a set of custom heads contributes under one treatment. */
|
||||
private static BigDecimal totalOf(final List<CustomHead> heads, final String usage) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final CustomHead head : heads) {
|
||||
if (head.usage.equals(usage)) {
|
||||
total = total.add(head.figure.amount);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** The demand or time slice those heads contribute, for the split shown on the face of the return. */
|
||||
private static BigDecimal liabilitySliceOf(final List<CustomHead> heads, final boolean demand) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final CustomHead head : heads) {
|
||||
if (USAGE_LIABILITY.equals(head.usage)) {
|
||||
total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** A rate and whether it is still the placeholder the migration seeded. */
|
||||
private static final class Rate {
|
||||
|
||||
final BigDecimal percentage;
|
||||
final boolean seedDefault;
|
||||
|
||||
Rate(final BigDecimal percentage, final boolean seedDefault) {
|
||||
this.percentage = percentage;
|
||||
this.seedDefault = seedDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate in force on a date - the most recent one effective on or before it. Null when the
|
||||
* date precedes every rate on record, which stops the return computing rather than defaulting to
|
||||
* a rate nobody set.
|
||||
*/
|
||||
private Rate rateInForce(final String rateType, final LocalDate onDate) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
|
||||
SELECT percentage, is_seed_default FROM m_rbi_reserve_rate
|
||||
WHERE rate_type = ? AND effective_from <= ?
|
||||
ORDER BY effective_from DESC LIMIT 1
|
||||
""", rateType, java.sql.Date.valueOf(onDate));
|
||||
if (found.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> row = found.get(0);
|
||||
return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")));
|
||||
}
|
||||
|
||||
/**
|
||||
* The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of
|
||||
* business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close
|
||||
* of business to read a balance at.
|
||||
*/
|
||||
private static LocalDate reportingFridayOnOrBefore(final LocalDate date) {
|
||||
return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
|
||||
}
|
||||
|
||||
/**
|
||||
* The date itself when the bank was open, otherwise the working day before it.
|
||||
*
|
||||
* A reporting Friday that is a declared holiday has no close of business, so there is no balance
|
||||
* to strike: the statutory framework takes the preceding working day instead. Sundays are
|
||||
* treated as non-working alongside declared holidays; Saturdays are not, because a reporting
|
||||
* Friday never falls on one and a holiday shifted back from Friday legitimately lands on
|
||||
* Thursday.
|
||||
*
|
||||
* Bounded at ten days. A chain of holidays longer than that is a data problem rather than a
|
||||
* calendar, and walking back indefinitely would silently report a balance from weeks earlier.
|
||||
*/
|
||||
private LocalDate precedingWorkingDay(final LocalDate date) {
|
||||
LocalDate candidate = date;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (!isNonWorkingDay(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = candidate.minusDays(1);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private boolean isNonWorkingDay(final LocalDate date) {
|
||||
if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
|
||||
return true;
|
||||
}
|
||||
// status_enum 300 is an activated holiday; a draft or deleted one does not close the bank.
|
||||
final Integer holidays = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_holiday
|
||||
WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date
|
||||
""", Integer.class, java.sql.Date.valueOf(date));
|
||||
return holidays != null && holidays > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The average of what was maintained across every day of the fortnight, which is the figure CRR
|
||||
* compliance actually turns on.
|
||||
*
|
||||
* RBI does not read a single day's balance: a bank must keep at least a daily minimum and average
|
||||
* the full requirement over the fortnight. A return that reported only the reporting date's
|
||||
* position would call a bank compliant that held nothing for thirteen days and topped up on the
|
||||
* last one.
|
||||
*
|
||||
* Every day is read, including holidays and Sundays: the balance stands over a closed day, and
|
||||
* excluding those days would let a fortnight ending on a long weekend average away a shortfall.
|
||||
*/
|
||||
private Map<String, Object> dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId,
|
||||
final List<String> maintainedRoles) {
|
||||
final List<Map<String, Object>> days = new ArrayList<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
BigDecimal lowest = null;
|
||||
LocalDate lowestOn = null;
|
||||
|
||||
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
|
||||
BigDecimal held = BigDecimal.ZERO;
|
||||
for (final String role : maintainedRoles) {
|
||||
held = held.add(figureFor(role, day, officeId).amount);
|
||||
}
|
||||
total = total.add(held);
|
||||
if (lowest == null || held.compareTo(lowest) < 0) {
|
||||
lowest = held;
|
||||
lowestOn = day;
|
||||
}
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("date", day.toString());
|
||||
row.put("amount", held);
|
||||
days.add(row);
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("days", days);
|
||||
out.put("dayCount", days.size());
|
||||
out.put("average", days.isEmpty() ? BigDecimal.ZERO
|
||||
: total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP));
|
||||
out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest);
|
||||
out.put("lowestOn", lowestOn == null ? null : lowestOn.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */
|
||||
private static Map<String, Object> fortnightOf(final LocalDate date) {
|
||||
final boolean first = date.getDayOfMonth() <= 15;
|
||||
final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16);
|
||||
final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth());
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("fortnight", first ? 1 : 2);
|
||||
out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight");
|
||||
out.put("fortnightStart", start.toString());
|
||||
out.put("fortnightEnd", end.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A figure as a percentage of a base, or null where that percentage would not mean anything.
|
||||
*
|
||||
* Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction
|
||||
* and can come out negative where inter-bank assets exceed the liabilities - an unusual position,
|
||||
* but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that
|
||||
* reads as a shortfall on a return where holdings are ample, which is worse than showing nothing.
|
||||
*/
|
||||
private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) {
|
||||
if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* A derived row - a split of a role's total, or a figure computed from several roles. It has no
|
||||
* accounts of its own because it is not read from the ledger: its parts are already listed under
|
||||
* the lines it was derived from, and repeating them here would show the same balance twice.
|
||||
*/
|
||||
private static Map<String, Object> subLine(final String label, final BigDecimal amount, final boolean configured,
|
||||
final LocalDate asOnDate) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("configKey", null);
|
||||
out.put("configured", configured);
|
||||
out.put("asOnDate", asOnDate.toString());
|
||||
out.put("amount", configured ? amount : null);
|
||||
out.put("derived", true);
|
||||
out.put("accounts", List.of());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> line(final String label, final RoleFigure figure, final LocalDate asOnDate) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("label", label);
|
||||
out.put("configKey", figure.configKey);
|
||||
out.put("configured", figure.configured);
|
||||
out.put("asOnDate", asOnDate.toString());
|
||||
// Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped"
|
||||
// from "mapped and genuinely nil".
|
||||
out.put("amount", figure.configured ? figure.amount : null);
|
||||
out.put("accounts", figure.accounts);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable reasons the return could not be computed, for the screen to show verbatim.
|
||||
*
|
||||
* A rate still carrying the seeded placeholder is listed here too. It does not stop the return
|
||||
* computing - the figures are arithmetically right for that rate - but the rate itself is one
|
||||
* nobody at the bank confirmed, so the return says so rather than presenting a required amount
|
||||
* derived from a placeholder as though it were settled.
|
||||
*/
|
||||
private static List<String> unmet(final Rate rate, final String rateType, final RoleFigure... figures) {
|
||||
final List<String> reasons = new ArrayList<>();
|
||||
if (rate == null) {
|
||||
reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. "
|
||||
+ "Set one under Organization > RBI Reserve Configuration.");
|
||||
} else if (rate.seedDefault) {
|
||||
reasons.add("The " + rateType + " rate of " + rate.percentage
|
||||
+ "% is the placeholder shipped with this installation, not a rate this bank has confirmed. "
|
||||
+ "Confirm the rate in force under Organization > RBI Reserve Configuration before filing.");
|
||||
}
|
||||
for (final RoleFigure figure : figures) {
|
||||
if (!figure.configured) {
|
||||
reasons.add("No GL accounts are mapped to " + figure.configKey
|
||||
+ ". Map them under Organization > RBI Reserve Configuration.");
|
||||
} else if (!figure.unclassified.isEmpty()) {
|
||||
// Counted in the base but placed under neither head, so the demand and time lines do
|
||||
// not sum to the total printed above them. On a filed return that reads as an
|
||||
// arithmetic error, so the accounts responsible are named.
|
||||
reasons.add(String.join(", ", figure.unclassified)
|
||||
+ " — mapped to the liability base but not marked Demand or Time, so the figure is counted in "
|
||||
+ "NDTL while appearing under neither head. Classify them under Organization > "
|
||||
+ "RBI Reserve Configuration.");
|
||||
}
|
||||
if (!figure.excluded.isEmpty()) {
|
||||
// The figure is short by these accounts' balances. Saying so is the difference
|
||||
// between a reviewable return and one that under-reports without a trace.
|
||||
reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded)
|
||||
+ " — mapped but no longer postable, so the balance shown excludes them. "
|
||||
+ "Review the mapping under Organization > RBI Reserve Configuration.");
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static LocalDate parseOrBusinessDate(final String param) {
|
||||
return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -227,14 +227,14 @@ public class CurrentProductsApiResource {
|
||||
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||
|
||||
if (savingProductData.hasAccountingEnabled()) {
|
||||
// Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever
|
||||
// savings product shares this id. Payment-channel and per-charge mappings are savings features a current
|
||||
// product does not write, so none are returned.
|
||||
final Map<String, Object> accountingMappings = accountMappingReadPlatformService
|
||||
.fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId());
|
||||
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = accountMappingReadPlatformService
|
||||
.fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId);
|
||||
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = accountMappingReadPlatformService
|
||||
.fetchFeeToIncomeAccountMappingsForSavingsProduct(productId);
|
||||
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = accountMappingReadPlatformService
|
||||
.fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId);
|
||||
.fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId());
|
||||
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = null;
|
||||
final Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = null;
|
||||
final Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = null;
|
||||
savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings,
|
||||
paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings);
|
||||
}
|
||||
|
||||
+4
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
|
||||
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
|
||||
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
|
||||
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
|
||||
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
|
||||
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
|
||||
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
|
||||
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
|
||||
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
|
||||
HashMap request = new HashMap<>();
|
||||
request.put("annualInterestRate", annualInterestRate);
|
||||
request.put("tenureInMonths", tenureInMonths);
|
||||
if (tenureInDays != null) {
|
||||
request.put("tenureInDays", tenureInDays);
|
||||
}
|
||||
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
|
||||
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
|
||||
request.put("principalAmount", principalAmount);
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
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;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic
|
||||
* `payment` command (the same one the account's own Account Actions menu already uses) -- so the
|
||||
* Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options,
|
||||
* Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the
|
||||
* gap-period interest credited first when the product's renewal grace window has passed. A no-op
|
||||
* (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all --
|
||||
* see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace.
|
||||
*/
|
||||
@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest")
|
||||
@Component
|
||||
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.")
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositGapInterestApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String creditGapInterest(@PathParam("accountId") final Long accountId) {
|
||||
// Posts a real interest transaction when past grace, so it needs write/update, not just read.
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final Map<String, Object> result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
|
||||
* 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
|
||||
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
|
||||
* m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/
|
||||
* closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it
|
||||
* needed for the single-account and full-list screens it serves, none of it needed here. Neither
|
||||
* m_savings_account.deposit_type_enum/status_enum nor
|
||||
* m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed
|
||||
* directly against pg_indexes), so that query was a full table scan on top of being wide. This
|
||||
* endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause
|
||||
* this feature added (see migration 0571).
|
||||
*/
|
||||
@Path("/v1/fixeddepositaccounts/maturity-dashboard")
|
||||
@Component
|
||||
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.")
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositMaturityDashboardApiResource {
|
||||
|
||||
/** Fineract's DepositAccountType.FIXED_DEPOSIT. */
|
||||
private static final int FIXED_DEPOSIT = 200;
|
||||
/** SavingsAccountStatusType.ACTIVE. */
|
||||
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 JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve() {
|
||||
// Same permission as the single-account/full-list FD endpoints -- this is a narrower read of
|
||||
// the same resource, not a new one, so no new permission is introduced for it.
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT sa.id AS "id",
|
||||
sa.account_no AS "accountNo",
|
||||
sa.client_id AS "clientId",
|
||||
c.display_name AS "clientName",
|
||||
sp.name AS "productName",
|
||||
sp.fd_type AS "fdType",
|
||||
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate",
|
||||
datp.deposit_amount AS "depositAmount",
|
||||
datp.maturity_amount AS "maturityAmount",
|
||||
datp.on_account_closure_enum AS "onClosureType",
|
||||
sa.linked_account_id AS "linkedAccountId",
|
||||
dptp.renewal_grace_period_days AS "renewalGracePeriodDays"
|
||||
FROM m_savings_account sa
|
||||
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.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_client c ON c.id = sa.client_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
|
||||
ORDER BY datp.maturity_date ASC
|
||||
""", FIXED_DEPOSIT, ACTIVE, MATURED);
|
||||
|
||||
return this.gson.toJson(rows);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
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;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the
|
||||
* original maturity date within the product's renewal grace window (0 days if none is configured), or
|
||||
* from today with a gap-period interest credit (at the linked Savings account's rate) once the grace
|
||||
* window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is
|
||||
* made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored
|
||||
* maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not
|
||||
* consulted. All the actual renewal and gap-interest logic lives in
|
||||
* DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same
|
||||
* collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it.
|
||||
*/
|
||||
@Path("/v1/fixeddepositaccounts/{accountId}/renew")
|
||||
@Component
|
||||
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.")
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositRenewalApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String renew(@PathParam("accountId") final Long accountId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
// A renewal moves money (or credits gap-period interest), so it needs the write/update
|
||||
// permission on this resource, not just read -- unlike the dashboard's own read-only endpoint.
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject()
|
||||
: JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||
final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull())
|
||||
? body.get("reinvestType").getAsInt()
|
||||
: null;
|
||||
final Map<String, Object> result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -27,6 +27,8 @@ import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -225,7 +227,11 @@ public class SavingsPooledInterestPostingService {
|
||||
|
||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||
final String currency = (String) due.get(0).get("currency_code");
|
||||
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
|
||||
// and the resumed legs have to carry the same reference as the legs already posted, or one
|
||||
// interest posting ends up split across two transaction ids.
|
||||
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
|
||||
productId);
|
||||
|
||||
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
||||
// account the payout is made from, so what is still merely owed and what is on its way to
|
||||
|
||||
+10
-1
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
|
||||
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
|
||||
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
|
||||
.notLessThanMin(BigDecimal.valueOf(0));
|
||||
|
||||
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
|
||||
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
|
||||
// an addition, so only one of the two needs to be a positive value.
|
||||
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
|
||||
if (tenureInDays != null && tenureInDays > 0) {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
|
||||
} else {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
}
|
||||
|
||||
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user