Compare commits
6
Commits
dev
...
934c11bf87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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(),
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+12
@@ -44,4 +44,16 @@ 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. Restricted to RAISED and UNDER_REVIEW because 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 IN ('RAISED', 'UNDER_REVIEW') 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> {}
|
||||
+7
@@ -1468,6 +1468,13 @@ public class AccountingProcessorHelper {
|
||||
accountMapping = paymentChannelSpecificAccountMapping;
|
||||
}
|
||||
}
|
||||
// Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can
|
||||
// now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so
|
||||
// this has to fail with a clean, named error instead of NPEing on accountMapping.
|
||||
if (accountMapping == null) {
|
||||
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId,
|
||||
CashAccountsForShares.fromInt(accountMappingTypeId).toString());
|
||||
}
|
||||
glAccount = accountMapping.getGlAccount();
|
||||
}
|
||||
return glAccount;
|
||||
|
||||
+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 : "");
|
||||
|
||||
|
||||
+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());
|
||||
|
||||
+5
-3
@@ -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;
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+43
-7
@@ -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()
|
||||
@@ -624,7 +630,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 +645,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 +675,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()) {
|
||||
@@ -1014,6 +1038,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");
|
||||
|
||||
+285
-25
@@ -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;
|
||||
@@ -150,7 +152,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,11 +188,25 @@ public class ShareAdvanceCollectionApiResource {
|
||||
// GET — Unapplied receipts, optionally for one client
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
@Transactional
|
||||
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
// 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(clientId);
|
||||
|
||||
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 "
|
||||
+ " cl.display_name AS client_name, "
|
||||
+ " c.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' ");
|
||||
if (clientId != null) {
|
||||
sql.append(" AND c.client_id = ").append(clientId);
|
||||
@@ -231,7 +247,8 @@ public class ShareAdvanceCollectionApiResource {
|
||||
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
|
||||
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 "
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
|
||||
+ " c.applied_share_account_id "
|
||||
+ " 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 +288,42 @@ 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");
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT ga.id, ga.gl_code, ga.name"
|
||||
+ " FROM acc_gl_account ga"
|
||||
+ " WHERE ga.disabled = false AND ga.account_usage = 1"
|
||||
+ " AND ("
|
||||
+ " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id"
|
||||
+ " AND d.status = 'APPROVED' 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))"
|
||||
+ " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id"
|
||||
+ " 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))"
|
||||
+ " )"
|
||||
+ " ORDER BY ga.gl_code");
|
||||
if (rows.isEmpty()) {
|
||||
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 +356,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
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@@ -472,14 +553,26 @@ 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.
|
||||
for (final Map<String, Object> row : collections) {
|
||||
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
|
||||
insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), 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,
|
||||
@@ -595,6 +688,108 @@ 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, under any circumstances -- attributing it to "whichever member's
|
||||
* screen happened to be open when this ran" is a coincidence of timing, not evidence of whose
|
||||
* money it is, and would silently hand one member's cash to a completely unrelated member simply
|
||||
* because their Apply Share screen loaded first. A clientless receipt can only ever be claimed
|
||||
* through the manual by-voucher search, where a person at the counter is actively vouching for it
|
||||
* against a member they can see, by quoting the specific voucher number the member is holding --
|
||||
* a deliberate, checkable act, not an automatic guess. 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 still only adopted against a matching client_id, never a different
|
||||
* member than the one asking.
|
||||
*/
|
||||
private void autoAdoptShareCollectionTagged(final Long clientIdFilter) {
|
||||
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"));
|
||||
// Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match
|
||||
// who is asking -- never adopted for a different member than the one whose screen it is.
|
||||
if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) {
|
||||
continue;
|
||||
}
|
||||
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 +799,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,"
|
||||
+ " 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 +815,23 @@ 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), precisely so a manager here can never
|
||||
// pick up money that was really meant for something else (a Demand Draft, say) just because
|
||||
// it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows
|
||||
// from before this tag existed) are still adoptable -- only an explicit, different purpose
|
||||
// blocks it.
|
||||
final String depositEntryType = (String) dep.get("entry_type");
|
||||
if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
|
||||
"Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. "
|
||||
+ "Choose a different voucher, or ask the cashier to correct the entry type.");
|
||||
}
|
||||
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;
|
||||
@@ -643,7 +857,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
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"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||
receiptNo);
|
||||
}
|
||||
@@ -665,16 +879,27 @@ 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 counter picked by hand, so it carries the identical ambiguity.
|
||||
final String reqEntryType = (String) req.get("entry_type");
|
||||
if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
|
||||
"Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. "
|
||||
+ "Choose a different voucher, or ask the clerk to correct the entry type.");
|
||||
}
|
||||
|
||||
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
|
||||
if (savingsAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
||||
@@ -692,16 +917,20 @@ 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"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||
receiptNo);
|
||||
}
|
||||
@@ -809,6 +1038,37 @@ 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+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,
|
||||
|
||||
+140
-20
@@ -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,6 +151,24 @@ 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.
|
||||
@@ -180,21 +204,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 +312,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 +379,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,6 +503,11 @@ 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).
|
||||
@@ -538,6 +573,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);
|
||||
@@ -683,6 +719,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (disbGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
|
||||
}
|
||||
approvedGlTxnId = disbGlTxnId;
|
||||
}
|
||||
|
||||
// ── Collection GL entry ───────────────────────────────────────────────
|
||||
@@ -705,6 +742,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (collGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
|
||||
}
|
||||
approvedGlTxnId = collGlTxnId;
|
||||
} else {
|
||||
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
|
||||
if (cashVaultGlId != null) {
|
||||
@@ -756,11 +794,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);
|
||||
@@ -1107,6 +1145,34 @@ 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);
|
||||
}
|
||||
|
||||
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
|
||||
final String requestType) {
|
||||
|
||||
@@ -1124,12 +1190,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 +1220,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 +1253,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 +1276,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()) {
|
||||
@@ -1242,6 +1335,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 +1426,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();
|
||||
@@ -1402,9 +1510,12 @@ 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();
|
||||
}
|
||||
@@ -1417,8 +1528,11 @@ public class ClerkDisbursementRequestApiResource {
|
||||
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
|
||||
for (final GlEntry e : entries) {
|
||||
if (e.txnId != null) {
|
||||
// Guarded by reversed = false: if transaction_id text is ever reused, this can
|
||||
// only flip rows that are currently live, never re-touch an already-reversed
|
||||
// unrelated transaction's rows.
|
||||
jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
|
||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
|
||||
e.txnId);
|
||||
}
|
||||
}
|
||||
@@ -1429,7 +1543,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
for (final GlEntry e : entries) {
|
||||
if (e.txnId != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
|
||||
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
|
||||
e.txnId);
|
||||
}
|
||||
}
|
||||
@@ -1442,9 +1556,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) {
|
||||
|
||||
+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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-5
@@ -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,
|
||||
|
||||
+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())
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -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;
|
||||
@@ -520,7 +522,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 +573,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);
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+5
-3
@@ -2079,7 +2079,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
|
||||
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
||||
final Map<String, Object> expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
|
||||
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
|
||||
|
||||
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
||||
@@ -2100,8 +2099,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
||||
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
|
||||
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
||||
.withDescription("Interest posted to " + accountNo),
|
||||
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
||||
.withDescription("Interest posted to " + accountNo),
|
||||
// The member's own savings account leg, not a GL posting -- creditGl(
|
||||
// controlGlId, ...) named the shared Savings Control GL account here,
|
||||
// mismatched against this account's own number on the same line.
|
||||
VoucherLine.creditSavings(account.getId(), partyName, accountNo, amount)
|
||||
.withDescription("Interest credited")));
|
||||
// Without this the voucher exists but nothing ties it to the transaction it paid, so
|
||||
// the register cannot show the two together and the transaction shows no voucher_no.
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ public class ShareAdditionRequestsApiResource {
|
||||
final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||
return this.jdbcTemplate.queryForList("""
|
||||
SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate",
|
||||
collected_by_name AS "collectedByName", status
|
||||
collected_by_name AS "collectedByName", status, note
|
||||
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
|
||||
""".formatted(placeholders), ids.toArray());
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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.shareaccounts.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
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.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The Board of Resolution register for share capital: every share account with the resolution that
|
||||
* authorised it.
|
||||
*
|
||||
* <p>
|
||||
* A co-operative allots share capital only under a board resolution, and the register is what an
|
||||
* auditor or the Registrar asks for - one line per member showing the resolution number against the
|
||||
* shares it authorised. Served as a single query rather than assembled on the client, which had to
|
||||
* fetch each account separately and stopped at two hundred of them.
|
||||
*
|
||||
* <p>
|
||||
* Read-only; nothing here writes.
|
||||
*/
|
||||
@Path("/v1/shareaccounts/bor-register")
|
||||
@Component
|
||||
@Tag(name = "Share BOR Register",
|
||||
description = "Share accounts with the board resolution that authorised them, for the BOR register.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareBorRegisterApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
/**
|
||||
* @param status
|
||||
* m_share_account.status_enum, to show only active or only closed members; omit for
|
||||
* every account whatever its status, which is what a register is
|
||||
* @param officeId
|
||||
* a single branch, for a branch-scoped login
|
||||
*/
|
||||
@GET
|
||||
@Operation(summary = "Share accounts with their board resolution",
|
||||
description = "One row per share account: member, branch, product, shares held, and the resolution number and date that authorised the allotment.")
|
||||
public Response retrieveRegister(@QueryParam("status") final Integer status,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
|
||||
// queryForList would key the maps "accountno" and "resolutionnumber" - which serialise
|
||||
// straight into the JSON and leave the client reading accountNo as undefined.
|
||||
//
|
||||
// Every date goes through to_char. Gson serialises a java.sql.Date with its default date
|
||||
// format, which produced "Sept 14, 2026" - a locale-dependent string the client cannot
|
||||
// parse, sort or compare, and which its ISO reader truncated to "Sept 14, 2". A date column
|
||||
// is sent as an ISO string so it reads the same wherever it lands.
|
||||
//
|
||||
// resolution_date is selected but is null on every row today: the column exists and the
|
||||
// frontend sends a value, but ShareAccount carries no @Column mapping for it, so the value
|
||||
// is dropped on save. The meeting date that IS written lives on the purchase transaction,
|
||||
// so it is read from there and the account's own date is used only as a fallback.
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT sa.id AS "shareAccountId",
|
||||
sa.account_no AS "accountNo",
|
||||
sa.client_id AS "clientId",
|
||||
c.account_no AS "cifNumber",
|
||||
c.display_name AS "clientName",
|
||||
o.id AS "officeId",
|
||||
o.name AS "officeName",
|
||||
sp.name AS "productName",
|
||||
sa.currency_code AS "currencyCode",
|
||||
sa.status_enum AS "statusEnum",
|
||||
sa.total_approved_shares AS "approvedShares",
|
||||
sa.total_pending_shares AS "pendingShares",
|
||||
sp.unit_price AS "unitPrice",
|
||||
COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue",
|
||||
sa.resolution_number AS "resolutionNumber",
|
||||
to_char(COALESCE(sa.resolution_date, txn.first_meeting_date), 'YYYY-MM-DD') AS "resolutionDate",
|
||||
txn.tranche_count AS "trancheCount",
|
||||
to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate",
|
||||
to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate",
|
||||
to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate",
|
||||
to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate"
|
||||
FROM m_share_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
|
||||
LEFT JOIN (
|
||||
SELECT t.account_id,
|
||||
MIN(t.meeting_date) AS first_meeting_date,
|
||||
COUNT(*) AS tranche_count
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.is_active = true AND t.type_enum = 500
|
||||
GROUP BY t.account_id
|
||||
) txn ON txn.account_id = sa.id
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (status != null) {
|
||||
sql.append(" AND sa.status_enum = ? ");
|
||||
args.add(status);
|
||||
}
|
||||
if (officeId != null) {
|
||||
sql.append(" AND o.id = ? ");
|
||||
args.add(officeId);
|
||||
}
|
||||
// Resolution first: the register is read by resolution, and an account with no resolution
|
||||
// recorded is the thing an auditor is looking for, so those sort to the top rather than
|
||||
// being buried at the end of the list.
|
||||
sql.append(" ORDER BY sa.resolution_number NULLS FIRST, sa.account_no ");
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||
return Response.ok(gson.toJson(rows)).build();
|
||||
}
|
||||
}
|
||||
+48
@@ -109,6 +109,18 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "resolution_number", nullable = false)
|
||||
private String resolutionNumber;
|
||||
|
||||
/**
|
||||
* Date of the board meeting that passed the resolution above.
|
||||
*
|
||||
* <p>
|
||||
* The column has existed since 0470 and the frontend has been sending a value all along, but
|
||||
* nothing mapped it - so every allotment was saved with the resolution number and without the
|
||||
* date it was passed on, and the BOR register had nothing to show the Registrar. Nullable
|
||||
* because the accounts already on file carry no date and cannot be given one retrospectively.
|
||||
*/
|
||||
@Column(name = "resolution_date")
|
||||
private LocalDate resolutionDate;
|
||||
|
||||
@Column(name = "account_no", length = 20, unique = true, nullable = false)
|
||||
private String accountNumber;
|
||||
|
||||
@@ -260,6 +272,19 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public LocalDate getResolutionDate() {
|
||||
return this.resolutionDate;
|
||||
}
|
||||
|
||||
public boolean setResolutionDate(final LocalDate resolutionDate) {
|
||||
boolean toReturn = false;
|
||||
if (this.resolutionDate == null || !this.resolutionDate.equals(resolutionDate)) {
|
||||
this.resolutionDate = resolutionDate;
|
||||
toReturn = true;
|
||||
}
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public boolean setSavingsAccount(final SavingsAccount savingsAccount) {
|
||||
boolean returnValue = false;
|
||||
if (!this.savingsAccount.getId().equals(savingsAccount.getId())) {
|
||||
@@ -537,8 +562,31 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
this.totalSharesPending = shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds this account's own instance of a transaction, after a save has replaced the caller's copy.
|
||||
*
|
||||
* <p>
|
||||
* Matched on identity where there is one. The callers run this right after {@code saveAndFlush},
|
||||
* by which point the transaction has been assigned its id, and matching on that is exact. The
|
||||
* field comparison below is the fallback for a transaction that has not been persisted yet.
|
||||
*
|
||||
* <p>
|
||||
* The fallback cannot stand on its own, because the fields it compares do not identify a
|
||||
* transaction: two redemptions of the same number of shares, at the same price, on the same day
|
||||
* are identical in every one of them. When that happened the loop returned the <i>earlier</i> of
|
||||
* the two and the caller went on to treat the new transaction as that one -- posting the second
|
||||
* redemption's journal entries under the first one's transaction id, so one share transaction
|
||||
* appeared to carry two redemptions and its voucher showed double the money that actually moved.
|
||||
*/
|
||||
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
|
||||
ShareAccountTransaction returnTrans = null;
|
||||
if (transaction.getId() != null) {
|
||||
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
|
||||
if (transaction.getId().equals(tran.getId())) {
|
||||
return tran;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
|
||||
// A charge/fee transaction carries no share count or unit price at all (both columns are
|
||||
// null), so this account's own transaction list is not uniformly non-null the way a
|
||||
|
||||
+61
-15
@@ -238,6 +238,14 @@ public class ShareAccountDataSerializer {
|
||||
minimumActivePeriod, minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy,
|
||||
rejectedDate, activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate);
|
||||
|
||||
// Set after construction rather than threaded through the constructor: it already takes
|
||||
// twenty-eight arguments, and every caller would have to change to carry one optional date.
|
||||
final LocalDate resolutionDate = this.fromApiJsonHelper
|
||||
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
|
||||
if (resolutionDate != null) {
|
||||
account.setResolutionDate(resolutionDate);
|
||||
}
|
||||
|
||||
for (ShareAccountTransaction pur : sharesPurchased) {
|
||||
pur.setShareAccount(account);
|
||||
}
|
||||
@@ -326,6 +334,14 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.resolutiondate_paramname, element)) {
|
||||
final LocalDate resolutionDate = this.fromApiJsonHelper
|
||||
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
|
||||
if (account.setResolutionDate(resolutionDate)) {
|
||||
actualChanges.put(ShareAccountApiConstants.resolutiondate_paramname, resolutionDate);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
|
||||
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
|
||||
if (account.setLinkedLoanId(linkedLoanId)) {
|
||||
@@ -783,6 +799,15 @@ public class ShareAccountDataSerializer {
|
||||
advanceCollectionIds = extractAdvanceCollectionIds(element);
|
||||
if (advanceCollectionIds.isEmpty()) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
|
||||
} else if (advanceCollectionIds.size() > 1) {
|
||||
// Additional-share tranches fund from exactly one receipt for now -- combining
|
||||
// instalments across several is the first-application Apply Collection dialog's own
|
||||
// behaviour, not this one's, until this screen is asked to support it too.
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.additionalcollectionids_paramname)
|
||||
.value(advanceCollectionIds.size())
|
||||
.failWithCodeAndMessage("share.tranche.single.voucher.only",
|
||||
"Only one voucher can currently fund an additional-share tranche. " + advanceCollectionIds.size()
|
||||
+ " were submitted.");
|
||||
} else {
|
||||
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
|
||||
}
|
||||
@@ -851,7 +876,7 @@ public class ShareAccountDataSerializer {
|
||||
advanceCollectionId);
|
||||
if (rows.isEmpty()) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||
.failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found.");
|
||||
.failWithCodeAndMessage("share.advance.collection.not.found", "A voucher for this payment could not be found.");
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> collection = rows.get(0);
|
||||
@@ -862,7 +887,7 @@ public class ShareAccountDataSerializer {
|
||||
final Number collectionClientId = (Number) collection.get("client_id");
|
||||
if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||
.failWithCode("share.advance.collection.client.mismatch",
|
||||
.failWithCodeAndMessage("share.advance.collection.client.mismatch",
|
||||
"Voucher " + label + " belongs to a different member.");
|
||||
}
|
||||
|
||||
@@ -871,13 +896,13 @@ public class ShareAccountDataSerializer {
|
||||
final String status = (String) collection.get("status");
|
||||
if (!"UNAPPLIED".equals(status)) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||
.failWithCode("share.advance.collection.not.unapplied",
|
||||
.failWithCodeAndMessage("share.advance.collection.not.unapplied",
|
||||
"Voucher " + label + " is no longer available — it reads " + status + ".");
|
||||
}
|
||||
|
||||
if (isReservedByAnotherTranche(advanceCollectionId)) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||
.failWithCode("share.advance.collection.already.reserved",
|
||||
.failWithCodeAndMessage("share.advance.collection.already.reserved",
|
||||
"Voucher " + label + " is already reserved against another pending share request.");
|
||||
}
|
||||
|
||||
@@ -891,8 +916,21 @@ public class ShareAccountDataSerializer {
|
||||
.multiply(BigDecimal.valueOf(sharesRequested));
|
||||
if (receiptTotal.compareTo(trancheValue) < 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
|
||||
.failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
|
||||
.failWithCodeAndMessage("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
|
||||
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
|
||||
} else if (receiptTotal.compareTo(trancheValue) > 0 && account.getSavingsAccount() == null) {
|
||||
// An over-payment is settled by crediting the difference to the member's linked savings
|
||||
// account (see AdditionalShareTrancheFundingService.postSharesIssued) -- with none linked,
|
||||
// approval would have nowhere to put it. Caught here, at submit, rather than left to fail
|
||||
// once the maker has already gone: short of a member paying the exact figure, this is
|
||||
// refused up front instead of accepted and then unable to settle.
|
||||
final BigDecimal excessAmount = receiptTotal.subtract(trancheValue);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
|
||||
.failWithCodeAndMessage("share.advance.collection.excess.no.savings",
|
||||
"The vouchers selected come to " + receiptTotal + ", which is " + excessAmount + " more than the "
|
||||
+ trancheValue + " value of the shares requested. The extra " + excessAmount
|
||||
+ " must be credited to the member's savings account, but none is linked to this share account. "
|
||||
+ "Link one, or collect the exact amount.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,7 +1234,12 @@ public class ShareAccountDataSerializer {
|
||||
private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
|
||||
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long minimumClientShares = account.getShareProduct().getMinimumClientShares();
|
||||
if (minimumClientShares == null || remainingShares >= minimumClientShares) {
|
||||
// The minimum only protects an account that is staying open with a reduced holding -- it has
|
||||
// nothing to say about one being emptied out entirely. A member redeeming every share they
|
||||
// hold is exiting, not falling short of a floor; the account closes right behind this
|
||||
// transaction (see closeShareAccountIfSharesZero), and there is no remaining balance left for
|
||||
// "at least the minimum" to mean anything about.
|
||||
if (minimumClientShares == null || remainingShares == 0 || remainingShares >= minimumClientShares) {
|
||||
return;
|
||||
}
|
||||
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
|
||||
@@ -1301,17 +1344,20 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
|
||||
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
|
||||
// Only synthesize a redeem transaction (and its GL posting) when shares actually remain to
|
||||
// be redeemed — an account closed after every share was already withdrawn has nothing left
|
||||
// to force-redeem, and posting a zero-share/zero-value transaction just clutters the GL with
|
||||
// a meaningless ₹0.00/₹0.00 entry pair.
|
||||
// A close with shares still outstanding used to force-redeem them right here -- one native
|
||||
// command, no maker-checker, liquidating the member's holding and crediting cash back to the
|
||||
// GL Fineract's own bridge names, all in the single step of closing the account. That is
|
||||
// exactly the governed Share Withdrawal flow's job: a clerk raises the redemption, a manager
|
||||
// approves it, and it settles to wherever the clerk actually keyed -- the member's savings, a
|
||||
// chosen GL, or Sundry. Refusing here instead of quietly redeeming is what makes closing an
|
||||
// account with a balance impossible to do any way other than through that flow; the frontend
|
||||
// already hid its own "Close" button for the same reason, but the command underneath was still
|
||||
// reachable directly and this is what actually closes that gap.
|
||||
final Long remainingShares = account.getTotalApprovedShares();
|
||||
if (remainingShares != null && remainingShares > 0) {
|
||||
final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, remainingShares, unitPrice);
|
||||
account.addAdditionalPurchasedShares(transaction);
|
||||
handleRedeemSharesChargeTransactions(account, transaction);
|
||||
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname).value(closedDate)
|
||||
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.with.shares.outstanding");
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
account.close(closedDate, approvedUser);
|
||||
return actualChanges;
|
||||
|
||||
+341
-213
@@ -18,20 +18,18 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.shareaccounts.service;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
@@ -41,11 +39,13 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransactio
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Funds one additional-share tranche — moves the member's money into the Share Sundry holding
|
||||
* account, where it waits for the tranche to be approved.
|
||||
* Funds one additional-share tranche — records how the member is paying for it, and turns that
|
||||
* money into share capital once the tranche is approved.
|
||||
*
|
||||
* <p>
|
||||
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
|
||||
@@ -55,35 +55,33 @@ import org.springframework.stereotype.Component;
|
||||
* valued and recorded per transaction, and leaves that account-level state alone.
|
||||
*
|
||||
* <p>
|
||||
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
|
||||
* the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by
|
||||
* {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only
|
||||
* gets the money into Sundry in the first place.
|
||||
* <b>What is posted, and when.</b> Nothing is posted when a tranche is raised — a request that is never approved
|
||||
* never touches anybody's money. {@code settle()} books the whole thing at approval, and the entry is the transfer the
|
||||
* clerk actually keyed:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Cash</b> — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche
|
||||
* leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for
|
||||
* money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.</li>
|
||||
* <li><b>Savings</b> — not there yet, so this debits the member:
|
||||
* <li><b>Savings</b> — the member is debited and share capital issued, in one pair:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Control (tranche value)
|
||||
* Cr Share Sundry (tranche value)
|
||||
* Cr Share Capital (tranche value)
|
||||
* </pre>
|
||||
*
|
||||
* The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal
|
||||
* command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts
|
||||
* its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are
|
||||
* reversed and replaced with the pair above.</li>
|
||||
* Their balance is checked on submit so a request that cannot be paid for is refused up front, but the money only
|
||||
* leaves when the shares are issued. The debit is written straight into their account rather than through Fineract's
|
||||
* withdrawal command, which would post a cash pair for cash that never moves.</li>
|
||||
* <li><b>Voucher</b> — the money is already in whichever account the cashier banked the receipt into, so that account
|
||||
* is debited and share capital credited. Submitting only reserves the receipt against this tranche so it cannot be
|
||||
* spent twice.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Note that Fineract's stock accounting <i>also</i> posts an APPLIED leg of its own for a new purchase
|
||||
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route
|
||||
* here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those
|
||||
* entries when the voucher was issued, and on the savings route the money came from the member's account rather than the
|
||||
* till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once
|
||||
* by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once.
|
||||
* Fineract's stock accounting would post an APPLIED leg of its own when the tranche is raised
|
||||
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now, and an APPROVED
|
||||
* leg out of the product's mapped Sundry account. Neither matches either route here, so neither is made for a funded
|
||||
* tranche (see {@code ShareAccountWritePlatformServiceJpaRepositoryImpl}). They used to be posted and reversed a moment
|
||||
* later, which left dead rows in the ledger on every tranche and a Shares Reference leg that had to be explained to
|
||||
* anyone reading the account.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -94,22 +92,21 @@ public class AdditionalShareTrancheFundingService {
|
||||
private static final int DEBIT = 2;
|
||||
|
||||
/** 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_SHARES_EQUITY = 4;
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
/** What Fineract's own bridge prefixes a share transaction's journal entries with. */
|
||||
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;
|
||||
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
/** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */
|
||||
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
/**
|
||||
* Funds a freshly raised tranche according to the route recorded on it.
|
||||
@@ -125,20 +122,26 @@ public class AdditionalShareTrancheFundingService {
|
||||
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||
reserveAdvanceCollection(account, transaction);
|
||||
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
|
||||
debitSavingsIntoSundry(account, transaction);
|
||||
validateSavingsFunding(account, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a tranche's funding once the tranche has been approved and its value released from
|
||||
* Share Sundry into share capital.
|
||||
* Turns an approved tranche's funding into share capital, and closes the funding record behind it.
|
||||
*
|
||||
* No journal entries of its own — Fineract's APPROVED posting already moved the money. This only
|
||||
* closes the funding record, so a voucher whose money is now share capital cannot be presented
|
||||
* again: until this point the receipt was merely reserved, because approval was still refusable.
|
||||
* Posts the whole entry itself rather than leaving it to the platform, because only this knows
|
||||
* where the money actually is. Closing the record last means a voucher whose money is now share
|
||||
* capital cannot be presented again: until this point the receipt was merely reserved, because
|
||||
* approval was still refusable.
|
||||
*/
|
||||
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
|
||||
final String fundingSource = transaction.getFundingSource();
|
||||
if (fundingSource == null) {
|
||||
return;
|
||||
}
|
||||
postSharesIssued(account, transaction);
|
||||
|
||||
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||
return;
|
||||
}
|
||||
// Every receipt behind the tranche, not just the lead one: a member's later instalments have
|
||||
@@ -153,21 +156,170 @@ public class AdditionalShareTrancheFundingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwinds the funding of a tranche that was rejected, so the member's money does not sit in
|
||||
* Share Sundry against shares that will never be issued.
|
||||
* Issues the share capital for an approved tranche: the money leaves wherever it has been sitting
|
||||
* and becomes equity.
|
||||
*
|
||||
* Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits
|
||||
* Shares Reference, the till, which is only correct for the cash route. The routes part company
|
||||
* here:
|
||||
* <p>
|
||||
* The credit is always Share Capital. What the debit is depends on where the money actually is,
|
||||
* which is the whole point of posting this here rather than letting the platform do it:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Cash</b> — nothing to do. The money genuinely came in as cash, so returning it to the
|
||||
* reference account is right, and releasing the receipt's reservation lets the clerk spend the
|
||||
* voucher on another tranche or refund it at the counter.</li>
|
||||
* <li><b>Savings</b> — the reference credit is wrong: no cash is going back over the counter.
|
||||
* It is reversed and the member's savings are credited instead, which is where the money came
|
||||
* from on submit.</li>
|
||||
* <li><b>Savings-funded</b> - this service moved it into Share Sundry when the tranche was
|
||||
* submitted, so Sundry is where it is and Sundry is what clears.</li>
|
||||
* <li><b>Voucher-funded</b> - it never moved. It is still in whichever account the cashier banked
|
||||
* the receipt into, read back from that receipt's own posting. The platform would have debited the
|
||||
* product's mapped Sundry account instead, taking money out of an account that never received it
|
||||
* and leaving the one that did holding the balance for ever.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Receipts are drawn down in order, each capped at what it actually holds, so a tranche paid for
|
||||
* in instalments clears each receipt for its own share. Anything the receipts do not cover falls
|
||||
* back to Sundry, which keeps the entry balanced rather than silently posting a one-sided one.
|
||||
*
|
||||
* <p>
|
||||
* Whatever a receipt holds beyond what the tranche needed — a member overpaying the lead receipt,
|
||||
* or naming a further instalment that turns out not to be needed at all — is credited to their
|
||||
* linked savings account, receipt by receipt, rather than left standing: every named receipt is
|
||||
* marked APPLIED at the end of {@code settle()} regardless of whether the drawdown below actually
|
||||
* reached it, so any part of one that this method does not move becomes untraceable money the
|
||||
* moment that happens — still sitting in whatever GL the cashier banked it to, but with no
|
||||
* UNAPPLIED receipt left pointing back at it.
|
||||
*/
|
||||
private void postSharesIssued(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
final BigDecimal amount = transaction.amount();
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
final Long productId = account.getShareProduct().getId();
|
||||
final Long officeId = account.getOfficeId();
|
||||
final String currencyCode = account.getCurrency().getCode();
|
||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String txnId = SHARE_TXN_ID_PREFIX + transaction.getId();
|
||||
final String accountNo = account.getAccountNumber();
|
||||
|
||||
if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(transaction.getFundingSource())) {
|
||||
// The member pays for the shares at the moment they are issued, not when the request was
|
||||
// raised: their balance is checked on submit but nothing is taken, so a tranche that is
|
||||
// never approved never touches their account. One pair, Dr their savings / Cr share
|
||||
// capital, which is exactly the transfer the clerk keyed.
|
||||
final Long savingsAccountId = account.getSavingsAccount().getId();
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
final Long savingsTxnId = debitSavingsAccount(savingsAccountId, officeId, amount, entryDate);
|
||||
transaction.updateSavingsDebitRequestId(savingsTxnId);
|
||||
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode,
|
||||
"Additional shares paid from savings | " + accountNo);
|
||||
insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
return;
|
||||
}
|
||||
|
||||
BigDecimal remaining = amount;
|
||||
BigDecimal excess = BigDecimal.ZERO;
|
||||
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
|
||||
// Every named receipt is visited, not just enough of them to cover the tranche: one left
|
||||
// untouched here would still be marked APPLIED at the end of settle(), so this is the only
|
||||
// chance to account for its money before that happens.
|
||||
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
|
||||
final Map<String, Object> coll = this.jdbcTemplate
|
||||
.queryForList("SELECT amount, gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (coll == null) {
|
||||
continue;
|
||||
}
|
||||
final BigDecimal held = (BigDecimal) coll.get("amount");
|
||||
final BigDecimal take = held.min(remaining.max(BigDecimal.ZERO));
|
||||
final BigDecimal leftover = held.subtract(take);
|
||||
final Long receiptGl = receiptGlOf((String) coll.get("gl_transaction_id"), glSundry);
|
||||
if (take.compareTo(BigDecimal.ZERO) > 0) {
|
||||
insertEntry(officeId, receiptGl, DEBIT, take, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode, "Additional shares issued | " + accountNo);
|
||||
remaining = remaining.subtract(take);
|
||||
}
|
||||
if (leftover.compareTo(BigDecimal.ZERO) > 0) {
|
||||
// This receipt held more than the tranche needed. Its own GL still has to be
|
||||
// cleared for the leftover -- the receipt is about to read APPLIED in full -- so
|
||||
// that leftover is drawn down here too, and handed back below as a savings credit
|
||||
// rather than left to vanish along with the receipt's UNAPPLIED status.
|
||||
insertEntry(officeId, receiptGl, DEBIT, leftover, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode, "Excess on additional shares receipt refunded | " + accountNo);
|
||||
excess = excess.add(leftover);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
|
||||
insertEntry(officeId, glSundry, DEBIT, remaining, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode,
|
||||
"Additional shares issued from Share Sundry | " + accountNo);
|
||||
}
|
||||
|
||||
insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
|
||||
if (excess.compareTo(BigDecimal.ZERO) > 0) {
|
||||
// Validated at submit (see ShareAccountDataSerializer.validateAdvanceCollectionsForTranche):
|
||||
// a tranche cannot be raised with receipts totalling more than its value unless a savings
|
||||
// account is linked to receive the difference. Checked again here rather than trusted,
|
||||
// since this also runs for a tranche raised before that guard existed.
|
||||
if (account.getSavingsAccount() == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.excess.no.savings",
|
||||
"This tranche's receipts total " + excess
|
||||
+ " more than the shares cost, but no savings account is linked to credit it to.");
|
||||
}
|
||||
final Long savingsAccountId = account.getSavingsAccount().getId();
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
// Not recorded on transaction.savingsDebitRequestId: that field means the savings-funded
|
||||
// route's own debit, a different transaction to a different end -- overloading it here for
|
||||
// a cash-funded tranche's incidental credit would misname what it actually is.
|
||||
creditSavingsAccount(savingsAccountId, officeId, excess, entryDate);
|
||||
insertEntry(officeId, glSavingsControl, CREDIT, excess, txnId, entryDate, account.getId(), transaction.getId(), userId, now,
|
||||
currencyCode, "Excess on additional shares receipts credited to savings | " + accountNo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a receipt's money is actually sitting in, read back from the posting that put it
|
||||
* there - the credit leg of that receipt's own journal transaction. Falls back to the product's
|
||||
* Sundry account for a receipt with no posting recorded against it.
|
||||
*/
|
||||
private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
|
||||
if (glTransactionId == null || glTransactionId.isBlank()) {
|
||||
return fallbackGlId;
|
||||
}
|
||||
final List<Long> rows = this.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwinds the funding of a rejected tranche.
|
||||
*
|
||||
* <p>
|
||||
* Nothing was ever debited on submit — {@link #postSharesIssued} is where the money actually
|
||||
* moves, and that never ran, because approval is what would have called it — so there is nothing
|
||||
* to give back. The only route with a submit-time side effect is cash, where the receipt was
|
||||
* earmarked against this account; releasing that lets the clerk spend the voucher on another
|
||||
* tranche or refund it at the counter. Savings has none, so it is a no-op here.
|
||||
*/
|
||||
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
final String fundingSource = transaction.getFundingSource();
|
||||
@@ -176,9 +328,9 @@ public class AdditionalShareTrancheFundingService {
|
||||
}
|
||||
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||
releaseAdvanceCollection(transaction);
|
||||
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
|
||||
refundSavings(account, transaction);
|
||||
}
|
||||
// A savings-funded tranche has nothing to unwind: the member is only debited when the shares
|
||||
// are issued, so a rejected one never took their money in the first place.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,85 +344,129 @@ public class AdditionalShareTrancheFundingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rejected tranche's money to the savings account it was taken from.
|
||||
*/
|
||||
private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
if (account.getSavingsAccount() == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal amount = transaction.amount();
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
final Long savingsAccountId = account.getSavingsAccount().getId();
|
||||
final Long productId = account.getShareProduct().getId();
|
||||
final Long officeId = account.getOfficeId();
|
||||
final String currencyCode = account.getCurrency().getCode();
|
||||
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String note = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber();
|
||||
|
||||
// Fineract's own deposit command, so the refund reaches the member's passbook and statement
|
||||
// as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
|
||||
// over the counter — so the cash debit is replaced by a Sundry debit below.
|
||||
final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||
|
||||
// Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
|
||||
// till, so that credit is reversed; the Sundry debit it raised alongside is correct and
|
||||
// stands.
|
||||
reverseStockReferenceCredit(transaction.getId(), productId, officeId);
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHTRREF-" + transaction.getId();
|
||||
|
||||
insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||
"Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this
|
||||
* tranche, leaving its Sundry debit standing.
|
||||
*/
|
||||
private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) {
|
||||
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
|
||||
reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses this tranche's own unreversed entries on one GL account and one side.
|
||||
* Takes the tranche value out of the member's savings account as a book transfer: the transaction
|
||||
* row and the balance, written directly, with no journal entry of its own.
|
||||
*
|
||||
* Matched on {@code share_transaction_id} rather than the {@code SH<id>} transaction_id string,
|
||||
* because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a
|
||||
* single GL and side is what keeps each reversal to the posting it is meant to undo.
|
||||
* <p>
|
||||
* Fineract's withdrawal command would post its own Dr Savings Control / Cr Cash pair, and no cash
|
||||
* leaves the branch here - the money becomes share capital. That pair used to be posted and then
|
||||
* reversed a moment later, leaving two dead rows in the ledger on every tranche. The caller books
|
||||
* the real pair itself; this only moves the member's own balance.
|
||||
*
|
||||
* <p>
|
||||
* The passbook and statement are unaffected: both read {@code m_savings_account_transaction},
|
||||
* which is what this writes. Interest recalculation, lien and overdraft checks and business events
|
||||
* are the trade-off, as on the disbursement register's own book transfers.
|
||||
*/
|
||||
private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) {
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE acc_gl_journal_entry SET reversed = true
|
||||
WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false
|
||||
""", shareTransactionId, glAccountId, officeId, typeEnum);
|
||||
private Long debitSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
|
||||
final LocalDate entryDate) {
|
||||
final Map<String, Object> acct = this.jdbcTemplate
|
||||
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (acct == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
|
||||
"The linked savings account " + savingsAccountId + " no longer exists.");
|
||||
}
|
||||
if (((Number) acct.get("status_enum")).intValue() != SAVINGS_STATUS_ACTIVE) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
|
||||
"The linked savings account is not active.");
|
||||
}
|
||||
final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
|
||||
: BigDecimal.ZERO;
|
||||
if (balance.compareTo(amount) < 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
|
||||
"The linked savings account holds " + balance + ", which is less than the " + amount + " these shares cost.");
|
||||
}
|
||||
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
|
||||
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
final long userId = this.context.authenticatedUser().getId();
|
||||
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
this.jdbcTemplate.update(con -> {
|
||||
// transaction_type_enum 2 = withdrawal.
|
||||
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, savingsAccountId);
|
||||
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);
|
||||
|
||||
this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
|
||||
+ "total_withdrawals_derived = total_withdrawals_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", txnDate.toString());
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
|
||||
payload.addProperty("note", note);
|
||||
/**
|
||||
* Credits the member's savings account as a book transfer, with no journal entry of its own — the
|
||||
* counterpart to {@link #debitSavingsAccount} for the one case that runs the other way: a cash-funded
|
||||
* tranche whose receipts added up to more than it cost, where the difference has nowhere else to go
|
||||
* but back to the member.
|
||||
*
|
||||
* <p>
|
||||
* Written directly for the same reason as the debit side: Fineract's deposit command would post its
|
||||
* own Dr Cash / Cr Savings Control pair, and no cash is arriving at the counter here — the money was
|
||||
* already in Share Sundry (or wherever the receipt's own GL was), and this is only moving the
|
||||
* member's own balance to reflect it. No balance/status checks beyond existence: crediting an
|
||||
* account can never leave it short of anything, unlike a debit.
|
||||
*/
|
||||
private Long creditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount,
|
||||
final LocalDate entryDate) {
|
||||
final Map<String, Object> acct = this.jdbcTemplate
|
||||
.queryForList("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (acct == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing",
|
||||
"The linked savings account " + savingsAccountId + " no longer exists.");
|
||||
}
|
||||
final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived")
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId();
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate);
|
||||
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
final long userId = this.context.authenticatedUser().getId();
|
||||
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
this.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, balance.add(amount));
|
||||
ps.setLong(8, userId);
|
||||
ps.setLong(9, userId);
|
||||
ps.setObject(10, now);
|
||||
ps.setObject(11, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
|
||||
this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
|
||||
+ "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId);
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,12 +484,10 @@ public class AdditionalShareTrancheFundingService {
|
||||
"A cash-funded share request must name the voucher the money was received on.");
|
||||
}
|
||||
|
||||
// Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new
|
||||
// purchase. On this route the cashier already posted exactly that pair when the voucher was
|
||||
// issued, so leaving it would count the member's money twice — Sundry credited 200 against a
|
||||
// 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed:
|
||||
// submitting a cash-funded tranche moves no money, it only earmarks the receipt.
|
||||
reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId());
|
||||
// No accounting at all on this route. The cashier already posted the money when the voucher
|
||||
// was issued, and submitting a cash-funded tranche moves none of it -- it only earmarks that
|
||||
// receipt for this account. Fineract's own APPLIED posting, which would have booked the same
|
||||
// money a second time, is not made for a funded tranche (see applyAddtionalShares).
|
||||
|
||||
for (final Long collectionId : collectionIds) {
|
||||
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
|
||||
@@ -304,7 +498,7 @@ public class AdditionalShareTrancheFundingService {
|
||||
/**
|
||||
* Savings route: debits the member and credits Share Sundry.
|
||||
*/
|
||||
private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
private void validateSavingsFunding(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||
if (account.getSavingsAccount() == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings",
|
||||
"This share account has no linked savings account to debit.");
|
||||
@@ -327,85 +521,10 @@ public class AdditionalShareTrancheFundingService {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
|
||||
"Savings account " + savings.get("account_no") + " does not hold enough to cover this share request.");
|
||||
}
|
||||
|
||||
final Long productId = account.getShareProduct().getId();
|
||||
final Long officeId = account.getOfficeId();
|
||||
final String currencyCode = account.getCurrency().getCode();
|
||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
if (savingsControlGlOf(savingsAccountId) == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String note = "Additional shares funded from savings | share account " + account.getAccountNumber();
|
||||
|
||||
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
|
||||
// pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
|
||||
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||
|
||||
// Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
|
||||
// assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
|
||||
// leaving its Sundry credit standing and then posting the savings pair below would credit
|
||||
// Sundry twice — 200 held against a 100 debit — and the surplus would never clear.
|
||||
reverseStockAppliedPair(transaction.getId(), productId, officeId);
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHTRSAV-" + transaction.getId();
|
||||
|
||||
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||
"Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber());
|
||||
insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||
"Additional shares held in Share Sundry pending approval | " + account.getAccountNumber());
|
||||
|
||||
transaction.updateSavingsDebitRequestId(savingsTxnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this
|
||||
* tranche, leaving its Sundry credit standing.
|
||||
*
|
||||
* Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so
|
||||
* the Sundry-to-Share-Capital pair raised later at approval is never caught by it.
|
||||
*/
|
||||
private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) {
|
||||
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT);
|
||||
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"),
|
||||
CREDIT);
|
||||
}
|
||||
|
||||
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
|
||||
final String note) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", txnDate.toString());
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
|
||||
payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId();
|
||||
}
|
||||
|
||||
private Long resolveSettlementPaymentTypeId() {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
|
||||
if (!rows.isEmpty()) {
|
||||
return toLong(rows.get(0).get("id"));
|
||||
}
|
||||
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
|
||||
if (fallback.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.payment.type",
|
||||
"No payment types are configured. Add one before funding shares from savings.");
|
||||
}
|
||||
return toLong(fallback.get(0).get("id"));
|
||||
}
|
||||
|
||||
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
|
||||
@@ -440,19 +559,28 @@ public class AdditionalShareTrancheFundingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@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 would have posted under the
|
||||
* same {@code SH<id>} transaction id, so they have to be indistinguishable from them to everything
|
||||
* that goes looking. Fineract's own {@code revertShareAccountJournalEntries} matches on the
|
||||
* 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 tranche behind it was 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(this.jdbcTemplate, glAccountId);
|
||||
this.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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -24,6 +24,8 @@ import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
@@ -134,7 +136,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVFORFEIT" + lockId;
|
||||
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT);
|
||||
// Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in —
|
||||
// a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each
|
||||
// year's declaration went unclaimed and came back to profit.
|
||||
|
||||
+56
-22
@@ -178,13 +178,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
if (!changes.isEmpty()) {
|
||||
this.shareAccountRepository.save(account);
|
||||
}
|
||||
// since we are reverting all journal entries we need to add journal
|
||||
// entries for application request
|
||||
// Modifying a pending application replaces its purchase transaction(s) with new ones of
|
||||
// the same shape, and only ever runs before the account has been approved -- at which
|
||||
// point nothing has been posted for it in the first place (see createShareAccount: no
|
||||
// cash has been collected yet, so there is nothing to post until
|
||||
// ShareAdvanceCollectionApiResource settles the real receipt, which requires the account
|
||||
// to already be Active). revertShareAccountJournalEntries is still called as a safety net
|
||||
// for any legacy account that predates that change and does carry a native posting for
|
||||
// its original transaction; it is a no-op where there is nothing to find. What it must
|
||||
// NOT do is post a fresh entry for the replacement transaction, which this used to do
|
||||
// unconditionally -- booking Dr Shares Reference (i.e. "cash received") for an
|
||||
// application nobody has paid for yet.
|
||||
if (changes.containsKey("reversalIds")) {
|
||||
ArrayList<Long> reversalIds = (ArrayList<Long>) changes.get("reversalIds");
|
||||
this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate);
|
||||
journalEntryWritePlatformService.createJournalEntriesForShares(
|
||||
populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
|
||||
changes.remove("reversalIds");
|
||||
}
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -217,9 +224,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId());
|
||||
Set<ShareAccountTransaction> transactions = new HashSet<>();
|
||||
transactions.add(transaction);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||
// After the stock APPLIED posting, not before: the savings route reverses that
|
||||
// posting's Shares Reference debit, which has to exist to be reversed.
|
||||
// The stock APPLIED posting assumes cash came over the counter for these shares,
|
||||
// and books Dr Shares Reference / Cr Share Sundry. A tranche funded through one of
|
||||
// this fork's own routes did not arrive that way: the cash route's money was
|
||||
// already taken at the till against a voucher, and the savings route debits the
|
||||
// member's own account. Both used to let this post and then reverse it a moment
|
||||
// later, leaving two dead rows behind on every tranche. Not posting it is the same
|
||||
// outcome without the wreckage -- fund() below books what actually happened.
|
||||
if (transaction.getFundingSource() == null) {
|
||||
this.journalEntryWritePlatformService
|
||||
.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||
}
|
||||
this.trancheFundingService.fund(account, transaction);
|
||||
}
|
||||
}
|
||||
@@ -297,15 +312,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
this.noteRepository.save(note);
|
||||
}
|
||||
}
|
||||
Set<ShareAccountTransaction> transactions = account.getShareAccountTransactions();
|
||||
Set<ShareAccountTransaction> journalTransactions = new HashSet<>();
|
||||
for (ShareAccountTransaction transaction : transactions) {
|
||||
if (transaction.isActive() && !transaction.isChargeTransaction()) {
|
||||
journalTransactions.add(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
|
||||
// Rejecting an application only ever happens before the account has been approved, at
|
||||
// which point nothing has been posted for it -- createShareAccount posts nothing, and the
|
||||
// real receipt is not booked until ShareAdvanceCollectionApiResource settles it, which
|
||||
// requires the account to already be Active. Posting a REJECTED entry here used to credit
|
||||
// Shares Reference (i.e. "cash returned") for cash that was never collected in the first
|
||||
// place, because this application never reached the stage where any was.
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(jsonCommand.commandId()) //
|
||||
.withEntityId(accountId) //
|
||||
@@ -412,9 +424,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
transactions.add(transaction);
|
||||
totalSubscribedShares += transaction.getTotalShares();
|
||||
}
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||
// The APPROVED posting above has released the money from Share Sundry into
|
||||
// share capital, so the funding behind it can now be closed off.
|
||||
// The stock APPROVED posting releases the money into share capital out of the
|
||||
// product's mapped Share Sundry account, whatever the money actually did. For a
|
||||
// tranche funded through this fork's own routes that account is not always where
|
||||
// it is: a voucher-funded one is sitting wherever the cashier banked the receipt.
|
||||
// settle() books those itself, against the account the money is really in.
|
||||
final Set<ShareAccountTransaction> stockPosted = new HashSet<>();
|
||||
for (ShareAccountTransaction transaction : transactions) {
|
||||
if (transaction.getFundingSource() == null) {
|
||||
stockPosted.add(transaction);
|
||||
}
|
||||
}
|
||||
if (!stockPosted.isEmpty()) {
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
|
||||
}
|
||||
for (ShareAccountTransaction transaction : transactions) {
|
||||
this.trancheFundingService.settle(account, transaction);
|
||||
}
|
||||
@@ -451,9 +474,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
ShareAccountTransaction transaction = account.retrievePurchasedShares(id);
|
||||
transactions.add(transaction);
|
||||
}
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||
// After the stock REJECTED posting, whose Shares Reference credit the savings
|
||||
// route reverses in favour of refunding the member.
|
||||
// The stock REJECTED posting unwinds the stock APPLIED one. A tranche funded
|
||||
// through this fork's own routes never had that APPLIED posting made (see
|
||||
// applyAddtionalShares), so posting its reversal here would book the undoing of
|
||||
// something that was never booked. unwind() below returns the money the way it
|
||||
// actually came in.
|
||||
final Set<ShareAccountTransaction> stockPosted = new HashSet<>();
|
||||
for (ShareAccountTransaction transaction : transactions) {
|
||||
if (transaction.getFundingSource() == null) {
|
||||
stockPosted.add(transaction);
|
||||
}
|
||||
}
|
||||
if (!stockPosted.isEmpty()) {
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted));
|
||||
}
|
||||
for (ShareAccountTransaction transaction : transactions) {
|
||||
this.trancheFundingService.unwind(account, transaction);
|
||||
}
|
||||
|
||||
+9
-5
@@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
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.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||
import org.apache.fineract.commands.domain.CommandProcessingResultType;
|
||||
import org.apache.fineract.commands.domain.CommandSource;
|
||||
@@ -304,7 +306,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
// back-dating it to 31 March files it months before it happened, where nobody reviewing the
|
||||
// approval would find it. The financial year it belongs to is carried in the description.
|
||||
postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")),
|
||||
"DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
|
||||
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLOSED),
|
||||
java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
|
||||
"FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #"
|
||||
+ declaration.getId());
|
||||
}
|
||||
@@ -406,7 +409,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
|
||||
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
|
||||
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||
final String transactionId = "DIVROUND" + dividendPayOutId;
|
||||
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_ROUNDING);
|
||||
|
||||
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
|
||||
// that will never be paid to a member, because no member's share divided into it evenly.
|
||||
@@ -555,7 +558,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||
final String transactionId = "DIVDECL" + dividendPayOutId;
|
||||
final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_DECLARED);
|
||||
// The financial year is stamped into every dividend journal entry so the ledger itself can
|
||||
// answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared
|
||||
// across years, and without the year on the line the balances only ever give a running total.
|
||||
@@ -602,7 +605,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
+ " (Reserve released to members)";
|
||||
|
||||
postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(),
|
||||
"DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
|
||||
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_APPROVED),
|
||||
java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -712,7 +716,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate());
|
||||
|
||||
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"),
|
||||
"DIVFORF" + lockId, entryDate,
|
||||
LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT), entryDate,
|
||||
"FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS
|
||||
+ " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate));
|
||||
|
||||
|
||||
+5
-1
@@ -279,7 +279,8 @@ public class SponsorDDApiResource {
|
||||
request.getChequeNumber(),
|
||||
request.getChequeBankName(),
|
||||
request.getCashierDepositRequestId(),
|
||||
request.getDenominationData()
|
||||
request.getDenominationData(),
|
||||
request.getGlAccountId()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -673,6 +674,9 @@ public class SponsorDDApiResource {
|
||||
// that received this cash, credited to the DD Cash Suspense GL account.
|
||||
private Long cashierDepositRequestId;
|
||||
private String denominationData;
|
||||
// GL only — the GL account the clerk chose to debit directly, instead of a member's own
|
||||
// account, a cheque, or cash.
|
||||
private Long glAccountId;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
|
||||
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
|
||||
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
|
||||
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
|
||||
Long cashierDepositRequestId, String denominationData);
|
||||
Long cashierDepositRequestId, String denominationData, Long glAccountId);
|
||||
|
||||
/**
|
||||
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
|
||||
|
||||
+92
-15
@@ -60,6 +60,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT";
|
||||
private static final String METHOD_CHEQUE = "CHEQUE";
|
||||
private static final String METHOD_CASH = "CASH";
|
||||
// A clerk debits a GL account directly (an institutional/holding account) instead of a member's
|
||||
// own account, a cheque, or cash over the counter -- no client, no balance check, no walk-in
|
||||
// purchaser details.
|
||||
private static final String METHOD_GL = "GL";
|
||||
|
||||
/** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */
|
||||
private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST";
|
||||
@@ -101,7 +105,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
|
||||
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
|
||||
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
|
||||
final Long cashierDepositRequestId, final String denominationData) {
|
||||
final Long cashierDepositRequestId, final String denominationData, final Long glAccountId) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
|
||||
@@ -177,6 +181,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
issuingBranchId, issuedDate, receiptComments, null);
|
||||
cashReceiptVoucherPending = true;
|
||||
}
|
||||
} else if (METHOD_GL.equals(paymentMethod)) {
|
||||
// No client, no walk-in purchaser -- an institutional draft funded straight from a GL
|
||||
// account. Debiting the same account the draft is credited to (DD Payable) would be a
|
||||
// self-cancelling entry with no real movement, the same bug class the Share GL-settlement
|
||||
// check exists to catch.
|
||||
if (glAccountId == null) {
|
||||
throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance.");
|
||||
}
|
||||
final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue();
|
||||
if (glAccountId.equals(ddPayableGlAccountId)) {
|
||||
final Map<String, Object> ddPayable = this.voucherService.glAccount(ddPayableGlAccountId);
|
||||
throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account ("
|
||||
+ ddPayable.get("gl_code") + ") this draft is credited to — pick a different GL account to debit.");
|
||||
}
|
||||
} else {
|
||||
validateClientExists(clientId);
|
||||
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
|
||||
@@ -200,8 +218,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
|
||||
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
|
||||
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
|
||||
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data, gl_account_id) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, leafletId);
|
||||
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
|
||||
@@ -228,6 +246,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
|
||||
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
|
||||
ps.setString(25, denominationData);
|
||||
if (glAccountId != null) ps.setLong(26, glAccountId); else ps.setNull(26, java.sql.Types.BIGINT);
|
||||
return ps;
|
||||
}, kh);
|
||||
|
||||
@@ -410,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
+ "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ").");
|
||||
}
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId);
|
||||
"SELECT d.id, d.status, d.gl_account_id, d.amount, d.txn_id, d.share_account_id, 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.id = ?",
|
||||
cashierDepositRequestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId);
|
||||
}
|
||||
@@ -419,6 +440,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
throw new IllegalStateException(
|
||||
"The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status"));
|
||||
}
|
||||
if (deposit.get("share_account_id") != null) {
|
||||
throw new IllegalStateException("This cashier deposit request has already been used for a share application.");
|
||||
}
|
||||
// The counter tags what a GL receipt was actually collected for at save time -- a code value
|
||||
// under GLEntryType (Admin > System > Manage Codes). See
|
||||
// 0565_convert_entry_type_to_code_value.xml. "General" and a missing tag (rows predating the
|
||||
// column) are still usable; only an explicit, different purpose blocks it here.
|
||||
final Object entryType = deposit.get("entry_type");
|
||||
if (entryType != null && !"General".equals(entryType) && !"DD Issue".equals(entryType)) {
|
||||
throw new IllegalStateException(
|
||||
"This cashier deposit request was recorded as " + entryType + ", not a Demand Draft issuance. Ask the cashier to "
|
||||
+ "correct the entry type, or select a different deposit request.");
|
||||
}
|
||||
final Object depositGlAccountObj = deposit.get("gl_account_id");
|
||||
final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null;
|
||||
if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) {
|
||||
@@ -441,8 +475,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
private Long findJournalEntryIdForCashierDeposit(final Map<String, Object> depositRequest, final Long cashSuspenseGlAccountId) {
|
||||
final String txnId = (String) depositRequest.get("txn_id");
|
||||
if (txnId == null) return null;
|
||||
// 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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId);
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1", txnId, cashSuspenseGlAccountId);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
|
||||
}
|
||||
|
||||
@@ -534,6 +572,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
}
|
||||
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
|
||||
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
|
||||
} else if (METHOD_GL.equals(paymentMethod)) {
|
||||
// The clerk's own chosen GL account -- no withdrawal command, no linked member account;
|
||||
// submitIssueRequest() already confirmed it exists and differs from DD Payable.
|
||||
drGlAccountId = ((Number) request.get("gl_account_id")).longValue();
|
||||
} else if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
||||
// Nothing is collected from the member — the society already owes them this money — so
|
||||
// the draft is funded by settling the Dividend Payable liability instead of debiting a
|
||||
@@ -1237,8 +1279,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) {
|
||||
throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits.");
|
||||
}
|
||||
} else if (METHOD_GL.equals(paymentMethod)) {
|
||||
if (clientId != null) {
|
||||
throw new IllegalArgumentException("Do not provide a Client ID for a GL-funded DD issuance — it is not raised against a member.");
|
||||
}
|
||||
if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) {
|
||||
throw new IllegalArgumentException("Do not provide an account or cheque details when funding a draft from a GL account.");
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH.");
|
||||
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, CASH, or GL.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,7 +1374,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
* Payable entry, posted via postGlOffsetEntry() right after this call, is visible.
|
||||
*/
|
||||
private void markWithdrawalGlEntriesReversed(final String transactionId) {
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId);
|
||||
// Guarded by reversed = false: if transaction_id text is ever reused, this can only flip
|
||||
// rows that are currently live, never re-touch an already-reversed unrelated transaction.
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
|
||||
transactionId);
|
||||
}
|
||||
|
||||
/** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */
|
||||
@@ -1374,8 +1427,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
|
||||
.withDescription("Held pending DD approval"));
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||
currentUser, VoucherService.PREFIX_CREDIT, lines);
|
||||
// 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. A DD request can raise up to
|
||||
// three vouchers across its life (this intake receipt, an approval issuance, or a
|
||||
// rejection refund), so the column holds whichever was raised most recently -- still far
|
||||
// better than the total blank it was before, and this is the one that exists on every DD
|
||||
// paid in cash, receipt or not.
|
||||
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE,
|
||||
issueRequestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
|
||||
if (voucherNo != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||
}
|
||||
@@ -1406,8 +1468,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
|
||||
.withDescription("Cash refunded to purchaser"));
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||
currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
|
||||
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
|
||||
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
if (voucherNo != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||
}
|
||||
@@ -1467,8 +1533,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
.withDescription("GST on DD charges"));
|
||||
}
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||
currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
|
||||
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
|
||||
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||
if (voucherNo != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||
}
|
||||
@@ -1592,14 +1662,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
final String transactionId = result != null ? result.getTransactionId() : null;
|
||||
if (transactionId == null) return null;
|
||||
|
||||
// transactionId was just minted by the createJournalEntry command above, in this same
|
||||
// method invocation - but scope every read/write below to the single most recently
|
||||
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
|
||||
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
|
||||
if (linkedAccountId != null) {
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2",
|
||||
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
|
||||
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1)",
|
||||
linkedAccountId, transactionId);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId);
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND reversed = false"
|
||||
+ " ORDER BY id DESC LIMIT 1", transactionId);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -626,10 +626,16 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
|
||||
+ "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount "
|
||||
+ "from m_cashier_deposit_request cdr "
|
||||
+ "left join m_code_value cv on cv.id = cdr.entry_type_cv_id "
|
||||
+ "where cdr.status = 'APPROVED' "
|
||||
+ "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) "
|
||||
+ "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request "
|
||||
+ "where cashier_deposit_request_id is not null) "
|
||||
// Already spoken for by a share application, or tagged for something else at save
|
||||
// time -- kept out of the picker rather than only rejected at submit, so the counter
|
||||
// never even offers the wrong voucher. See 0565_convert_entry_type_to_code_value.xml.
|
||||
+ "and cdr.share_account_id is null "
|
||||
+ "and (cv.code_value IS NULL OR cv.code_value IN ('General', 'DD Issue')) "
|
||||
+ "order by cdr.actioned_at desc";
|
||||
return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData(
|
||||
rs.getLong("id"),
|
||||
|
||||
+40
@@ -748,9 +748,22 @@ public class VoucherVerificationApiResource {
|
||||
UNION ALL
|
||||
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||
FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||
FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
-- This table only ever stored the checker's id, never a name snapshot the
|
||||
-- way every other register here does, so approver_name comes back null and
|
||||
-- the outer query's COALESCE to the verification row's name is what fills it
|
||||
-- in on this one register.
|
||||
SELECT voucher_no, checker_id, CAST(NULL AS VARCHAR), checker_date
|
||||
FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
|
||||
),
|
||||
-- Who submitted the request the voucher belongs to.
|
||||
--
|
||||
@@ -766,8 +779,14 @@ public class VoucherVerificationApiResource {
|
||||
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, clerk_id, status FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, clerk_id, status FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT voucher_no, maker_id, status FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
|
||||
)
|
||||
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
||||
v.created_by_username, o.name AS branch_name,
|
||||
@@ -917,6 +936,26 @@ public class VoucherVerificationApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other side of a split transaction — GL entries a native platform command posted on its own,
|
||||
* outside this voucher, for the same underlying event.
|
||||
*
|
||||
* Nothing splits that way today, so this returns empty for every voucher. Share redemption used to:
|
||||
* approval posted Dr Share Capital / Cr Share Sundry through the native {@code redeemshares}
|
||||
* command, and only the settlement that followed (Dr Sundry / Cr Savings Control) became a
|
||||
* voucher, so a reader saw half an entry and no sign of where the money came from. A redemption
|
||||
* now books as the single entry the clerk actually keyed — Dr Share Capital straight to its
|
||||
* destination — and the voucher carries both of its sides, so there is no longer a second half to
|
||||
* show. Returning those same rows here would print this voucher's own lines a second time under a
|
||||
* banner saying they are not part of it.
|
||||
*
|
||||
* Kept rather than deleted because it is the seam a genuinely split transaction would hook into:
|
||||
* one {@code if} branch keyed on referenceType, not a new endpoint or a new field on the response.
|
||||
*/
|
||||
private List<Map<String, Object>> relatedGlEntries(final String referenceType, final Object referenceId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ────────────────────────
|
||||
// A query parameter for the same reason as verify: a formatted voucher number contains
|
||||
// slashes, which the strict firewall rejects even encoded, so it cannot be a path segment.
|
||||
@@ -1000,6 +1039,7 @@ public class VoucherVerificationApiResource {
|
||||
// existed, or repaired by hand in the database, should show as unbalanced rather than be
|
||||
// presented as a valid slip.
|
||||
out.put("balanced", totalDebit.compareTo(totalCredit) == 0);
|
||||
out.put("relatedLines", relatedGlEntries((String) h.get("reference_type"), h.get("reference_id")));
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
|
||||
+41
-4
@@ -326,6 +326,8 @@ public class VoucherService {
|
||||
final Map<String, Object> party = savingsPartyForTransaction(savingsTxnId);
|
||||
final String partyName = (String) party.get("party_name");
|
||||
final String accountNo = (String) party.get("account_no");
|
||||
final Long savingsAccountId = party.get("savings_account_id") == null ? null
|
||||
: ((Number) party.get("savings_account_id")).longValue();
|
||||
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
@@ -335,13 +337,24 @@ public class VoucherService {
|
||||
// The member's own side is named after the member; every other leg is named after
|
||||
// its GL account, which is what a voucher shows for an income or expense head.
|
||||
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
|
||||
final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
|
||||
final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
|
||||
|
||||
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
|
||||
final int typeEnum = ((Number) row.get("type_enum")).intValue();
|
||||
VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
|
||||
: VoucherLine.creditGl(glId, name, ref, amount);
|
||||
final boolean isDebit = typeEnum == 2;
|
||||
|
||||
final VoucherLine line;
|
||||
if (isMemberSide) {
|
||||
// This is the member's own savings account leg, not a GL posting -- pairing the
|
||||
// shared Savings Control GL id with the member's own account number on a debitGl/
|
||||
// creditGl line mismatched the two; debitSavings/creditSavings name the account
|
||||
// this actually is.
|
||||
line = isDebit ? VoucherLine.debitSavings(savingsAccountId, partyName, accountNo, amount)
|
||||
: VoucherLine.creditSavings(savingsAccountId, partyName, accountNo, amount);
|
||||
} else {
|
||||
final String name = (String) row.get("gl_name");
|
||||
final String ref = (String) row.get("gl_code");
|
||||
line = isDebit ? VoucherLine.debitGl(glId, name, ref, amount) : VoucherLine.creditGl(glId, name, ref, amount);
|
||||
}
|
||||
final String description = (String) row.get("description");
|
||||
lines.add(description == null ? line : line.withDescription(description));
|
||||
}
|
||||
@@ -370,6 +383,7 @@ public class VoucherService {
|
||||
private Map<String, Object> savingsPartyForTransaction(final Long savingsTxnId) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
|
||||
sa.id AS savings_account_id,
|
||||
sa.account_no,
|
||||
apm.gl_account_id AS control_gl_id
|
||||
FROM m_savings_account_transaction t
|
||||
@@ -536,6 +550,29 @@ public class VoucherService {
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account Fineract's native loan disbursement/repayment command actually posted the cash
|
||||
* leg to, for a voucher's till side — looked up by the {@code loan_transaction_id} FK on {@code
|
||||
* acc_gl_journal_entry} rather than assumed, since which GL that command credits (a disbursement)
|
||||
* or debits (a repayment) depends on the transaction's own payment type via its Fund Source
|
||||
* mapping, not a single fixed cash account. Empty rather than absent if the transaction posted no
|
||||
* matching leg (a non-cash payment type routed elsewhere, or accounting off for this tenant) —
|
||||
* callers show a generic label in that case rather than a wrong or missing one.
|
||||
*/
|
||||
public Map<String, Object> tillGlFromLoanTransaction(final Long loanTransactionId, final boolean wantCreditLeg) {
|
||||
if (loanTransactionId == null) {
|
||||
return Map.of();
|
||||
}
|
||||
// type_enum: 1 = credit, 2 = debit.
|
||||
final int typeEnum = wantCreditLeg ? 1 : 2;
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT g.id, g.name, g.gl_code FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account g ON g.id = je.account_id
|
||||
WHERE je.loan_transaction_id = ? AND je.type_enum = ? AND je.reversed = false
|
||||
LIMIT 1
|
||||
""", loanTransactionId, typeEnum).stream().findFirst().orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or
|
||||
* one whose accounts were never recorded — callers show what they have rather than treating an
|
||||
|
||||
@@ -85,4 +85,31 @@
|
||||
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0549_aml_alert_single_row_per_transaction.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0550_client_risk_change_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0546_board_resolution_register.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0551_aml_rule_coverage_classification.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0552_activate_automatable_aml_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0553_client_behaviour_risk_review.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0554_aml_recurring_review_validity_and_tier.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0555_aml_str_case_covered_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0554_aml_alert_highest_severity.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0555_low_severity_aml_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0556_aml_alert_rule_disposition.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0557_aml_alert_raised_on_actual_time.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0558_aml_alert_rule_directly_decided.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0559_risk_policy_parameters.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0560_client_kyc_risk_profile_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0561_add_client_behaviour_window_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0562_behaviour_window_distinct_months.xml" relativeToChangelogFile="true"/>
|
||||
<!-- Same parallel-branch prefix overlap as noted above: 0551-0554 below repeat numbers already
|
||||
used by the AML set, but the filenames differ, so the changeset identities stay distinct. -->
|
||||
<include file="parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0563_ledger_txn_sequence.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0564_add_entry_type_to_gl_requests.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Register of Board Resolutions (BOR).
|
||||
|
||||
A co-operative society records every board decision as a numbered resolution in its minute book, and an auditor or
|
||||
the Registrar asks to see that register. Until now the number existed only as free text typed into whichever screen
|
||||
needed to quote it - Apply Shares and Approve Shares both ask for one - with nothing behind it to check against.
|
||||
|
||||
This table is that register. Each row is one resolution: the meeting it was passed at, who was present, what was
|
||||
decided, and the number the society recorded it under.
|
||||
|
||||
resolution_number is the society's own, typed from the minute book rather than generated here: a co-operative
|
||||
numbers its resolutions when it minutes them, so a number this system invented would not match the book it is meant
|
||||
to mirror. financial_year and resolution_seq are kept alongside so a year's resolutions can be grouped and ordered
|
||||
even when their numbers do not sort usefully.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0546-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_board_resolution"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_board_resolution">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="resolution_number" type="VARCHAR(50)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_board_resolution_number"/>
|
||||
</column>
|
||||
<column name="financial_year" type="VARCHAR(10)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution_seq" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- The meeting. -->
|
||||
<column name="meeting_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_type" type="VARCHAR(40)" defaultValue="BOARD_MEETING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_place" type="VARCHAR(200)"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
|
||||
<!-- What was decided. subject is the one-line description the register lists; resolution_text is the
|
||||
wording as minuted, which is what actually carries authority. -->
|
||||
<column name="subject" type="VARCHAR(300)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution_text" type="TEXT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="category" type="VARCHAR(60)"/>
|
||||
|
||||
<!-- Attendance and the vote. Quorum is recorded as counted at the meeting rather than derived, because
|
||||
the bye-laws that set it vary by society and change over time. -->
|
||||
<column name="total_directors" type="INT"/>
|
||||
<column name="members_present" type="INT"/>
|
||||
<column name="quorum_met" type="BOOLEAN" defaultValueBoolean="true"/>
|
||||
<column name="votes_for" type="INT"/>
|
||||
<column name="votes_against" type="INT"/>
|
||||
<column name="votes_abstained" type="INT"/>
|
||||
|
||||
<!-- Who signed it off. -->
|
||||
<column name="proposed_by" type="VARCHAR(150)"/>
|
||||
<column name="seconded_by" type="VARCHAR(150)"/>
|
||||
<column name="chairperson_name" type="VARCHAR(150)"/>
|
||||
|
||||
<!-- APPROVED once passed; DRAFT while being minuted; CANCELLED when a later meeting rescinds it.
|
||||
Superseding rather than deleting is the point: a resolution that was acted on cannot be removed from
|
||||
the register just because it was later reversed. -->
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="APPROVED">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_from" type="DATE"/>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()"/>
|
||||
<column name="last_modified_by" type="BIGINT"/>
|
||||
<column name="last_modified_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0546-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_board_resolution_year_seq"/></not>
|
||||
</preConditions>
|
||||
<!-- Serves the register's default listing, which is by year then entry order. -->
|
||||
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_year_seq">
|
||||
<column name="financial_year"/>
|
||||
<column name="resolution_seq"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0546-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_board_resolution_meeting_date"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_meeting_date">
|
||||
<column name="meeting_date"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- One transaction was raising one alert per rule it matched. A single 12,00,000 cash deposit
|
||||
came back as two open alerts - the CTR and the new-account rule - and an officer had to
|
||||
dispose of both to deal with one deposit. Worse, whitelisting one while escalating the other
|
||||
left the same transaction recorded as simultaneously cleared and suspicious.
|
||||
|
||||
A transaction-monitoring system raises one alert per transaction, under the rule that most
|
||||
warrants attention, and lists the others alongside it. That is what an analyst works from
|
||||
and what a regulator expects to see in the case file: one decision per transaction. -->
|
||||
|
||||
<changeSet id="0549-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_alert" columnName="also_matched_rules"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
The other rules the same transaction matched. Kept so nothing is lost when the alert is
|
||||
reduced to one row - the analyst can still see that a deposit was both reportable and on
|
||||
a new account, and the case file shows every rule that fired.
|
||||
</comment>
|
||||
<addColumn tableName="m_aml_alert">
|
||||
<column name="also_matched_rules" type="VARCHAR(500)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0549-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_aml_alert_txn"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
Read on every evaluation to find an alert already raised for this transaction, so a
|
||||
re-run does not raise a second one.
|
||||
</comment>
|
||||
<createIndex tableName="m_aml_alert" indexName="idx_aml_alert_txn">
|
||||
<column name="transaction_type"/>
|
||||
<column name="transaction_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- A customer's KYC risk category decides how often they are re-KYC'd and what their AML
|
||||
thresholds are, so moving one is a decision about how closely the bank watches them. It
|
||||
was only changeable by editing the KYC record directly - one officer, no second pair of
|
||||
eyes, and no record of why.
|
||||
|
||||
The Risk Review Report already works out which customers are rated below what their
|
||||
behaviour warrants. This lets the reviewer raise that change from the report itself and
|
||||
sends it for approval, so the rating moves only once a checker agrees. -->
|
||||
|
||||
<changeSet id="0550-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_client_risk_change_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_client_risk_change_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Recorded as it stood when the request was raised, so an approval that lands after
|
||||
the rating moved for some other reason can be seen to have been overtaken. -->
|
||||
<column name="current_category" type="INT"/>
|
||||
<column name="requested_category" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reason" type="VARCHAR(500)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="maker_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_date" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0550-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_risk_change_client"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_client_risk_change_request" baseColumnNames="client_id"
|
||||
constraintName="fk_risk_change_client" referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0550-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_risk_change_status"/></not>
|
||||
</preConditions>
|
||||
<comment>The approval queue reads by status; the report reads by client.</comment>
|
||||
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0550-4" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_risk_change_client"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_client">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- A GL settlement (settlement_mode = 'GL') credits whichever GL account the clerk picked on
|
||||
the Fund Transfer's CR row, instead of the member's linked savings account: this column
|
||||
records which one, so approve() knows where to post and the voucher can name it correctly. -->
|
||||
<changeSet author="fineract" id="0551-1-add-settlement-gl-account">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_gl_account_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="settlement_gl_account_id" type="BIGINT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Says, for each of the 221 AML scenarios in the catalog, how the bank actually covers it.
|
||||
|
||||
Until now the catalog was a flat list and 207 of those scenarios had nothing behind them - no transaction rule, no
|
||||
review step, nothing. They read on screen exactly like the 14 that were live, which is the worst of both worlds: an
|
||||
inspector sees 221 scenarios "monitored", and an officer has no way to tell which ones the system is actually
|
||||
watching.
|
||||
|
||||
A real AML function never claims to automate everything. It classifies:
|
||||
|
||||
AUTOMATED the engine evaluates it on every transaction
|
||||
MANUAL_REVIEW no rule can decide it - an officer judges it, so it belongs on a review checklist
|
||||
("customer could not explain source of funds", "adverse media report")
|
||||
NOT_APPLICABLE the bank does not do this business at all, so the scenario cannot arise
|
||||
(trade finance, foreign remittance, credit cards, lockers - none exist in this CBS)
|
||||
PLANNED automatable in principle, but needs data the engine is not yet given
|
||||
|
||||
Recording the reason alongside is what makes this defensible. "Not monitored" invites a finding; "not applicable,
|
||||
because this bank has no trade finance book" answers it.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0551-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_risk_rule" columnName="coverage_status"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_risk_rule">
|
||||
<column name="coverage_status" type="VARCHAR(20)"/>
|
||||
<column name="coverage_note" type="VARCHAR(500)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- AUTOMATED: everything that already has a transaction rule behind it. -->
|
||||
<changeSet author="fineract" id="0551-2">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule r
|
||||
SET coverage_status = 'AUTOMATED',
|
||||
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
|
||||
WHERE rule_kind = 'AML'
|
||||
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
NOT_APPLICABLE: business this bank does not transact.
|
||||
|
||||
A co-operative bank running this CBS has no trade finance book, no forex or cross-border remittance, no credit
|
||||
card issuance and no locker module. The scenarios written for those cannot arise, and leaving them looking
|
||||
unmonitored misrepresents the gap. Should the bank take on any of these lines, the classification is revisited
|
||||
rather than the scenario being re-added.
|
||||
-->
|
||||
<changeSet author="fineract" id="0551-3">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'NOT_APPLICABLE',
|
||||
coverage_note = 'No trade finance or import/export business is transacted on this CBS, so this scenario cannot arise.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE 'Trade Based Money Laundering%' OR rule_name LIKE '%Overseas Direct Investment%');
|
||||
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'NOT_APPLICABLE',
|
||||
coverage_note = 'No cross-border remittance or foreign exchange business is transacted on this CBS.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE 'Afghan Drug Business%'
|
||||
OR rule_name LIKE '%cross border%' OR rule_name LIKE '%Cross border%'
|
||||
OR rule_name LIKE '%foreign remittance%' OR rule_name LIKE '%foreign inward%'
|
||||
OR rule_name LIKE '%outward remittance%' OR rule_name LIKE '%Outward foreign%'
|
||||
OR rule_name LIKE '%another country%' OR rule_name LIKE '%tax havens%');
|
||||
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'NOT_APPLICABLE',
|
||||
coverage_note = 'No credit card is issued on this CBS.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE 'Credit Card Monitoring%' OR rule_name LIKE '%credit card%');
|
||||
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'NOT_APPLICABLE',
|
||||
coverage_note = 'No safe deposit locker module exists on this CBS.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND rule_name LIKE '%Locker%';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
MANUAL_REVIEW: scenarios that turn on a judgement no rule can make.
|
||||
|
||||
Whether a customer "could not explain the source of funds", or whether a media report is adverse, is decided by
|
||||
the officer in front of them. These are not failures of automation - they are the part of AML that is meant to
|
||||
be human, and RBI's Master Direction describes them as matters for the principal officer's assessment.
|
||||
-->
|
||||
<changeSet author="fineract" id="0551-4">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'MANUAL_REVIEW',
|
||||
coverage_note = 'Turns on an officer''s judgement at the counter or during periodic review; carried on the AML review checklist.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE 'Customer Due Diligence%'
|
||||
OR rule_name LIKE 'Name Screening%'
|
||||
OR rule_name LIKE '%Shell Companies%'
|
||||
OR rule_name LIKE '%adverse media%' OR rule_name LIKE '%Adverse media%'
|
||||
OR rule_name LIKE '%could not explain%'
|
||||
OR rule_name LIKE '%does not reside%'
|
||||
OR rule_name LIKE '%Complaint received%'
|
||||
OR rule_name LIKE '%Mutilated%'
|
||||
OR rule_name LIKE 'FICN%'
|
||||
OR rule_name LIKE 'Terrorist Financing%'
|
||||
OR rule_name LIKE 'Non Customer Monitoring%'
|
||||
OR rule_name LIKE 'Additional RFIs Rule%'
|
||||
OR rule_name LIKE '%Risk Categorization%');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
PLANNED: automatable, but the engine is not yet given what it would need to decide them.
|
||||
|
||||
Counterparty identity (many-to-one), the account's dormancy state before the transaction, the turnover the
|
||||
customer declared at KYC, and the channel a transaction arrived on are all absent from AmlTransactionContext.
|
||||
Named here so the gap is visible and costed rather than discovered at an inspection.
|
||||
-->
|
||||
<changeSet author="fineract" id="0551-5">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'PLANNED',
|
||||
coverage_note = 'Needs counterparty identity on the transaction context - not yet captured.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE '%Many to One%' OR rule_name LIKE '%One to Many%'
|
||||
OR rule_name LIKE '%common phone%' OR rule_name LIKE '%joint holder%'
|
||||
OR rule_name LIKE '%customers working together%'
|
||||
OR rule_name LIKE '%Multiple accounts under the same name%'
|
||||
OR rule_name LIKE '%unrelated parties%'
|
||||
OR rule_name LIKE '%single Indian party%'
|
||||
OR rule_name LIKE '%same remitter%'
|
||||
OR rule_name LIKE '%several accounts%');
|
||||
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'PLANNED',
|
||||
coverage_note = 'Needs the customer''s declared turnover compared against actual activity - not yet computed.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE '%inconsistent with Profile%' OR rule_name LIKE '%inconsistent with client profile%'
|
||||
OR rule_name LIKE 'Sudden %' OR rule_name LIKE '%surge in activity%'
|
||||
OR rule_name LIKE '%High Net worth%');
|
||||
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'PLANNED',
|
||||
coverage_note = 'Needs the transaction channel (ATM, UPI, NEFT, RTGS, POS) on the context - not yet captured.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL
|
||||
AND (rule_name LIKE '%ATM%' OR rule_name LIKE '%card usage%'
|
||||
OR rule_name LIKE '%Non Home Branch%' OR rule_name LIKE '%multiple branches%');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Anything still unclassified is automatable with what the engine already has - a threshold, a direction, a window
|
||||
and a subject. 0552 gives each of these a transaction rule; this marks them so the two migrations cannot drift.
|
||||
-->
|
||||
<changeSet author="fineract" id="0551-6">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'AUTOMATED',
|
||||
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Gives a transaction rule to every catalog scenario the engine can already decide.
|
||||
|
||||
0551 classified the catalog; this acts on the AUTOMATED half of it. Each row below is a scenario that was described
|
||||
in the catalog and never evaluated, expressed in the terms the engine understands - a type, a threshold, a
|
||||
direction, a window, and who the rule is about.
|
||||
|
||||
Thresholds follow the reporting limits Indian banks work to: ten lakh is the CTR line, fifty thousand the PAN
|
||||
quoting line under Rule 114B, and two lakh the cash acceptance ceiling under section 269ST. Where RBI's wording
|
||||
gives no figure, the number is the one a co-operative bank of this size would set, and every one of them is
|
||||
editable from the AML Rules Master afterwards - the point is that the scenario is evaluated at all, not that this
|
||||
file guessed the bank's exact appetite.
|
||||
|
||||
Windows and counts are stated for pattern rules because a pattern needs both: three or more transactions inside a
|
||||
band, not one large one. That distinction is what the engine's structuring and velocity paths test, and it is why a
|
||||
single large deposit no longer reports as "splitting deposits below fifty thousand".
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0552-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_DEPOSIT_DAY'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
-- Helper: each INSERT links back to its catalog scenario by name, so the rule and the scenario it
|
||||
-- implements stay connected and the register can show which is which.
|
||||
|
||||
-- ── Daily cash, by direction ────────────────────────────────────────────────────────────────
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_CASH_DEPOSIT_DAY', 'High value cash deposits in a day', 'SAVINGS', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value cash deposits in a day' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_DAY', 'High value cash withdrawals in a day', 'SAVINGS', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'DEBIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value cash withdrawals in a day' LIMIT 1;
|
||||
|
||||
-- Large cash debit. Separate from LARGE_CASH_TXN_25L, which is direction-agnostic: this is the catalog's
|
||||
-- own withdrawal-side scenario and carries the lower 269ST-informed figure.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'LARGE_CASH_TXN_DEBIT', 'Large cash transaction (Debit)', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'DEBIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Large cash transaction(Debit)' LIMIT 1;
|
||||
|
||||
-- ── Monthly cash aggregates ─────────────────────────────────────────────────────────────────
|
||||
-- STRUCTURING rather than THRESHOLD: the concern is a month's worth of deposits summing past the CTR
|
||||
-- line, which is only a pattern if there were several of them.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_CASH_DEPOSIT_MONTH', 'High Value Cash Deposits in a month', 'SAVINGS', 1000000, 'STRUCTURING',
|
||||
30, 3, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Deposits in a month' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_MONTH', 'High Value Cash Withdrawals in a month', 'SAVINGS', 1000000, 'STRUCTURING',
|
||||
30, 3, 'DEBIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Withdrawals in a month' LIMIT 1;
|
||||
|
||||
-- ── Non-cash, daily and monthly ─────────────────────────────────────────────────────────────
|
||||
-- cash_only false: these are deliberately the transfer/cheque counterparts of the rules above, which is
|
||||
-- why the catalog lists them separately.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_DAY', 'High Value Non-Cash Deposits in a day', 'ALL', 2000000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value Non- Cash Deposits in a day' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_DAY', 'High Value non-Cash Withdrawal in a day', 'ALL', 2000000, 'THRESHOLD',
|
||||
NULL, NULL, 'DEBIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value non- Cash Withdrawal in a day' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_MONTH', 'High Value non-cash Deposits in a month', 'ALL', 2000000, 'STRUCTURING',
|
||||
30, 3, 'CREDIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value non-cash Deposits in a month' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_MONTH', 'High value non-cash withdrawals in a month', 'ALL', 2000000, 'STRUCTURING',
|
||||
30, 3, 'DEBIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value non-cash withdrawals in a month' LIMIT 1;
|
||||
|
||||
-- ── Credit entries and current accounts ─────────────────────────────────────────────────────
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'CREDIT_ENTRY_THRESHOLD', 'Threshold Limit in Credit entry', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Threshold Limit in Credit entry' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'CURRENT_ACCOUNT_HIGH_VALUE', 'High Value Transactions in current account', 'CURRENT', 2500000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions in current account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'CURRENT_ACCOUNT_UNUSUAL_ACTIVITY', 'Unusually high activity in Current Account', 'CURRENT', 5000000, 'VELOCITY',
|
||||
30, 10, NULL, false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Unusually high activity in Current Account(CA)' LIMIT 1;
|
||||
|
||||
-- ── New accounts ────────────────────────────────────────────────────────────────────────────
|
||||
-- NEW_ACCOUNT scope keeps these off long-standing customers; the engine reads the account's opening date
|
||||
-- from the context rather than inferring it from transaction history.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NEW_ACCOUNT_HIGH_VALUE_TXN', 'High Value transactions in a new Account', 'ALL', 500000, 'THRESHOLD',
|
||||
30, NULL, NULL, false, 'NEW_ACCOUNT', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions in a new Account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NEW_ACCOUNT_HIGH_ACTIVITY', 'High activity in a new account', 'ALL', 200000, 'VELOCITY',
|
||||
30, 5, NULL, false, 'NEW_ACCOUNT', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High activity in a new account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NEW_ACCOUNT_TXN_COUNT_MONTHLY', 'Number of Transactions in newly opened Accounts, monthly', 'ALL', 100000, 'VELOCITY',
|
||||
30, 10, NULL, false, 'NEW_ACCOUNT', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Number of Transactions in newly opened Accounts, monthly' LIMIT 1;
|
||||
|
||||
-- ── Subject-scoped: who the customer is ─────────────────────────────────────────────────────
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'REAL_ESTATE_ACCOUNT_MONITORING', 'Monitor accounts of parties in real estate', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Monitor accounts of parties%occupation related to Real Estate' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'REAL_ESTATE_CASH_HIGH_VALUE', 'High value cash transactions - real estate agents and dealers', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions pertaining to accounts of real estate agents and dealers' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'PRECIOUS_METAL_DEALER_CASH', 'High value cash transactions by dealer in precious metal or stone', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions by dealer in precious metal or stone' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NPO_CASH_HIGH_VALUE', 'High value cash transactions in NPOs', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, true, 'CHARITABLE_ORG', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions in NPOs' LIMIT 1;
|
||||
|
||||
-- NTR territory: FIU-IND expects a non-profit receipt return at ten lakh, so the figure is not the
|
||||
-- bank's to choose.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NGO_RECEIPT_OF_FUNDS', 'Registered NGOs - receipt of funds in account', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', false, 'CHARITABLE_ORG', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Account Monitoring - Reg. NGOs - Receipt of funds in account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NPO_ONWARD_DISBURSAL', 'NPOs receiving funds and transferring to multiple beneficiaries', 'ALL', 500000, 'VELOCITY',
|
||||
30, 5, 'DEBIT', false, 'CHARITABLE_ORG', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'NPOs or charities receiving funds%' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_RISK_CUSTOMER_HIGH_VALUE', 'High Value transactions by high risk customers', 'ALL', 500000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions by high risk customers' LIMIT 1;
|
||||
|
||||
-- Staff accounts. Left inactive and with no subject scope: the resolver knows PEP, charitable, high-risk
|
||||
-- business and new-account only, so a STAFF scope would fall through to "matches everything" and report
|
||||
-- this against every customer in the bank. Configured now, switched on when staff can be identified.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'STAFF_ACCOUNT_MONITORING', 'Monitoring accounts of Staff', 'ALL', 200000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, NULL, id, false
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Monitoring accounts of Staff' LIMIT 1;
|
||||
|
||||
-- ── Loans and deposits ──────────────────────────────────────────────────────────────────────
|
||||
-- Two lakh is section 269ST's ceiling on cash accepted in a single transaction, which is the line a cash
|
||||
-- loan repayment is measured against.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'LOAN_CASH_REPAYMENT_WINDOW', 'Repayment of loan in cash over a window', 'LOAN', 200000, 'STRUCTURING',
|
||||
90, 3, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Repayment of loan in cash greater than%' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'FDR_PREMATURE_CLOSURE_LARGE', 'Premature closure of large FDR through PO/DD', 'DEPOSIT', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'DEBIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Premature closure of large FDR through PO/DD' LIMIT 1;
|
||||
|
||||
-- ── Instruments and inward funds ────────────────────────────────────────────────────────────
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'DD_DEPOSIT_IMMEDIATE_WITHDRAWAL', 'Multiple deposit of MCs/DDs followed by immediate withdrawal', 'ALL', 500000, 'VELOCITY',
|
||||
7, 3, NULL, false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Multiple deposit of Mcs/DDs%' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NUMEROUS_CREDIT_SLIPS', 'Numerous small credit slips totalling a substantial amount', 'SAVINGS', 500000, 'STRUCTURING',
|
||||
7, 5, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Depositing cash by means of numerous credit slip%' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'INWARD_REMITTANCE_HIGH_VALUE', 'High value inward remittance', 'TRANSFER', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value inward remittance' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'INWARD_REMITTANCE_NEW_ACCOUNT', 'Inward remittance in a new account', 'TRANSFER', 200000, 'THRESHOLD',
|
||||
30, NULL, 'CREDIT', false, 'NEW_ACCOUNT', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Inward remittance in a new account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NEFT_HIGH_VALUE', 'Monitoring NEFT transactions', 'TRANSFER', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Monitoring NEFT Transactions' LIMIT 1;
|
||||
|
||||
-- ── Frequency ───────────────────────────────────────────────────────────────────────────────
|
||||
-- The catalog lists this scenario twice; FREQUENT_LOW_CASH_WITHDRAWAL already covers the first. This is
|
||||
-- the monthly counterpart, over a longer window.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, min_limit_amount, max_limit_amount, source_client_risk_rule_id, is_active)
|
||||
SELECT 'FREQUENT_LOW_CASH_WITHDRAWAL_MONTH', 'Frequent low cash withdrawals (monthly)', 'SAVINGS', 300000, 'VELOCITY',
|
||||
30, 8, 'DEBIT', true, NULL, 1, 50000, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Frequent low cash withdrawals'
|
||||
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = m_client_risk_rule.id)
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'CHEQUE_LEAVES_HIGH_USAGE', 'High number of cheque leaves', 'ALL', 100000, 'VELOCITY',
|
||||
30, 20, 'DEBIT', false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High number of cheque leaves' LIMIT 1;
|
||||
|
||||
-- Round-figure transfers, a laundering tell the catalog describes in foreign currency but which reads
|
||||
-- the same in rupees. Banded to the round lakh so ordinary amounts do not match.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, min_limit_amount, source_client_risk_rule_id, is_active)
|
||||
SELECT 'ROUND_FIGURE_TRANSFERS', 'Transfers in round multiples, repeated', 'TRANSFER', 1000000, 'VELOCITY',
|
||||
30, 5, NULL, false, NULL, 100000, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Amounts of money transfer carried out%' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'INWARD_THEN_IMMEDIATE_OUTFLOW', 'Inward remittance followed by immediate withdrawal or transfer', 'ALL', 500000, 'VELOCITY',
|
||||
2, 3, NULL, false, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Inward remittance followed by immediate%' LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
PAN-quoting scenarios. Rule 114B requires a PAN on cash deposits of fifty thousand or more, so these are about
|
||||
the absence of one - a condition the engine cannot yet read from the transaction context.
|
||||
|
||||
Left inactive rather than omitted: the rule exists with the right threshold and wording, so activating it is a
|
||||
switch once the context carries the customer's PAN status, and until then the register shows exactly why it is
|
||||
off rather than the scenario silently missing.
|
||||
-->
|
||||
<changeSet author="fineract" id="0552-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_WITHOUT_PAN'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_CASH_WITHOUT_PAN', 'High value cash transactions in accounts without PAN', 'ALL', 50000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, true, NULL, id, false
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High value Cash transactions in accounts without PAN' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_NONCASH_WITHOUT_PAN', 'High value non-cash transactions in accounts without PAN', 'ALL', 1000000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, NULL, id, false
|
||||
FROM m_client_risk_rule WHERE rule_name = 'high value non cash transactions in accounts without PAN' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
|
||||
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'HIGH_VALUE_TXN_WITHOUT_PAN_ID', 'High Value Transactions without PAN ID', 'ALL', 50000, 'THRESHOLD',
|
||||
NULL, NULL, NULL, false, NULL, id, false
|
||||
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions without PAN ID' LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The rules seeded above are now backed; mark their scenarios AUTOMATED so the register reflects what the engine
|
||||
actually evaluates. The PAN rules stay PLANNED - they exist but are switched off, and calling them automated
|
||||
would be the same overstatement this whole exercise is correcting.
|
||||
-->
|
||||
<changeSet author="fineract" id="0552-3">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule r
|
||||
SET coverage_status = 'AUTOMATED',
|
||||
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
|
||||
WHERE r.rule_kind = 'AML'
|
||||
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
|
||||
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = true);
|
||||
|
||||
UPDATE m_client_risk_rule r
|
||||
SET coverage_status = 'PLANNED',
|
||||
coverage_note = 'Rule is configured with its threshold but switched off, because the engine cannot yet '
|
||||
|| 'identify this scenario''s subject from the transaction context.'
|
||||
WHERE r.rule_kind = 'AML'
|
||||
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
|
||||
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = false)
|
||||
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t2
|
||||
WHERE t2.source_client_risk_rule_id = r.id AND t2.is_active = true);
|
||||
|
||||
-- Anything still unclassified after 0551 and this file is automatable in principle but has no rule yet.
|
||||
UPDATE m_client_risk_rule
|
||||
SET coverage_status = 'PLANNED',
|
||||
coverage_note = 'Automatable, but no transaction rule has been configured for it yet.'
|
||||
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Every other request type that raises a voucher (m_clerk_withdrawal_request,
|
||||
m_clerk_closure_request, m_clerk_disbursement_request, m_cashier_deposit_request) stores the
|
||||
voucher_no it was given back on itself, so VoucherVerificationApiResource.myWork() and the
|
||||
Received/Payment/Journal registers can find it by joining on this table. Share withdrawal
|
||||
never got this column, so raiseShareWithdrawalVoucher()'s vouchers existed in
|
||||
m_voucher/m_voucher_line but were invisible everywhere a register looks a voucher up by its
|
||||
owning request: "My Voucher"/"My Work" silently dropped them, and Payment totals came up
|
||||
short by exactly what had been correctly posted and disbursed. -->
|
||||
<changeSet author="fineract" id="0552-1-add-voucher-no">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="voucher_no"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="voucher_no" type="VARCHAR(50)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Same gap as 0552 for m_clerk_share_withdrawal_request, found by the same audit: both of
|
||||
these maker-checker request tables raise a voucher (DividendClaimRequestApiResource,
|
||||
SponsorDDIssueRequestWritePlatformServiceImpl) but never stored voucher_no back on
|
||||
themselves, so VoucherVerificationApiResource.myWork() could never find it: a manager who
|
||||
approved a dividend cash payout or a DD issuance saw it nowhere in "My Voucher"/"My Work",
|
||||
and the branch's Payment total came up short by exactly what had been paid out. -->
|
||||
<changeSet author="fineract" id="0553-1-add-voucher-no-dividend-claim">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_share_dividend_claim_request" columnName="voucher_no"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_share_dividend_claim_request">
|
||||
<column name="voucher_no" type="VARCHAR(50)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0553-2-add-voucher-no-sponsor-dd">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_sponsor_dd_issue_request" columnName="voucher_no"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_sponsor_dd_issue_request">
|
||||
<column name="voucher_no" type="VARCHAR(50)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Risk ratings were being suggested off a single alerted transaction. A High-risk customer who
|
||||
bought something small enough to trip nothing came back as "lower to Low", and a Low-risk
|
||||
customer moving large sums repeatedly came back as nothing at all, because the report only
|
||||
ever saw the transactions a rule had already fired on - never the customer's actual
|
||||
behaviour.
|
||||
|
||||
A rating is about the customer, not about one transaction. This records what each customer
|
||||
actually did over a period - every transaction, not only the alerted ones - and compares it
|
||||
with the income they declared at onboarding. That comparison is what a periodic KYC review
|
||||
is, and it is the only thing that can honestly say a rating should move.
|
||||
|
||||
Raising and lowering are deliberately not symmetric. A rating goes up on evidence and comes
|
||||
down only on a clean period: getting an increase wrong costs a customer some extra
|
||||
monitoring, getting a decrease wrong takes the monitoring off someone who needed it. -->
|
||||
|
||||
<changeSet id="0553-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_client_behaviour_window"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
One row per customer per closed observation window. Written by the review job, never by
|
||||
a transaction - a single transaction is an input to a window, not a window of its own.
|
||||
</comment>
|
||||
<createTable tableName="m_client_behaviour_window">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="window_from" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="window_to" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Every transaction in the window, not only the ones that tripped a rule. -->
|
||||
<column name="txn_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="credit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="debit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="largest_txn" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Cash is watched separately: the same turnover in cash and by transfer are not the
|
||||
same risk, because cash carries no counterparty trail. -->
|
||||
<column name="cash_txn_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cash_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Transactions at or above the CTR limit, which are reportable in their own right. -->
|
||||
<column name="large_txn_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- What the customer said they earn, copied in as it stood: a later edit to the client
|
||||
record must not silently rewrite a window that was already assessed. -->
|
||||
<column name="declared_annual_income" type="DECIMAL(19,6)"/>
|
||||
<column name="declared_annual_turnover" type="DECIMAL(19,6)"/>
|
||||
<!-- Window credits, annualised, over declared income. Above 1 means the customer moved
|
||||
more than they said they earn. -->
|
||||
<column name="income_ratio" type="DECIMAL(19,6)"/>
|
||||
|
||||
<column name="alert_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="str_case_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="risk_category_at_review" type="INT"/>
|
||||
<!-- UP / DOWN / HOLD, with the sentence explaining it. -->
|
||||
<column name="suggested_direction" type="VARCHAR(10)"/>
|
||||
<column name="suggested_category" type="INT"/>
|
||||
<column name="suggestion_reason" type="VARCHAR(600)"/>
|
||||
|
||||
<column name="computed_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0553-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_behaviour_window_client"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_client_behaviour_window" baseColumnNames="client_id"
|
||||
constraintName="fk_behaviour_window_client" referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0553-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_behaviour_window_client"/></not>
|
||||
</preConditions>
|
||||
<comment>Read per customer, newest first, by both the report and the next run of the job.</comment>
|
||||
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_client">
|
||||
<column name="client_id"/>
|
||||
<column name="window_to"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0553-4" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_behaviour_window_direction"/></not>
|
||||
</preConditions>
|
||||
<comment>The review worklist reads the customers whose rating the last window says should move.</comment>
|
||||
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_direction">
|
||||
<column name="suggested_direction"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- A clerk funding a Demand Draft by debiting a GL account directly (payment_method = 'GL'),
|
||||
instead of a member's savings/current account, a cheque or cash: this column records which
|
||||
GL account approveIssueRequest() debits, credited against the sponsor's own DD Payable GL. -->
|
||||
<changeSet author="fineract" id="0554-1-add-gl-account-to-sponsor-dd-issue-request">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_sponsor_dd_issue_request" columnName="gl_account_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_sponsor_dd_issue_request">
|
||||
<column name="gl_account_id" type="BIGINT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- An alert reports the severity of the rule it was raised under, and that is the only rule
|
||||
the screen can see. Since one transaction now raises one alert under whichever rule most
|
||||
warrants attention, a high-severity rule can end up in also_matched_rules - not because it
|
||||
was less serious, but because a statutory CTR outranked it.
|
||||
|
||||
An officer sweeping up low and medium alerts for one batch decision would then be offered
|
||||
a transaction that had tripped a high-severity rule, with nothing on the row to say so.
|
||||
This records the highest severity across every rule the transaction matched, so the screen
|
||||
can hold back exactly those. -->
|
||||
|
||||
<changeSet id="0554-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_alert" columnName="highest_severity"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
1 Low, 2 Medium, 3 High — the worst band among all rules this transaction matched, not
|
||||
only the governing one. Null where no matched rule carried a severity at all, which is
|
||||
how the CTR rules arrive: they are returns the bank owes, not graded suspicions.
|
||||
</comment>
|
||||
<addColumn tableName="m_aml_alert">
|
||||
<column name="highest_severity" type="INT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0554-2" author="CBS">
|
||||
<comment>
|
||||
Backfill from the governing rule for alerts raised before this column existed. Their
|
||||
also_matched rules are recorded as codes rather than ids, so the governing rule's own
|
||||
severity is the most that can honestly be said about them.
|
||||
</comment>
|
||||
<sql>
|
||||
UPDATE m_aml_alert a
|
||||
SET highest_severity = (
|
||||
SELECT cr.risk_category
|
||||
FROM m_aml_transaction_rule r
|
||||
JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
|
||||
WHERE r.id = a.rule_id)
|
||||
WHERE a.highest_severity IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Gives a recurring-alert review a life of its own, and records how severe the customer's repetition had become when
|
||||
it was taken.
|
||||
|
||||
A review used to hold the customer out of the queue only until their alert count rose by one. A customer whose
|
||||
ordinary business trips a rule every few days therefore came back the day after being reviewed, and the officer
|
||||
re-read the same explanation they had written themselves. Nothing about the response changed however many times
|
||||
this happened, so a customer surfacing for the fortieth time was handled exactly like one surfacing for the first.
|
||||
|
||||
valid_until the review stands until this date; the customer stays out of the queue meanwhile unless their
|
||||
alert count grows past the escalation tier's own trigger
|
||||
escalation_tier what the count had reached when reviewed - ROUTINE, ELEVATED, SENIOR or MANDATORY_STR - so the
|
||||
history reads as a progression rather than a flat list of identical reviews
|
||||
alerts_actioned how many of the customer's open alerts this review actually disposed of, which is what separates
|
||||
a review that did something from one that only recorded an opinion
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0554-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_recurring_review" columnName="valid_until"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_recurring_review">
|
||||
<column name="valid_until" type="DATE"/>
|
||||
<column name="escalation_tier" type="VARCHAR(20)"/>
|
||||
<column name="alerts_actioned" type="INT" defaultValueNumeric="0"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Existing reviews keep behaving as they did: no validity date means the old "any new alert brings them back"
|
||||
rule still applies to them, rather than back-dating a window they were never taken under.
|
||||
-->
|
||||
<changeSet author="fineract" id="0554-2">
|
||||
<sql>
|
||||
UPDATE m_aml_recurring_review SET escalation_tier = 'ROUTINE' WHERE escalation_tier IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Lets one STR case cover several alerts.
|
||||
|
||||
A case has always pointed at exactly one alert, which is right when an officer escalates a single transaction they
|
||||
find suspicious. It is wrong for a case raised off a customer's repeated behaviour: escalating a recurring-activity
|
||||
review opened one case per open alert, so a customer reviewed at four alerts put four cases in front of the
|
||||
Principal Officer, each describing one transaction in a pattern that only means anything taken together. Reviewed
|
||||
again at nine, they would put five more.
|
||||
|
||||
That is also not how the report itself works. An STR to FIU-IND describes suspicious *activity* - the pattern, the
|
||||
customer, and the transactions that make it up - so the officer investigating needs them in one place, not split
|
||||
across cases that each hold a fragment.
|
||||
|
||||
m_aml_str_case.alert_id stays as it is: it remains the alert the case was opened from, which keeps every existing
|
||||
case, query and screen working unchanged. This table records the rest of what a case covers, so a case opened from
|
||||
one transaction reads exactly as it does today, and one opened from a pattern carries all of it.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0555-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_aml_str_case_alert"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_aml_str_case_alert">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="str_case_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="alert_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="added_on_utc" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0555-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="FK_str_case_alert_case"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="str_case_id"
|
||||
constraintName="FK_str_case_alert_case"
|
||||
referencedTableName="m_aml_str_case" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="alert_id"
|
||||
constraintName="FK_str_case_alert_alert"
|
||||
referencedTableName="m_aml_alert" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
One alert belongs to at most one case. Without this a second escalation of the same transaction could attach it
|
||||
to a second case, and the same transaction would reach FIU-IND twice under two references.
|
||||
-->
|
||||
<changeSet author="fineract" id="0555-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="uq_str_case_alert"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_aml_str_case_alert" indexName="uq_str_case_alert" unique="true">
|
||||
<column name="alert_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0555-4">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_str_case_alert_case"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_aml_str_case_alert" indexName="idx_str_case_alert_case">
|
||||
<column name="str_case_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Existing cases each cover the one alert they were opened from, so the link table describes them too and nothing
|
||||
has to ask "which kind of case is this" when reading coverage.
|
||||
-->
|
||||
<changeSet author="fineract" id="0555-5">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_str_case_alert</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_str_case_alert (str_case_id, alert_id, added_on_utc)
|
||||
SELECT id, alert_id, now() FROM m_aml_str_case;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Gives the catalog a low-severity band for transaction behaviour.
|
||||
|
||||
The catalog held 19 Low entries and not one of them could raise an alert: every Low row is rule_kind 'KYC', which
|
||||
describes who a customer is - a student, a farmer, a government employee - and never what a transaction looks like.
|
||||
All 221 AML-kind rules sat in Medium or High. So the engine could not produce a Low alert at all, the Low Risk tile
|
||||
on the Flagged Transactions screen was permanently nil, and an officer had only two settings: ignore, or treat as a
|
||||
suspicion worth disposing of.
|
||||
|
||||
That gap matters beyond the empty tile. Monitoring needs somewhere to put activity that is worth recording and is
|
||||
not worth an officer's suspicion - the first slightly-large withdrawal on a quiet account, a run of small cash
|
||||
credits, a round-sum payment. Real CBS monitoring logs these to build a behavioural picture over time; they become
|
||||
evidence when something else happens on the same account, and they are noise if each one demands a decision. Low is
|
||||
that place, and it is what makes batch disposition useful: many small observations, one decision.
|
||||
|
||||
Bands sit strictly below the Medium rules they shadow, so an amount belongs to exactly one severity. Cash movement
|
||||
of fifty thousand and above in a repeating pattern is Medium (FREQUENT_LOW_CASH_*, band 50k-2L, three in seven
|
||||
days); a single such transaction is not covered there at all, and that single transaction is what MODERATE_CASH_*
|
||||
records. The frequency rule below stops at 49,999 so it cannot collide with the Medium band.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!-- The catalog scenarios. Written as AML-kind Low rows so they can source a transaction rule, which the
|
||||
existing KYC-kind Low rows cannot. -->
|
||||
<changeSet author="fineract" id="0555-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_kind = 'AML' AND risk_category = 1</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_client_risk_rule (rule_name, risk_category, rule_kind, reason, order_position, aml_check_status, coverage_status)
|
||||
VALUES
|
||||
('Moderate cash deposit on a savings account', 1, 'AML',
|
||||
'Cash deposit at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 601, 'FLAGGED', 'AUTOMATED'),
|
||||
('Moderate cash withdrawal on a savings account', 1, 'AML',
|
||||
'Cash withdrawal at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 602, 'FLAGGED', 'AUTOMATED'),
|
||||
('Repeated small cash deposits in a week', 1, 'AML',
|
||||
'Several small cash deposits inside a week - ordinary for a trader, worth recording for anyone else', 603, 'FLAGGED', 'AUTOMATED'),
|
||||
('First sizeable transaction on a new account', 1, 'AML',
|
||||
'A recently opened account moving money for the first time - normal after a salary or a festival, and the point at which monitoring should start watching', 604, 'FLAGGED', 'AUTOMATED');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- The transaction rules that implement them. Bands stop below the Medium rules so no amount is graded twice. -->
|
||||
<changeSet author="fineract" id="0555-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MODERATE_CASH_DEPOSIT'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
-- Rs 50,000 to Rs 2,00,000 in cash, one transaction. Above the PAN quoting line under Rule 114B, below
|
||||
-- the Rs 10,00,000 CTR line. The Medium rule over the same band asks for three inside a week; a single
|
||||
-- one was previously recorded nowhere.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
|
||||
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'MODERATE_CASH_DEPOSIT', 'Moderate cash deposit', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
|
||||
NULL, NULL, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash deposit on a savings account' LIMIT 1;
|
||||
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
|
||||
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'MODERATE_CASH_WITHDRAWAL', 'Moderate cash withdrawal', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
|
||||
NULL, NULL, 'DEBIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash withdrawal on a savings account' LIMIT 1;
|
||||
|
||||
-- Four or more cash credits each under Rs 50,000 inside a week, totalling Rs 1,00,000 or more.
|
||||
-- Deliberately capped at 49,999 so it cannot overlap the Medium band that begins at 50,000: this is
|
||||
-- frequency in small change, which is a shopkeeper ordinary week and only matters if it persists.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
|
||||
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'REPEATED_SMALL_CASH_DEPOSIT', 'Repeated small cash deposits in a week', 'SAVINGS', 100000, 1, 49999,
|
||||
'VELOCITY', 7, 4, 'CREDIT', true, NULL, id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'Repeated small cash deposits in a week' LIMIT 1;
|
||||
|
||||
-- Rs 25,000 up to Rs 5,00,000 on an account opened within the last 180 days. NEW_ACCOUNT is the scope the
|
||||
-- resolver already understands; the ceiling is where NEW_ACCOUNT_LARGE_TXN (Medium) takes over, so the
|
||||
-- two never both fire.
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
|
||||
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
|
||||
SELECT 'NEW_ACCOUNT_FIRST_ACTIVITY', 'First sizeable transaction on a new account', 'SAVINGS', 25000,
|
||||
25000, 499999, 'THRESHOLD', NULL, NULL, NULL, false, 'NEW_ACCOUNT', id, true
|
||||
FROM m_client_risk_rule WHERE rule_name = 'First sizeable transaction on a new account' LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Lets an officer dispose of the individual rules a transaction matched, not only the transaction as a whole.
|
||||
|
||||
One transaction raises one alert, under the rule that most warrants attention, and the rest are recorded alongside it.
|
||||
That is right for reporting - a transaction is one case - but it left the officer with one verdict covering everything
|
||||
that fired. A deposit can trip six rules where five are explainable by the customer's declared business and the sixth
|
||||
is not; recording "whitelisted" against all six loses exactly the observation that mattered, and recording
|
||||
"escalated" against all six sends five findings to the STR that do not belong there.
|
||||
|
||||
This table holds a decision per rule. The alert keeps its own status - it is still the case, and it is what the CTR
|
||||
and STR registers read - but it is now derived from the rules beneath it: every rule cleared means the alert is
|
||||
whitelisted, any rule escalated means the alert is escalated, and anything in between leaves the alert open with
|
||||
part of it decided.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0556-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="m_aml_alert_rule"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
|
||||
<createTable tableName="m_aml_alert_rule">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="alert_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- The code rather than a foreign key to m_aml_transaction_rule: the alert is a record of what was
|
||||
detected at the time, and editing or retiring a rule afterwards must not rewrite that history. -->
|
||||
<column name="rule_code" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Copied at the time of the decision for the same reason. A rule re-banded later does not change what
|
||||
the officer was looking at when they decided. -->
|
||||
<column name="risk_category" type="INT"/>
|
||||
<!-- True for the rule the alert itself was raised under. -->
|
||||
<column name="is_governing" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- NULL until decided; then WHITELIST or ESCALATE. -->
|
||||
<column name="disposition" type="VARCHAR(20)"/>
|
||||
<column name="reviewed_by" type="BIGINT"/>
|
||||
<column name="reviewed_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
<column name="review_notes" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="alert_id"
|
||||
constraintName="fk_aml_alert_rule_alert" referencedTableName="m_aml_alert"
|
||||
referencedColumnNames="id" onDelete="CASCADE"/>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="reviewed_by"
|
||||
constraintName="fk_aml_alert_rule_reviewer" referencedTableName="m_appuser"
|
||||
referencedColumnNames="id"/>
|
||||
|
||||
<!-- A rule appears once per alert: the same rule cannot be both cleared and escalated on one transaction. -->
|
||||
<addUniqueConstraint tableName="m_aml_alert_rule" columnNames="alert_id, rule_code"
|
||||
constraintName="uq_aml_alert_rule"/>
|
||||
|
||||
<createIndex tableName="m_aml_alert_rule" indexName="idx_aml_alert_rule_alert">
|
||||
<column name="alert_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- Backfill: every alert already raised gets a row per rule it matched, so the expanded pane is populated for
|
||||
history as well as for alerts raised from here on. An alert already decided has that decision copied down to
|
||||
each of its rules - it was made about all of them, and leaving them blank would show a closed alert as
|
||||
undecided. -->
|
||||
<changeSet author="fineract" id="0556-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_alert_rule</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
|
||||
reviewed_on, review_notes)
|
||||
SELECT a.id, r.rule_code, cr.risk_category, true, a.disposition, a.reviewed_by, a.reviewed_on, a.review_notes
|
||||
FROM m_aml_alert a
|
||||
JOIN m_aml_transaction_rule r ON r.id = a.rule_id
|
||||
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id;
|
||||
|
||||
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
|
||||
reviewed_on, review_notes)
|
||||
SELECT a.id, code.rule_code, cr.risk_category, false, a.disposition, a.reviewed_by, a.reviewed_on,
|
||||
a.review_notes
|
||||
FROM m_aml_alert a
|
||||
CROSS JOIN LATERAL unnest(string_to_array(a.also_matched_rules, ',')) AS raw(rule_code)
|
||||
CROSS JOIN LATERAL (SELECT trim(raw.rule_code) AS rule_code) AS code
|
||||
LEFT JOIN m_aml_transaction_rule r ON r.rule_code = code.rule_code
|
||||
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
|
||||
WHERE a.also_matched_rules IS NOT NULL
|
||||
AND a.also_matched_rules <> ''
|
||||
AND trim(raw.rule_code) <> ''
|
||||
ON CONFLICT (alert_id, rule_code) DO NOTHING;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Gives already-raised alerts the time they were actually raised.
|
||||
|
||||
raised_on was being set from the transaction's value date - a LocalDate widened with atStartOfDay() - so every alert
|
||||
ever raised sat at midnight. The feed is ordered by it and the screen prints it, so alerts raised minutes apart were
|
||||
indistinguishable and every row read "00:00". The engine now records the moment the alert is raised; this repairs the
|
||||
rows written before that.
|
||||
|
||||
created_on_utc is the auditing timestamp written when the row was inserted, which is the time being recovered. Only
|
||||
rows still sitting exactly at midnight are touched, so anything already correct is left alone and the changeset is
|
||||
safe to re-run.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0557-1">
|
||||
<sql>
|
||||
UPDATE m_aml_alert
|
||||
SET raised_on = created_on_utc
|
||||
WHERE created_on_utc IS NOT NULL
|
||||
AND raised_on = date_trunc('day', raised_on);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Separates the rule an officer actually decided from the rules that were carried with it.
|
||||
|
||||
Marking one rule suspicious escalates the whole transaction, and every other rule on it is closed under that same
|
||||
escalation - the transaction is one case, so nothing can be left open behind a filed STR. But the record then read as
|
||||
though the officer had examined and condemned all of them, which is not what happened and is not what an audit of the
|
||||
decision should show. On a transaction that tripped six rules, knowing which one the officer put their name to is the
|
||||
whole substance of the finding.
|
||||
|
||||
This flag carries that: true where the officer named the rule, false where the system closed it to keep the case
|
||||
consistent. Existing rows are backfilled on the same principle - a rule closed by a whole-alert decision was decided
|
||||
by the officer, because there they acted on the transaction as a whole.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0558-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_aml_alert_rule" columnName="directly_decided"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_alert_rule">
|
||||
<column name="directly_decided" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- Everything decided before this column existed was decided through a whole-alert action, which is a direct
|
||||
decision on every rule under it. -->
|
||||
<changeSet author="fineract" id="0558-2">
|
||||
<sql>
|
||||
UPDATE m_aml_alert_rule SET directly_decided = true WHERE disposition IS NOT NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user