Compare commits
18
Commits
demo
...
94b0afff38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
1a9adaec24 | ||
|
|
2da5a25e04 | ||
|
|
baf30cff68 | ||
|
|
f1d68d17fe | ||
|
|
d7d22382a5 | ||
|
|
7cdc25322e | ||
|
|
a4c3dbb6c4 | ||
|
|
dd8445de06 | ||
|
|
9345f2294c | ||
|
|
190cd92a85 |
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
||||
}
|
||||
|
||||
ext['groovy.version'] = '4.0.17'
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
|
||||
|
||||
// Apply static weaving configuration to all subprojects
|
||||
subprojects { subproject ->
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
||||
// Configure Java compilation
|
||||
tasks.named('compileJava') {
|
||||
outputs.cacheIf { true }
|
||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+3
-1
@@ -436,7 +436,9 @@ public final class AccountingConstants {
|
||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ public enum JobName {
|
||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
|
||||
+3
@@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
|
||||
long countByClientId(Long clientId);
|
||||
|
||||
/** Alerts for one customer inside the recurring-behaviour observation window. */
|
||||
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/**
|
||||
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
|
||||
*
|
||||
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
|
||||
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
|
||||
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_recurring_review")
|
||||
public class AmlRecurringReview {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "alert_count_at_review", nullable = false)
|
||||
private Integer alertCountAtReview;
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 30)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "notes", nullable = false, length = 1000)
|
||||
private String notes;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "reviewed_by", nullable = false)
|
||||
private AppUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on", nullable = false)
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
protected AmlRecurringReview() {}
|
||||
|
||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
|
||||
final AmlRecurringReview review = new AmlRecurringReview();
|
||||
review.client = client;
|
||||
review.alertCountAtReview = alertCountAtReview;
|
||||
review.outcome = outcome.name();
|
||||
review.notes = notes;
|
||||
review.reviewedBy = reviewedBy;
|
||||
review.reviewedOn = LocalDateTime.now();
|
||||
return review;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public Integer getAlertCountAtReview() {
|
||||
return alertCountAtReview;
|
||||
}
|
||||
|
||||
public AmlRecurringReviewOutcome getOutcome() {
|
||||
return AmlRecurringReviewOutcome.valueOf(outcome);
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
public AppUser getReviewedBy() {
|
||||
return reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return reviewedOn;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** What a reviewer concluded about a customer's repeated AML activity. */
|
||||
public enum AmlRecurringReviewOutcome {
|
||||
|
||||
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
|
||||
CLEARED,
|
||||
/** Not conclusive; keep watching this customer more closely. */
|
||||
MONITOR,
|
||||
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
|
||||
ESCALATED
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
|
||||
|
||||
/** Newest first, so the caller can take the head as the latest review for a customer. */
|
||||
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
|
||||
}
|
||||
+23
@@ -86,6 +86,29 @@ public class AmlTransactionRule {
|
||||
|
||||
protected AmlTransactionRule() {}
|
||||
|
||||
/**
|
||||
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
|
||||
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
|
||||
* the two rows are always created together and the rule code is derived from it.
|
||||
*/
|
||||
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
|
||||
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
|
||||
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
|
||||
final AmlTransactionRule rule = new AmlTransactionRule();
|
||||
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
|
||||
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
|
||||
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
|
||||
rule.description = description;
|
||||
rule.appliesTo = appliesTo.name();
|
||||
rule.ruleType = ruleType.name();
|
||||
rule.windowDays = windowDays;
|
||||
rule.thresholdAmount = thresholdAmount;
|
||||
rule.minLimitAmount = minLimitAmount;
|
||||
rule.maxLimitAmount = maxLimitAmount;
|
||||
rule.active = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "is_pwd")
|
||||
private Boolean isPwd;
|
||||
|
||||
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||
private ClientKyc clientKyc;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pwd_category_cv_id")
|
||||
private CodeValue pwdCategory;
|
||||
|
||||
+255
@@ -27,6 +27,7 @@ import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -103,6 +104,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "pan_status", length = 20)
|
||||
private String panStatus;
|
||||
|
||||
// ── PAN declaration ──────────────────────────────────────────────────────
|
||||
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
|
||||
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
|
||||
// never clear it however many times the officer retries. Rather than leave such a client
|
||||
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
|
||||
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
|
||||
|
||||
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
|
||||
@Column(name = "pan_declaration_taken")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
|
||||
@Column(name = "pan_declaration_mismatch_fields", length = 100)
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
|
||||
@Column(name = "pan_declaration_name_on_pan", length = 200)
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
/** Date of birth exactly as printed on the physical PAN card. */
|
||||
@Column(name = "pan_declaration_dob_on_pan")
|
||||
private LocalDate panDeclarationDobOnPan;
|
||||
|
||||
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
|
||||
@Column(name = "pan_declaration_remarks", length = 500)
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Column(name = "pan_declaration_taken_on")
|
||||
private LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||
@Column(name = "aadhaar_number_masked", length = 12)
|
||||
@@ -114,6 +145,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "aadhaar_verified_on")
|
||||
private LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
|
||||
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
|
||||
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
|
||||
// itself is issued there is no number to put through the OTP flow — the check cannot pass
|
||||
// however long the branch waits. Recording the receipt is what waives the exception.
|
||||
|
||||
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
|
||||
@Column(name = "aadhaar_receipt_taken")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
|
||||
@Column(name = "aadhaar_enrolment_id", length = 40)
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
|
||||
@Column(name = "aadhaar_enrolment_date")
|
||||
private LocalDate aadhaarEnrolmentDate;
|
||||
|
||||
/** Officer's note on what was sighted. */
|
||||
@Column(name = "aadhaar_receipt_remarks", length = 500)
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Column(name = "aadhaar_receipt_taken_on")
|
||||
private LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||
@Column(name = "gstin_number", length = 15)
|
||||
private String gstinNumber;
|
||||
@@ -195,6 +251,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "freeze_authority", length = 100)
|
||||
private String freezeAuthority;
|
||||
|
||||
@Column(name = "freeze_date")
|
||||
private LocalDate freezeDate;
|
||||
|
||||
@Column(name = "freeze_amount", precision = 19, scale = 6)
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Column(name = "unfreeze_date")
|
||||
private LocalDate unfreezeDate;
|
||||
|
||||
@Column(name = "no_debit_date")
|
||||
private LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
@Column(name = "form60_type", length = 20)
|
||||
private String form60Type;
|
||||
@@ -241,6 +309,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "remarks", length = 500)
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "is_exception", nullable = false)
|
||||
private Boolean isException;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Column(name = "exception_resolved", nullable = false)
|
||||
private Boolean exceptionResolved;
|
||||
|
||||
@Column(name = "exception_resolved_on")
|
||||
private LocalDate exceptionResolvedOn;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected ClientKyc() {}
|
||||
@@ -261,6 +341,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.gstinVerified = false;
|
||||
this.cinVerified = false;
|
||||
this.dinVerified = false;
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
}
|
||||
|
||||
@@ -288,6 +370,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
private void applyExtendedFields(final JsonCommand command) {
|
||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
||||
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
|
||||
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
|
||||
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
|
||||
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
|
||||
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
|
||||
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
|
||||
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
|
||||
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
|
||||
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
|
||||
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
|
||||
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
|
||||
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
|
||||
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
|
||||
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||
@@ -317,6 +417,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
||||
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
|
||||
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
|
||||
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
|
||||
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
|
||||
this.form60Type = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form60Type"), null);
|
||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||
@@ -325,6 +429,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("form15Type"), null);
|
||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form15FY"), null);
|
||||
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
@@ -367,6 +478,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||
v -> this.panAadhaarSeedingStatus = v);
|
||||
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
|
||||
v -> this.panDeclarationTaken = v != null ? v : false);
|
||||
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
|
||||
v -> this.panDeclarationMismatchFields = v);
|
||||
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
|
||||
v -> this.panDeclarationNameOnPan = v);
|
||||
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
|
||||
v -> this.panDeclarationDobOnPan = v);
|
||||
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
|
||||
v -> this.panDeclarationRemarks = v);
|
||||
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
|
||||
v -> this.panDeclarationTakenOn = v);
|
||||
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
|
||||
v -> this.aadhaarReceiptTaken = v != null ? v : false);
|
||||
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
|
||||
v -> this.aadhaarEnrolmentId = v);
|
||||
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
|
||||
v -> this.aadhaarEnrolmentDate = v);
|
||||
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
|
||||
v -> this.aadhaarReceiptRemarks = v);
|
||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||
v -> this.aadhaarReceiptTakenOn = 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);
|
||||
@@ -392,12 +525,23 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||
v -> this.freezeAuthority = v);
|
||||
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
|
||||
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
|
||||
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
|
||||
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
|
||||
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
@@ -431,6 +575,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||
public void markInProgress() {
|
||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||
@@ -631,6 +784,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return panStatus;
|
||||
}
|
||||
|
||||
public Boolean getPanDeclarationTaken() {
|
||||
return panDeclarationTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the PAN exception is waived. */
|
||||
public boolean isPanDeclarationTaken() {
|
||||
return Boolean.TRUE.equals(this.panDeclarationTaken);
|
||||
}
|
||||
|
||||
public String getPanDeclarationMismatchFields() {
|
||||
return panDeclarationMismatchFields;
|
||||
}
|
||||
|
||||
public String getPanDeclarationNameOnPan() {
|
||||
return panDeclarationNameOnPan;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationDobOnPan() {
|
||||
return panDeclarationDobOnPan;
|
||||
}
|
||||
|
||||
public String getPanDeclarationRemarks() {
|
||||
return panDeclarationRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationTakenOn() {
|
||||
return panDeclarationTakenOn;
|
||||
}
|
||||
|
||||
public Boolean getAadhaarReceiptTaken() {
|
||||
return aadhaarReceiptTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
|
||||
public boolean isAadhaarReceiptTaken() {
|
||||
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
|
||||
}
|
||||
|
||||
public String getAadhaarEnrolmentId() {
|
||||
return aadhaarEnrolmentId;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarEnrolmentDate() {
|
||||
return aadhaarEnrolmentDate;
|
||||
}
|
||||
|
||||
public String getAadhaarReceiptRemarks() {
|
||||
return aadhaarReceiptRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarReceiptTakenOn() {
|
||||
return aadhaarReceiptTakenOn;
|
||||
}
|
||||
|
||||
public String getAadhaarNumberMasked() {
|
||||
return aadhaarNumberMasked;
|
||||
}
|
||||
@@ -795,6 +1002,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public void setIsException(Boolean isException) {
|
||||
this.isException = isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public void setExceptionReason(String exceptionReason) {
|
||||
this.exceptionReason = exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
|
||||
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public LocalDate getExceptionResolvedOn() {
|
||||
return exceptionResolvedOn;
|
||||
}
|
||||
|
||||
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@@ -846,4 +1085,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public LocalDate getFreezeDate() {
|
||||
return this.freezeDate;
|
||||
}
|
||||
|
||||
public BigDecimal getFreezeAmount() {
|
||||
return this.freezeAmount;
|
||||
}
|
||||
|
||||
public LocalDate getUnfreezeDate() {
|
||||
return this.unfreezeDate;
|
||||
}
|
||||
|
||||
public LocalDate getNoDebitDate() {
|
||||
return this.noDebitDate;
|
||||
}
|
||||
}
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
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 customer-outreach attempt against an open KYC exception.
|
||||
*
|
||||
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||
*
|
||||
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||
* browser.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc_contact_log")
|
||||
public class ClientKycContactLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||
@Column(name = "kyc_id")
|
||||
private Long kycId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", length = 20, nullable = false)
|
||||
private KycContactChannel channel;
|
||||
|
||||
@Column(name = "notes", length = 1000, nullable = false)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "officer_name", length = 200)
|
||||
private String officerName;
|
||||
|
||||
@Column(name = "logged_by_user_id")
|
||||
private Long loggedByUserId;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||
private KycContactDeliveryStatus deliveryStatus;
|
||||
|
||||
@Column(name = "destination_masked", length = 120)
|
||||
private String destinationMasked;
|
||||
|
||||
@Column(name = "provider", length = 40)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "provider_reference", length = 120)
|
||||
private String providerReference;
|
||||
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "message_body", length = 2000)
|
||||
private String messageBody;
|
||||
|
||||
@Column(name = "contacted_at", nullable = false)
|
||||
private LocalDateTime contactedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
protected ClientKycContactLog() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||
this.client = client;
|
||||
this.kycId = kycId;
|
||||
this.channel = channel;
|
||||
this.notes = notes;
|
||||
this.officerName = officerName;
|
||||
this.loggedByUserId = loggedByUserId;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.contactedAt = contactedAt;
|
||||
this.createdAt = contactedAt;
|
||||
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||
* persisted — the entity is append-only afterwards.
|
||||
*/
|
||||
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||
final String providerReference, final String failureReason, final String messageBody) {
|
||||
this.deliveryStatus = status;
|
||||
this.destinationMasked = destinationMasked;
|
||||
this.provider = provider;
|
||||
this.providerReference = providerReference;
|
||||
this.failureReason = truncate(failureReason, 500);
|
||||
this.messageBody = truncate(messageBody, 2000);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public Long getKycId() {
|
||||
return this.kycId;
|
||||
}
|
||||
|
||||
public KycContactChannel getChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
public String getOfficerName() {
|
||||
return this.officerName;
|
||||
}
|
||||
|
||||
public Long getLoggedByUserId() {
|
||||
return this.loggedByUserId;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return this.exceptionReason;
|
||||
}
|
||||
|
||||
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||
return this.deliveryStatus;
|
||||
}
|
||||
|
||||
public String getDestinationMasked() {
|
||||
return this.destinationMasked;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public String getProviderReference() {
|
||||
return this.providerReference;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
public String getMessageBody() {
|
||||
return this.messageBody;
|
||||
}
|
||||
|
||||
public LocalDateTime getContactedAt() {
|
||||
return this.contactedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return this.createdAt;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
*/
|
||||
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||
}
|
||||
+12
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||
|
||||
/**
|
||||
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||
*
|
||||
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||
* every resolved record invisible in both tabs the moment it was resolved.
|
||||
*/
|
||||
List<ClientKyc> findByExceptionResolvedTrue();
|
||||
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||
|
||||
|
||||
-7
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
||||
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||
void removeSignatureId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
||||
|
||||
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
||||
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||
|
||||
+38
@@ -95,6 +95,44 @@ public class ClientRiskRule {
|
||||
|
||||
protected ClientRiskRule() {}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
*/
|
||||
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
|
||||
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
|
||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||
final String dormantFlag) {
|
||||
final ClientRiskRule rule = new ClientRiskRule();
|
||||
rule.ruleName = ruleName;
|
||||
rule.reason = reason;
|
||||
rule.riskCategory = riskCategory;
|
||||
rule.orderPosition = orderPosition;
|
||||
rule.legalFormId = legalFormId;
|
||||
rule.minMonthlyIncome = minMonthlyIncome;
|
||||
rule.maxMonthlyIncome = maxMonthlyIncome;
|
||||
rule.minAnnualIncome = minAnnualIncome;
|
||||
rule.maxAnnualIncome = maxAnnualIncome;
|
||||
rule.minAnnualTurnover = minAnnualTurnover;
|
||||
rule.maxAnnualTurnover = maxAnnualTurnover;
|
||||
rule.amlCheckStatus = amlCheckStatus;
|
||||
rule.bannedEntityMatch = bannedEntityMatch;
|
||||
rule.willfulDefaulter = willfulDefaulter;
|
||||
rule.ecgcSalFlag = ecgcSalFlag;
|
||||
rule.dormantFlag = dormantFlag;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public void setOccupationCategory(final CodeValue occupationCategory) {
|
||||
this.occupationCategory = occupationCategory;
|
||||
}
|
||||
|
||||
public void setOccupation(final CodeValue occupation) {
|
||||
this.occupation = occupation;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
||||
|
||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
||||
|
||||
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
|
||||
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
|
||||
Integer findMaxOrderPosition();
|
||||
|
||||
boolean existsByRuleNameIgnoreCase(String ruleName);
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||
* KYC exception.
|
||||
*
|
||||
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||
* <ul>
|
||||
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||
* down.</li>
|
||||
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||
* actually transmit a notice to the customer at save time.</li>
|
||||
* </ul>
|
||||
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||
* received, so every call site branches on this flag rather than on the channel name.
|
||||
*/
|
||||
public enum KycContactChannel {
|
||||
|
||||
PHONE_CALL("Phone Call", false),
|
||||
SMS("SMS", true),
|
||||
EMAIL("Email", true),
|
||||
WHATSAPP("WhatsApp Message", true),
|
||||
BRANCH_VISIT("In-Branch Visit", false),
|
||||
FIELD_VISIT("Agent Field Visit", false);
|
||||
|
||||
private final String displayLabel;
|
||||
private final boolean platformSent;
|
||||
|
||||
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||
this.displayLabel = displayLabel;
|
||||
this.platformSent = platformSent;
|
||||
}
|
||||
|
||||
public String getDisplayLabel() {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||
public boolean isPlatformSent() {
|
||||
return this.platformSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||
* labels describe the *purpose* of the message, not a different channel.
|
||||
*
|
||||
* @return the matching channel, or {@code null} when nothing matches.
|
||||
*/
|
||||
public static KycContactChannel fromString(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String normalised = raw.trim().toUpperCase();
|
||||
for (final KycContactChannel channel : values()) {
|
||||
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||
KycContactChannel best = null;
|
||||
for (final KycContactChannel channel : values()) {
|
||||
final String label = channel.displayLabel.toUpperCase();
|
||||
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||
best = channel;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Outcome of a platform-sent KYC contact notice.
|
||||
*
|
||||
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||
*/
|
||||
public enum KycContactDeliveryStatus {
|
||||
|
||||
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||
NOT_APPLICABLE,
|
||||
|
||||
/** The gateway accepted the message for delivery. */
|
||||
SENT,
|
||||
|
||||
/** The gateway was reached but rejected the message, or the call errored. */
|
||||
FAILED,
|
||||
|
||||
/** The customer has no mobile number / email address on file for this channel. */
|
||||
SKIPPED_NO_DESTINATION,
|
||||
|
||||
/** No gateway is configured for this channel in this deployment. */
|
||||
PROVIDER_NOT_CONFIGURED;
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+29
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
@Deprecated
|
||||
private final List<EntityImageIdAdapter> imageIdAdapters;
|
||||
private final ContentStoreService storeService;
|
||||
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_image row survives but the file it points at does not — the usual cause is a
|
||||
// database copied between environments without its file store. For the caller that is
|
||||
// simply "this entity has no image", not a server fault, so report it the same way as a
|
||||
// missing image record instead of letting it surface as a 500.
|
||||
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
|
||||
entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, -1L);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.dividendpaidreport.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.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
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.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
|
||||
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
|
||||
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
|
||||
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
|
||||
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
|
||||
* is, by construction, a real dividend payment.
|
||||
*/
|
||||
@Path("/v1/accounting/dividend-paid-report")
|
||||
@Component
|
||||
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendPaidReportApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("fromDate", fromDate);
|
||||
result.addProperty("toDate", toDate);
|
||||
if (payableDividendsAccountId == null) {
|
||||
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
|
||||
result.addProperty("totalPaid", BigDecimal.ZERO);
|
||||
result.addProperty("payoutCount", 0);
|
||||
result.add("entries", new JsonArray());
|
||||
result.addProperty("glAccountConfigured", false);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
|
||||
SELECT entry_date, amount, transaction_id
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
ORDER BY entry_date DESC
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
|
||||
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
|
||||
result.addProperty("glAccountConfigured", true);
|
||||
|
||||
final JsonArray entries = new JsonArray();
|
||||
for (final Map<String, Object> row : entryRows) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("entryDate", row.get("entry_date").toString());
|
||||
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||
entry.addProperty("transactionId", (String) row.get("transaction_id"));
|
||||
entries.add(entry);
|
||||
}
|
||||
result.add("entries", entries);
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject errorBody = new JsonObject();
|
||||
errorBody.addProperty("message", message);
|
||||
errorBody.addProperty("defaultUserMessage", message);
|
||||
errorBody.addProperty("httpStatus", httpStatus);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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.profitandloss.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Real Profit & Loss (Net Profit) for a date range, computed live from posted General Ledger
|
||||
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
|
||||
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
|
||||
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
|
||||
*
|
||||
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
|
||||
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
|
||||
* and decreases with a CREDIT. Reversed entries are excluded.
|
||||
*/
|
||||
@Path("/v1/accounting/profitandloss")
|
||||
@Component
|
||||
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
|
||||
@RequiredArgsConstructor
|
||||
public class ProfitAndLossApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_income,
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_expense
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE je.reversed = false
|
||||
AND je.entry_date BETWEEN ? AND ?
|
||||
""", from, to);
|
||||
|
||||
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||
|
||||
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
|
||||
"netProfit", netProfit));
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
String shareEntityType = "share";
|
||||
|
||||
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
|
||||
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
|
||||
String redeemresolutionnumber_paramname = "resolutionNumber";
|
||||
|
||||
String redeemmeetingdate_paramname = "meetingDate";
|
||||
|
||||
/** Loan this share account is held as margin/security against — optional, set via the
|
||||
* account update command. When present, a redeem request is checked against the loan's
|
||||
* outstanding balance. */
|
||||
String linkedloanid_paramname = "linkedLoanId";
|
||||
|
||||
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,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
numberofshares_paramname, purchasedprice_paramname)));
|
||||
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
|
||||
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
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.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/recurring-alerts")
|
||||
@Component
|
||||
@Tag(name = "AML Recurring Alerts",
|
||||
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
|
||||
@RequiredArgsConstructor
|
||||
public class AmlRecurringAlertApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final AmlRecurringAlertWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the recurring-alert review queue",
|
||||
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
|
||||
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
|
||||
public String retrieveRecurringAlerts(
|
||||
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
|
||||
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
|
||||
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
|
||||
this.context.authenticatedUser();
|
||||
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
|
||||
onlyNeedingReview);
|
||||
return this.toApiJsonSerializer.serialize(rows);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{clientId}/review")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Record a review of a customer's repeated AML activity",
|
||||
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
|
||||
+ "count grows beyond the count reviewed here.")
|
||||
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
|
||||
request.getWindowDays() == null ? 0 : request.getWindowDays());
|
||||
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AmlRecurringReviewRequest {
|
||||
|
||||
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
|
||||
private String outcome;
|
||||
|
||||
@Schema(description = "Why the customer keeps surfacing and what was decided",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String notes;
|
||||
|
||||
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
|
||||
example = "90")
|
||||
private Integer windowDays;
|
||||
}
|
||||
+8
-1
@@ -22,7 +22,14 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param riskCategory
|
||||
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
|
||||
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
|
||||
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
|
||||
*/
|
||||
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) implements Serializable {
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
|
||||
implements Serializable {
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One customer who has tripped the AML engine repeatedly inside the observation window.
|
||||
*
|
||||
* @param alertCount
|
||||
* alerts in the window — what the threshold is compared against
|
||||
* @param highRiskCount
|
||||
* how many of those came from a High severity catalog rule
|
||||
* @param distinctRules
|
||||
* number of different rules involved; one rule firing five times is a weaker signal than
|
||||
* five different rules firing once, and the reviewer needs to see which it is
|
||||
* @param openCount
|
||||
* alerts still awaiting a per-transaction disposition
|
||||
* @param escalatedCount
|
||||
* alerts already escalated to an STR case
|
||||
* @param needsReview
|
||||
* true when there is no review yet, or new alerts have arrived since the last one
|
||||
* @param alerts
|
||||
* the triggering transactions themselves, so the reviewer can see why this customer keeps
|
||||
* surfacing without leaving the row
|
||||
*/
|
||||
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
|
||||
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
|
||||
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
|
||||
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
|
||||
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
|
||||
}
|
||||
+25
-4
@@ -19,12 +19,15 @@
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
|
||||
|
||||
private final AmlAlertRepository repository;
|
||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
|
||||
.collect(Collectors.toList());
|
||||
// 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);
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
||||
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private AmlAlertData mapToData(AmlAlert alert) {
|
||||
/** 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();
|
||||
final Map<Long, Integer> byId = new HashMap<>();
|
||||
if (sourceIds.isEmpty()) {
|
||||
return byId;
|
||||
}
|
||||
this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory()));
|
||||
return byId;
|
||||
}
|
||||
|
||||
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
|
||||
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());
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
public interface AmlRecurringAlertNotificationService {
|
||||
|
||||
/**
|
||||
* Called once per transaction after the engine has saved its alerts. Raises a notification for
|
||||
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
|
||||
*
|
||||
* @param alertsJustRaised
|
||||
* how many alerts this transaction produced, so the count before it can be derived
|
||||
*/
|
||||
void onAlertsRaised(Client client, int alertsJustRaised);
|
||||
}
|
||||
+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.portfolio.aml.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.notification.service.UserNotificationService;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
|
||||
*
|
||||
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
|
||||
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
|
||||
* customer's office, resolved by {@link UserNotificationService}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
|
||||
|
||||
/**
|
||||
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
|
||||
* treats as "this customer has done this enough times to warrant another look".
|
||||
*/
|
||||
private static final int RECURRING_THRESHOLD = 3;
|
||||
private static final int WINDOW_DAYS = 90;
|
||||
|
||||
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
|
||||
private static final String OBJECT_TYPE = "AML_RECURRING";
|
||||
private static final String EVENT_TYPE = "recurringAmlActivity";
|
||||
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final UserNotificationService userNotificationService;
|
||||
|
||||
@Override
|
||||
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
|
||||
if (client == null || alertsJustRaised <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
|
||||
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
|
||||
final long before = after - alertsJustRaised;
|
||||
|
||||
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
|
||||
// alert once the threshold is passed. Notifying every time would bury the reviewer in
|
||||
// duplicates for a customer they have already been told about; notifying only on the
|
||||
// first crossing would go permanently silent on a customer who keeps offending.
|
||||
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String content = String.format(
|
||||
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
|
||||
client.getDisplayName(), after, WINDOW_DAYS);
|
||||
|
||||
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
|
||||
client.getOffice() == null ? null : client.getOffice().getId());
|
||||
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
|
||||
} catch (final RuntimeException e) {
|
||||
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
|
||||
// it is watching, and a notification is the least important part of that observation.
|
||||
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
|
||||
public interface AmlRecurringAlertReadPlatformService {
|
||||
|
||||
/**
|
||||
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
|
||||
*
|
||||
* @param onlyNeedingReview
|
||||
* when true, hides customers already reviewed at their current alert count
|
||||
*/
|
||||
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
|
||||
|
||||
/** Alerts in the window for one customer, used to record a review against the count seen. */
|
||||
int alertCountInWindow(Long clientId, int windowDays);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Builds the recurring-alert review queue.
|
||||
*
|
||||
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
|
||||
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
|
||||
* never disagree with the Risk Category shown on the alert itself.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
|
||||
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
private final AmlAlertReadPlatformService alertReadPlatformService;
|
||||
private final AmlRecurringReviewRepository reviewRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
|
||||
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
|
||||
final LocalDateTime cutoff = windowFrom.atStartOfDay();
|
||||
|
||||
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
|
||||
// nested alerts stay newest-first without a second sort.
|
||||
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
|
||||
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
|
||||
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
|
||||
.map(Map.Entry::getKey).toList();
|
||||
if (qualifying.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
|
||||
|
||||
final List<AmlRecurringAlertData> rows = new ArrayList<>();
|
||||
for (final Long clientId : qualifying) {
|
||||
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();
|
||||
if (onlyNeedingReview && !needsReview) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
|
||||
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
|
||||
summary.riskCategory(), alerts.size(),
|
||||
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
||||
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
||||
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
|
||||
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
||||
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
||||
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
|
||||
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
|
||||
lastReview == null ? null : lastReview.getAlertCountAtReview(),
|
||||
lastReview == null ? null : lastReview.getOutcome().name(),
|
||||
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
|
||||
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
|
||||
windowFrom, alerts));
|
||||
}
|
||||
|
||||
// Worst offenders first; most recent activity breaks ties.
|
||||
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
|
||||
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int alertCountInWindow(final Long clientId, final int windowDays) {
|
||||
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
|
||||
return (int) this.alertReadPlatformService.retrieve(null).stream()
|
||||
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
|
||||
}
|
||||
|
||||
/** Account number and current KYC risk rating, fetched once for the whole queue. */
|
||||
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
|
||||
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
|
||||
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
|
||||
final Map<Long, ClientSummary> byId = new HashMap<>();
|
||||
this.jdbcTemplate.query(sql, rs -> {
|
||||
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
|
||||
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
|
||||
}, clientIds.toArray());
|
||||
return byId;
|
||||
}
|
||||
|
||||
private record ClientSummary(String accountNo, Integer riskCategory) {}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public interface AmlRecurringAlertWritePlatformService {
|
||||
|
||||
/**
|
||||
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
|
||||
* the reviewer was looking at so the customer only resurfaces when that count grows.
|
||||
*
|
||||
* @param windowDays
|
||||
* observation window the reviewer had on screen, so the recorded count matches it
|
||||
* @return the id of the stored review
|
||||
*/
|
||||
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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.AmlRecurringReview;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
|
||||
|
||||
private static final String RESOURCE = "amlrecurringreview";
|
||||
private static final int DEFAULT_WINDOW_DAYS = 90;
|
||||
|
||||
private final AmlRecurringReviewRepository reviewRepository;
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
|
||||
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
|
||||
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
// Counted here rather than taken from the request: a caller-supplied count could be used to
|
||||
// silence a customer at an inflated number they were never actually reviewed at.
|
||||
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
|
||||
|
||||
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
||||
this.context.authenticatedUser());
|
||||
return this.reviewRepository.saveAndFlush(review).getId();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
validator.reset().parameter("clientId").value(clientId).notNull();
|
||||
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
|
||||
validator.reset().parameter("outcome").value(outcome).notBlank();
|
||||
|
||||
AmlRecurringReviewOutcome parsed = null;
|
||||
if (StringUtils.isNotBlank(outcome)) {
|
||||
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
|
||||
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
|
||||
parsed = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parsed == null) {
|
||||
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
+9
@@ -44,6 +44,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -59,6 +60,14 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
raised.add(alertRepository.save(alert));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// re-querying what it just wrote.
|
||||
if (!raised.isEmpty()) {
|
||||
recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size());
|
||||
}
|
||||
return raised;
|
||||
}
|
||||
|
||||
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
/**
|
||||
* 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.clerkwithdrawal.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
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.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Maker-checker withdrawal (redeem shares) request workflow for a Share Account, submitted only
|
||||
* 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).
|
||||
*/
|
||||
@Path("/v1/clerk/share-withdrawal-requests")
|
||||
@Component
|
||||
@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||
// simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string.
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final String[] LIST_COLUMNS = { "id", "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", "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" };
|
||||
|
||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
|
||||
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
|
||||
final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root);
|
||||
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", 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
|
||||
// it, since the CR row's account number is only ever used client-side to double-check against
|
||||
// the share account's own authoritative linked-savings-account link.
|
||||
final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null;
|
||||
final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString()
|
||||
: null;
|
||||
|
||||
if (shareAccountId == null) {
|
||||
throw apiError(400, "Share account id is required");
|
||||
}
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (requestedShares == null || requestedShares <= 0) {
|
||||
throw apiError(400, "Number of shares to withdraw must be greater than zero");
|
||||
}
|
||||
if (requestedDate == null || requestedDate.isBlank()) {
|
||||
throw apiError(400, "Requested date is required");
|
||||
}
|
||||
if (resolutionNumber == null || resolutionNumber.isBlank()) {
|
||||
throw apiError(400, "Board Resolution Number is required for a share withdrawal");
|
||||
}
|
||||
if (meetingDate == null || meetingDate.isBlank()) {
|
||||
throw apiError(400, "Date of Meeting is required for a share withdrawal");
|
||||
}
|
||||
if (settlementPaymentTypeId == null) {
|
||||
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
|
||||
}
|
||||
validateSettlementPaymentType(settlementPaymentTypeId);
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
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);
|
||||
|
||||
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.
|
||||
*/
|
||||
private void validateSettlementPaymentType(final Long paymentTypeId) {
|
||||
final Map<String, Object> paymentType = jdbcTemplate
|
||||
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst()
|
||||
.orElse(null);
|
||||
if (paymentType == null) {
|
||||
throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist");
|
||||
}
|
||||
final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment"));
|
||||
if (isCash) {
|
||||
throw apiError(400, "Settlement Payment Type '" + paymentType.get("value")
|
||||
+ "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer");
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests ── Manager sees all requests ────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||
final int offset, final int limit) {
|
||||
final StringBuilder where = new StringBuilder(baseWhere);
|
||||
final List<Object> params = new ArrayList<>(baseParams);
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional
|
||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||
params.toArray()))
|
||||
.orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ─────────────
|
||||
|
||||
/**
|
||||
* Unlike list()/myRequests(), this single-record detail also carries the client's compliance
|
||||
* summary (KYC status, Risk Category, open AML alert count) — so the review screen can show it
|
||||
* permanently on the page, not only inside the on-demand /validate popup.
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final Map<String, Object> enriched = new java.util.LinkedHashMap<>();
|
||||
for (final Map.Entry<?, ?> entry : record.entrySet()) {
|
||||
enriched.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
final Object clientIdObj = record.get("client_id");
|
||||
final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null;
|
||||
enriched.putAll(loadClientComplianceSummary(clientId));
|
||||
return gson.toJson(enriched);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadClientComplianceSummary(final Long clientId) {
|
||||
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
if (clientId == null) {
|
||||
return summary;
|
||||
}
|
||||
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
|
||||
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
|
||||
WHERE client_id = ? ORDER BY id DESC LIMIT 1
|
||||
""", clientId).stream().findFirst().orElse(null);
|
||||
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
|
||||
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue()
|
||||
: null;
|
||||
final Long openAlerts = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
|
||||
|
||||
summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record");
|
||||
summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300);
|
||||
summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified");
|
||||
summary.put("client_risk_high", riskCategory != null && riskCategory == 3);
|
||||
summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ──
|
||||
|
||||
/**
|
||||
* Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so
|
||||
* the checker can catch things that changed since submission — a share/savings account that got
|
||||
* closed, a payment type that got deactivated, a client whose KYC lapsed or who now has an open
|
||||
* AML alert, an active loan secured by these very shares ({@code m_share_account.linked_loan_id}
|
||||
* — this bank supports loan-against-shares), that loan's tenure not yet being complete, this
|
||||
* client currently guaranteeing someone else's active loan, or (the exact bug class this session
|
||||
* ran into) a savings product's GL Fund Source mapping for this payment type going missing, which
|
||||
* would silently post the settlement leg to the default Cash at Vault account instead of Share
|
||||
* Sundry.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/validate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String validate(@PathParam("id") final Long id) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final List<Map<String, Object>> checks = new ArrayList<>();
|
||||
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(),
|
||||
resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber));
|
||||
|
||||
final Object meetingDate = record.get("meeting_date");
|
||||
checks.add(checkResult("Date of Meeting provided", meetingDate != null,
|
||||
meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length()))));
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
|
||||
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
||||
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
|
||||
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
||||
FROM m_share_account WHERE id = ?
|
||||
""", shareAccountId).stream().findFirst().orElse(null);
|
||||
|
||||
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
|
||||
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
|
||||
: 0L;
|
||||
checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares,
|
||||
requestedShares + " of " + heldShares + " shares currently held"));
|
||||
|
||||
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
|
||||
? ((Number) shareAccountRow.get("statusEnum")).intValue()
|
||||
: -1;
|
||||
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
||||
|
||||
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
||||
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
||||
: null;
|
||||
final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null
|
||||
? ((Number) shareAccountRow.get("clientId")).longValue()
|
||||
: null;
|
||||
|
||||
Integer savingsStatus = null;
|
||||
Long savingsProductId = null;
|
||||
if (savingsAccountId != null) {
|
||||
final Map<String, Object> savingsRow = jdbcTemplate
|
||||
.queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?",
|
||||
savingsAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue()
|
||||
: null;
|
||||
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)));
|
||||
|
||||
// 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
|
||||
// loan is still outstanding, and separately, releasing shares to a client currently on the hook as a
|
||||
// guarantor for someone else's active loan deserves the same scrutiny.
|
||||
final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null
|
||||
? ((Number) shareAccountRow.get("linkedLoanId")).longValue()
|
||||
: null;
|
||||
Map<String, Object> linkedLoanRow = null;
|
||||
if (linkedLoanId != null) {
|
||||
linkedLoanRow = jdbcTemplate.queryForList("""
|
||||
SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate
|
||||
FROM m_loan WHERE id = ?
|
||||
""", linkedLoanId).stream().findFirst().orElse(null);
|
||||
}
|
||||
final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null
|
||||
? ((Number) linkedLoanRow.get("statusId")).intValue()
|
||||
: null;
|
||||
final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus);
|
||||
final String loanCheckDetail;
|
||||
if (linkedLoanId == null) {
|
||||
loanCheckDetail = "No loan linked to this share account";
|
||||
} else if (linkedLoanRow == null) {
|
||||
loanCheckDetail = "Loan #" + linkedLoanId + " — record not found";
|
||||
} else {
|
||||
loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus);
|
||||
}
|
||||
checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding,
|
||||
loanCheckDetail));
|
||||
|
||||
boolean tenureOk = true;
|
||||
String tenureDetail = "No linked loan";
|
||||
if (linkedLoanId != null && linkedLoanOutstanding && linkedLoanRow != null) {
|
||||
final Object maturedOnObj = linkedLoanRow.get("expectedMaturedOnDate");
|
||||
if (maturedOnObj instanceof java.sql.Date maturedOnDate) {
|
||||
final boolean matured = !maturedOnDate.toLocalDate()
|
||||
.isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
tenureOk = matured;
|
||||
tenureDetail = "Linked loan term ends " + maturedOnDate.toLocalDate() + (matured ? " — completed" : " — not yet completed");
|
||||
} else {
|
||||
tenureOk = false;
|
||||
tenureDetail = "Linked loan is active with no expected maturity date on record";
|
||||
}
|
||||
}
|
||||
checks.add(checkResult("Tenure Check — linked loan term completed", tenureOk, tenureDetail));
|
||||
|
||||
if (clientId != null) {
|
||||
final Long guarantorActiveLoans = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id
|
||||
WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true
|
||||
AND l.loan_status_id IN (300, 303, 304, 700)
|
||||
""", Long.class, clientId);
|
||||
checks.add(checkResult("Guarantor Check — not guaranteeing any active loan",
|
||||
guarantorActiveLoans == null || guarantorActiveLoans == 0,
|
||||
guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)"
|
||||
: "None"));
|
||||
}
|
||||
|
||||
final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number
|
||||
? ((Number) settlementPaymentTypeIdObj).longValue()
|
||||
: null;
|
||||
boolean paymentTypeNonCash = false;
|
||||
String paymentTypeDetail = "Missing";
|
||||
if (settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> pt = jdbcTemplate
|
||||
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (pt != null) {
|
||||
paymentTypeNonCash = !Boolean.TRUE.equals(pt.get("is_cash_payment"));
|
||||
paymentTypeDetail = pt.get("value") + (paymentTypeNonCash ? "" : " (cash — not allowed)");
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
|
||||
|
||||
if (clientId != null) {
|
||||
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
|
||||
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
|
||||
WHERE client_id = ? ORDER BY id DESC LIMIT 1
|
||||
""", clientId).stream().findFirst().orElse(null);
|
||||
|
||||
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
|
||||
checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300,
|
||||
kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus)));
|
||||
|
||||
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null
|
||||
? ((Number) kycRow.get("riskCategory")).intValue()
|
||||
: null;
|
||||
checks.add(checkResult("Client Risk Category is not High", riskCategory == null || riskCategory != 3,
|
||||
riskCategory == null ? "Not classified" : riskCategoryLabel(riskCategory)));
|
||||
|
||||
final Long openAlerts = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
|
||||
checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0,
|
||||
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
|
||||
}
|
||||
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("label", label);
|
||||
result.put("pass", pass);
|
||||
result.put("detail", detail);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isLoanStatusOutstanding(final int loanStatusId) {
|
||||
return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700;
|
||||
}
|
||||
|
||||
private String loanStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 303 -> "Transfer in Progress";
|
||||
case 304 -> "Transfer on Hold";
|
||||
case 400 -> "Withdrawn by Client";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed (Obligations Met)";
|
||||
case 601 -> "Closed (Written Off)";
|
||||
case 602 -> "Closed (Rescheduled)";
|
||||
case 700 -> "Overpaid";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String shareStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String savingsStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 303 -> "Transfer in Progress";
|
||||
case 304 -> "Transfer on Hold";
|
||||
case 400 -> "Withdrawn by Applicant";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed";
|
||||
case 700 -> "Pre-mature Closure";
|
||||
case 800 -> "Matured";
|
||||
case 900 -> "Terminated";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String kycStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Pending";
|
||||
case 200 -> "In Progress";
|
||||
case 300 -> "Verified";
|
||||
case 400 -> "Rejected";
|
||||
case 500 -> "Expired";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String riskCategoryLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 1 -> "Low";
|
||||
case 2 -> "Medium";
|
||||
case 3 -> "High";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ──
|
||||
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId())) {
|
||||
throw apiError(403,
|
||||
"Maker-checker: you cannot approve a share withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
final Object requestedDateObj = record.get("requested_date");
|
||||
final Object meetingDateObj = record.get("meeting_date");
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString()));
|
||||
payload.addProperty("requestedShares", requestedShares);
|
||||
payload.addProperty("resolutionNumber", resolutionNumber);
|
||||
payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString()));
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = 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.
|
||||
final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null;
|
||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||
|
||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||
// to the member's linked savings account. Two Fineract journal entries get posted — native
|
||||
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
|
||||
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
|
||||
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
final int approvedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
share_transaction_id = ?, activity_log = ?, actioned_at = ?,
|
||||
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,
|
||||
settlement != null ? settlement.savingsTransactionId() : null, id);
|
||||
if (approvedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
// If this redemption brought the account's holding to zero, close the share account right
|
||||
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
|
||||
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
|
||||
// redemption amount itself already went through this same Maker-Checker review; there are no
|
||||
// shares left for the native close command to force-redeem without oversight.
|
||||
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (shareTxnId != null) {
|
||||
res.addProperty("shareTransactionId", shareTxnId);
|
||||
}
|
||||
if (settlement != null) {
|
||||
res.addProperty("settlementAmount", settlement.amount());
|
||||
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
|
||||
} else {
|
||||
res.addProperty("settlementWarning", settlementNote);
|
||||
}
|
||||
if (accountClosed) {
|
||||
res.addProperty("accountClosed", true);
|
||||
}
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the share account when this redemption has just brought its holding to zero — the
|
||||
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
|
||||
* needing a separate closure request to be submitted and approved afterwards.
|
||||
*/
|
||||
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
|
||||
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
|
||||
Long.class, shareAccountId);
|
||||
if (remainingShares == null || remainingShares != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final JsonObject closePayload = new JsonObject();
|
||||
closePayload.addProperty("locale", "en");
|
||||
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||
|
||||
/**
|
||||
* Credits the redemption value to the share account's linked savings account, if one exists.
|
||||
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
||||
* the unit price ourselves — the transaction was already priced by Fineract's own
|
||||
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
||||
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
|
||||
* command so the transaction is fully audited and posts its own GL entry the normal way — the
|
||||
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
|
||||
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
|
||||
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
|
||||
* on that bridge account. Falls back to the legacy hardcoded
|
||||
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
|
||||
* column existed.
|
||||
*/
|
||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForList("""
|
||||
SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount
|
||||
FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id
|
||||
WHERE sat.id = ?
|
||||
""", shareTransactionId).stream().findFirst().orElse(null);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue();
|
||||
if (savingsAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
return new Settlement(settlementAmount, result.getResourceId());
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Rejection reason is required");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
|
||||
|
||||
final int rejectedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
|
||||
Timestamp.from(Instant.now()), id);
|
||||
if (rejectedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
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("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject share withdrawal requests.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Share withdrawal request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String formatForFineract(final String isoOrTimestamp) {
|
||||
try {
|
||||
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
|
||||
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
if (existingJson != null && !existingJson.isBlank()) {
|
||||
try {
|
||||
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
|
||||
} catch (Exception e) {
|
||||
log = new JsonArray();
|
||||
}
|
||||
}
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("timestamp", Instant.now().toString());
|
||||
entry.addProperty("action", action);
|
||||
entry.addProperty("user", username);
|
||||
if (note != null && !note.isBlank()) {
|
||||
entry.addProperty("note", note);
|
||||
}
|
||||
log.add(entry);
|
||||
return gson.toJson(log);
|
||||
}
|
||||
|
||||
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(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
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.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Customer-outreach log for KYC exceptions.
|
||||
*
|
||||
* <pre>
|
||||
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
|
||||
* GET /v1/kyc/exceptions/{clientId}/contact-log
|
||||
* </pre>
|
||||
*
|
||||
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
|
||||
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
|
||||
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
|
||||
* body with HTTP 200, not as an error, because the log row was still written.
|
||||
*
|
||||
* <p>No PUT or DELETE: the log is append-only evidence.
|
||||
*
|
||||
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
|
||||
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
|
||||
* already holds it.
|
||||
*/
|
||||
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycContactLogApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycContactLogService kycContactLogService;
|
||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Log a customer contact attempt for a KYC exception",
|
||||
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
|
||||
+ "customer using the destination held on their client record. Returns the persisted log including "
|
||||
+ "its delivery outcome.")
|
||||
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
|
||||
description = "All logged outreach for this customer, oldest first.")
|
||||
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
|
||||
return this.toApiJsonSerializer.serialize(logs);
|
||||
}
|
||||
|
||||
private JsonObject parse(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(json);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject payload, final String member) {
|
||||
final JsonElement element = payload.get(member);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
|
||||
@Path("/v1/kyc/exceptions")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycExceptionsApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientKycReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
|
||||
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
|
||||
this.context.authenticatedUser();
|
||||
final List<ClientKycData> exceptions;
|
||||
if (Boolean.TRUE.equals(resolved)) {
|
||||
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
|
||||
} else {
|
||||
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
|
||||
}
|
||||
for (ClientKycData k : exceptions) {
|
||||
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
|
||||
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
|
||||
}
|
||||
return this.toApiJsonSerializer.serialize(exceptions);
|
||||
}
|
||||
}
|
||||
+17
@@ -22,6 +22,7 @@ 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.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/kyc/risk-rules")
|
||||
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||
private final KycRiskRulesWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
|
||||
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
|
||||
return this.toApiJsonSerializer.serialize(rules);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Add a KYC/AML risk rule",
|
||||
description = "Appends a rule to the catalog. Supplying any transaction band or observation window also "
|
||||
+ "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the "
|
||||
+ "persisted rule in the same shape as the catalog GET.")
|
||||
public String createRiskRule(final KycRiskRuleRequest request) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||
return this.toApiJsonSerializer.serialize(rule);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -41,6 +41,9 @@ public class ClientKycCommand {
|
||||
private final Boolean verifyPan;
|
||||
private final Boolean verifyAadhaar;
|
||||
private final Boolean verifyGstin;
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
|
||||
// ── Aadhaar OTP flow ──────────────────────────────────────────────────────
|
||||
/** Full Aadhaar – used ONLY for OTP generation; never stored. */
|
||||
@@ -54,7 +57,8 @@ public class ClientKycCommand {
|
||||
final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
|
||||
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
|
||||
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
|
||||
final String aadhaarOtp) {
|
||||
final String aadhaarOtp, final Boolean isException, final String exceptionReason,
|
||||
final Boolean exceptionResolved) {
|
||||
this.kycType = kycType;
|
||||
this.riskCategory = riskCategory;
|
||||
this.panNumber = panNumber;
|
||||
@@ -68,6 +72,9 @@ public class ClientKycCommand {
|
||||
this.aadhaarNumber = aadhaarNumber;
|
||||
this.aadhaarRefNumber = aadhaarRefNumber;
|
||||
this.aadhaarOtp = aadhaarOtp;
|
||||
this.isException = isException;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public void validateForInitiate() {
|
||||
@@ -111,8 +118,9 @@ public class ClientKycCommand {
|
||||
}
|
||||
|
||||
// At least one ID document must be provided — except for minors, who prove identity via
|
||||
// the Birth Certificate / School ID document-type picker instead.
|
||||
if (!skipIdentityDocumentCheck) {
|
||||
// the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
|
||||
final boolean isKycException = isException != null && isException;
|
||||
if (!skipIdentityDocumentCheck && !isKycException) {
|
||||
final boolean hasPan = panNumber != null && !panNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
|
||||
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
|
||||
@@ -250,4 +258,16 @@ public class ClientKycCommand {
|
||||
public String getAadhaarRefNumber() { return aadhaarRefNumber; }
|
||||
/** OTP entered by the client. */
|
||||
public String getAadhaarOtp() { return aadhaarOtp; }
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-3
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
|
||||
public static final String verifyPanParam = "verifyPan";
|
||||
public static final String verifyAadhaarParam = "verifyAadhaar";
|
||||
public static final String verifyGstinParam = "verifyGstin";
|
||||
public static final String panVerifiedParam = "panVerified";
|
||||
public static final String aadhaarVerifiedParam = "aadhaarVerified";
|
||||
public static final String gstinVerifiedParam = "gstinVerified";
|
||||
|
||||
// ── Aadhaar OTP flow parameters ───────────────────────────────────────────
|
||||
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
|
||||
@@ -50,6 +53,22 @@ public final class ClientKycApiConstants {
|
||||
|
||||
// ── Extended KYC fields ──────────────────────────────────────────────────
|
||||
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
|
||||
|
||||
// ── PAN declaration (accepted when the PAN check cannot pass) ─────────────
|
||||
public static final String panDeclarationTakenParam = "panDeclarationTaken";
|
||||
public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields";
|
||||
public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan";
|
||||
public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan";
|
||||
public static final String panDeclarationRemarksParam = "panDeclarationRemarks";
|
||||
public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn";
|
||||
|
||||
// ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ───
|
||||
public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken";
|
||||
public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId";
|
||||
public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate";
|
||||
public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks";
|
||||
public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn";
|
||||
|
||||
public static final String enterprisePanParam = "enterprisePan";
|
||||
public static final String cinNumberParam = "cinNumber";
|
||||
public static final String dinNumberParam = "dinNumber";
|
||||
@@ -69,11 +88,19 @@ public final class ClientKycApiConstants {
|
||||
public static final String accountStatusParam = "accountStatus";
|
||||
public static final String freezeReasonParam = "freezeReason";
|
||||
public static final String freezeAuthorityParam = "freezeAuthority";
|
||||
public static final String freezeDateParam = "freezeDate";
|
||||
public static final String freezeAmountParam = "freezeAmount";
|
||||
public static final String unfreezeDateParam = "unfreezeDate";
|
||||
public static final String noDebitDateParam = "noDebitDate";
|
||||
public static final String form60TypeParam = "form60Type";
|
||||
public static final String form60DateParam = "form60Date";
|
||||
public static final String form15ApplicableParam = "form15Applicable";
|
||||
public static final String form15TypeParam = "form15Type";
|
||||
public static final String form15FYParam = "form15FY";
|
||||
public static final String isExceptionParam = "isException";
|
||||
public static final String exceptionReasonParam = "exceptionReason";
|
||||
public static final String exceptionResolvedParam = "exceptionResolved";
|
||||
public static final String exceptionResolvedOnParam = "exceptionResolvedOn";
|
||||
|
||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
@@ -83,16 +110,30 @@ public final class ClientKycApiConstants {
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
|
||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
|
||||
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
+44
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -52,11 +53,29 @@ public final class ClientKycData {
|
||||
private final String panCategory;
|
||||
private final String panStatus;
|
||||
|
||||
// ── PAN declaration ───────────────────────────────────────────────────────
|
||||
/** Signed customer declaration accepted in place of a passing PAN check. */
|
||||
private final Boolean panDeclarationTaken;
|
||||
/** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */
|
||||
private final String panDeclarationMismatchFields;
|
||||
private final String panDeclarationNameOnPan;
|
||||
private final LocalDate panDeclarationDobOnPan;
|
||||
private final String panDeclarationRemarks;
|
||||
private final LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ───────────────────────────────────────────────────────────────
|
||||
private final String aadhaarNumberMasked;
|
||||
private final Boolean aadhaarVerified;
|
||||
private final LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ─────────────────────────────────────────────
|
||||
/** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */
|
||||
private final Boolean aadhaarReceiptTaken;
|
||||
private final String aadhaarEnrolmentId;
|
||||
private final LocalDate aadhaarEnrolmentDate;
|
||||
private final String aadhaarReceiptRemarks;
|
||||
private final LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN ─────────────────────────────────────────────────────────────────
|
||||
private final String gstinNumber;
|
||||
private final Boolean gstinVerified;
|
||||
@@ -65,6 +84,8 @@ public final class ClientKycData {
|
||||
// ── Enterprise / Non-Individual ──────────────────────────────────────────
|
||||
private final String enterprisePan;
|
||||
private final String cinNumber;
|
||||
private final Boolean cinVerified;
|
||||
private final LocalDate cinVerifiedOn;
|
||||
private final LocalDate incorporationDate;
|
||||
|
||||
// ── KYC Document ─────────────────────────────────────────────────────────
|
||||
@@ -84,6 +105,10 @@ public final class ClientKycData {
|
||||
private final String accountStatus;
|
||||
private final String freezeReason;
|
||||
private final String freezeAuthority;
|
||||
private final LocalDate freezeDate;
|
||||
private final BigDecimal freezeAmount;
|
||||
private final LocalDate unfreezeDate;
|
||||
private final LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
private final String form60Type;
|
||||
@@ -97,10 +122,29 @@ public final class ClientKycData {
|
||||
private final LocalDate ckycUploadedOn;
|
||||
private final String ckycBatchId;
|
||||
|
||||
// ── Exception management ──────────────────────────────────────────────────
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
private final LocalDate exceptionResolvedOn;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
private final LocalDate initiatedOn;
|
||||
private final LocalDate verifiedOn;
|
||||
private final LocalDate rejectedOn;
|
||||
private final LocalDate expiryDate;
|
||||
private final String remarks;
|
||||
|
||||
private final String clientAccountNo;
|
||||
private final String clientOfficeName;
|
||||
|
||||
// ── Customer contact (for KYC outreach) ──────────────────────────────────
|
||||
// Masked only. The exceptions screen shows these so the officer knows who they are reaching,
|
||||
// but the full values never leave the server — the send path resolves them from m_client
|
||||
// itself, so a tampered browser cannot redirect a KYC notice to another address.
|
||||
private final String clientMobileMasked;
|
||||
private final String clientEmailMasked;
|
||||
/** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */
|
||||
private final Boolean hasMobile;
|
||||
private final Boolean hasEmail;
|
||||
}
|
||||
|
||||
+52
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,45 @@ public class ClientKycRequest {
|
||||
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
|
||||
private String panAadhaarSeedingStatus;
|
||||
|
||||
@Schema(description = "True when a signed customer declaration has been accepted in place of a passing "
|
||||
+ "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
@Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when "
|
||||
+ "PAN could not be checked at all", example = "NAME")
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
@Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA")
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
@Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988")
|
||||
private String panDeclarationDobOnPan;
|
||||
|
||||
@Schema(description = "Officer's note on why the mismatch exists and what proof was sighted",
|
||||
example = "Client's PAN carries expanded middle name; passport sighted.")
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Schema(description = "Date the declaration was accepted", example = "07 August 2026")
|
||||
private String panDeclarationTakenOn;
|
||||
|
||||
@Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a "
|
||||
+ "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic "
|
||||
+ "'Aadhaar Verification Failed' KYC exception.", example = "true")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
@Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip",
|
||||
example = "1234/12345/12345 12/05/2026 11:30:15")
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
@Schema(description = "Enrolment date printed on the slip", example = "12 May 2026")
|
||||
private String aadhaarEnrolmentDate;
|
||||
|
||||
@Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.")
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Schema(description = "Date the receipt was accepted", example = "07 August 2026")
|
||||
private String aadhaarReceiptTakenOn;
|
||||
|
||||
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
|
||||
private String enterprisePan;
|
||||
|
||||
@@ -96,6 +136,18 @@ public class ClientKycRequest {
|
||||
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
|
||||
private String freezeAuthority;
|
||||
|
||||
@Schema(description = "Date of account freeze (null if not frozen)")
|
||||
private String freezeDate;
|
||||
|
||||
@Schema(description = "Amount frozen (null if not frozen)")
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)")
|
||||
private String unfreezeDate;
|
||||
|
||||
@Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)")
|
||||
private String noDebitDate;
|
||||
|
||||
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
|
||||
private String form60Type;
|
||||
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One KYC customer-contact attempt as returned to the UI.
|
||||
*
|
||||
* <p>{@code deliveryStatus} and {@code deliveryMessage} exist so the officer can tell a delivered
|
||||
* notice from a failed one at a glance — the dialog shows the failure verbatim rather than a
|
||||
* generic success toast.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public final class KycContactLogData {
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final Long kycId;
|
||||
|
||||
/** Enum name, e.g. {@code SMS}. */
|
||||
private final String channel;
|
||||
/** Human label, e.g. {@code SMS}/{@code In-Branch Visit} — what the grid renders. */
|
||||
private final String channelLabel;
|
||||
|
||||
private final String notes;
|
||||
private final String officerName;
|
||||
private final String exceptionReason;
|
||||
|
||||
/** NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED */
|
||||
private final String deliveryStatus;
|
||||
/** True only for a channel that was supposed to transmit and did. */
|
||||
private final Boolean delivered;
|
||||
/** True when this channel transmits at all (SMS/Email/WhatsApp) vs. is recorded offline. */
|
||||
private final Boolean platformSent;
|
||||
/** Officer-facing sentence describing the outcome — success or the reason it failed. */
|
||||
private final String deliveryMessage;
|
||||
|
||||
private final String destinationMasked;
|
||||
private final String provider;
|
||||
private final String providerReference;
|
||||
private final String messageBody;
|
||||
|
||||
private final LocalDateTime contactedAt;
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /v1/kyc/risk-rules}.
|
||||
*
|
||||
* Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the
|
||||
* live-transaction counterpart of the rule; supplying any of them creates a companion row in
|
||||
* {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them
|
||||
* all blank for a pure KYC-time profile rule (income / turnover / occupation driven).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class KycRiskRuleRequest {
|
||||
|
||||
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ruleName;
|
||||
|
||||
@Schema(description = "Reason / criteria text explaining why the rule exists",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
|
||||
private Integer riskCategory;
|
||||
|
||||
@Schema(description = "Position in the catalog. Omit to append after the last existing rule.")
|
||||
private Integer orderPosition;
|
||||
|
||||
@Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.")
|
||||
private Integer legalFormId;
|
||||
|
||||
@Schema(description = "Occupation category code value the rule is restricted to")
|
||||
private Long occupationCategoryCvId;
|
||||
|
||||
@Schema(description = "Occupation code value the rule is restricted to")
|
||||
private Long occupationCvId;
|
||||
|
||||
private BigDecimal minMonthlyIncome;
|
||||
private BigDecimal maxMonthlyIncome;
|
||||
private BigDecimal minAnnualIncome;
|
||||
private BigDecimal maxAnnualIncome;
|
||||
private BigDecimal minAnnualTurnover;
|
||||
private BigDecimal maxAnnualTurnover;
|
||||
|
||||
@Schema(description = "AML check status the rule records", example = "FLAGGED")
|
||||
private String amlCheckStatus;
|
||||
|
||||
@Schema(description = "Banned entity match flag", example = "NO")
|
||||
private String bannedEntityMatch;
|
||||
|
||||
@Schema(description = "Willful defaulter flag", example = "NO")
|
||||
private String willfulDefaulter;
|
||||
|
||||
@Schema(description = "ECGC/SAL flag", example = "NO")
|
||||
private String ecgcSalFlag;
|
||||
|
||||
@Schema(description = "Dormancy flag", example = "ACTIVE")
|
||||
private String dormantFlag;
|
||||
|
||||
@Schema(description = "Lower bound of the transaction band the rule covers (display only)")
|
||||
private BigDecimal txnMinLimitAmount;
|
||||
|
||||
@Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.")
|
||||
private BigDecimal txnMaxLimitAmount;
|
||||
|
||||
@Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the "
|
||||
+ "lower bound of the band when omitted.")
|
||||
private BigDecimal txnThresholdAmount;
|
||||
|
||||
@Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30")
|
||||
private Integer txnWindowDays;
|
||||
|
||||
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||
private String txnRuleType;
|
||||
|
||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
||||
private String txnAppliesTo;
|
||||
|
||||
/**
|
||||
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||
*/
|
||||
public boolean hasTransactionRule() {
|
||||
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
||||
}
|
||||
}
|
||||
+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.notification;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Routes an outbound customer notice to the {@link CustomerNotificationSender} registered for its
|
||||
* channel.
|
||||
*
|
||||
* <p>Senders are injected as a list and indexed by channel at startup, so a new gateway becomes
|
||||
* available by declaring a bean — nothing here changes.
|
||||
*/
|
||||
@Component
|
||||
public class CustomerNotificationDispatcher {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CustomerNotificationDispatcher.class);
|
||||
|
||||
private final Map<KycContactChannel, CustomerNotificationSender> sendersByChannel = new EnumMap<>(KycContactChannel.class);
|
||||
|
||||
public CustomerNotificationDispatcher(final List<CustomerNotificationSender> senders) {
|
||||
for (final CustomerNotificationSender sender : senders) {
|
||||
final CustomerNotificationSender existing = this.sendersByChannel.put(sender.getChannel(), sender);
|
||||
if (existing != null) {
|
||||
LOG.warn("Two senders registered for channel {} — {} replaced {}", sender.getChannel(),
|
||||
sender.getClass().getSimpleName(), existing.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
LOG.info("Customer notification channels available: {}", this.sendersByChannel.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send on the given channel.
|
||||
*
|
||||
* @return the outcome; {@link CustomerNotificationResult#notConfigured} for an offline channel
|
||||
* or one with no registered sender. Never throws for a delivery failure.
|
||||
*/
|
||||
public CustomerNotificationResult dispatch(final KycContactChannel channel, final Client client, final String subject,
|
||||
final String plainBody, final String htmlBody) {
|
||||
if (!channel.isPlatformSent()) {
|
||||
// Caller shouldn't reach here for PHONE_CALL / visits, but guard rather than send.
|
||||
return CustomerNotificationResult.notConfigured(null, "Channel " + channel + " is recorded offline, not transmitted");
|
||||
}
|
||||
final CustomerNotificationSender sender = this.sendersByChannel.get(channel);
|
||||
if (sender == null) {
|
||||
LOG.warn("No sender registered for channel {} — notice to client {} not transmitted.", channel, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(null, "No gateway is registered for " + channel);
|
||||
}
|
||||
return sender.send(client, subject, plainBody, htmlBody);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.notification;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
|
||||
/**
|
||||
* What a {@link CustomerNotificationSender} did. Senders never throw for a delivery failure — they
|
||||
* return a {@code FAILED} result — so that a gateway outage still produces a persisted contact-log
|
||||
* row rather than losing the officer's notes to a rolled-back transaction.
|
||||
*/
|
||||
public record CustomerNotificationResult(KycContactDeliveryStatus status, String destinationMasked, String provider,
|
||||
String providerReference, String failureReason) {
|
||||
|
||||
public static CustomerNotificationResult sent(final String destinationMasked, final String provider, final String reference) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.SENT, destinationMasked, provider, reference, null);
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult failed(final String destinationMasked, final String provider, final String reason) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.FAILED, destinationMasked, provider, null, reason);
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult noDestination(final String provider) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.SKIPPED_NO_DESTINATION, null, provider, null,
|
||||
"Customer has no destination on file for this channel");
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult notConfigured(final String provider, final String reason) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.PROVIDER_NOT_CONFIGURED, null, provider, null, reason);
|
||||
}
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this.status.isDelivered();
|
||||
}
|
||||
}
|
||||
+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.client.notification;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
|
||||
/**
|
||||
* Transport for one outbound customer channel.
|
||||
*
|
||||
* <p>Implementations are discovered by Spring and matched to a channel via {@link #getChannel()},
|
||||
* so adding a gateway means adding a bean — no dispatcher edit. Each implementation is responsible
|
||||
* for resolving the destination off the {@link Client} itself; callers never pass a destination in,
|
||||
* which keeps a browser from being able to redirect a KYC notice to an arbitrary address.
|
||||
*
|
||||
* <p>Contract: never throw for a delivery problem. Return
|
||||
* {@link CustomerNotificationResult#failed} instead, so the officer's contact log still persists.
|
||||
*/
|
||||
public interface CustomerNotificationSender {
|
||||
|
||||
KycContactChannel getChannel();
|
||||
|
||||
/**
|
||||
* @param client the customer to reach; the destination is read off this record
|
||||
* @param subject short title — used by channels that have one (email), ignored by those that don't
|
||||
* @param plainBody the message as plain text; SMS/WhatsApp send this verbatim
|
||||
* @param htmlBody rich rendering for channels that support it; may be null
|
||||
*/
|
||||
CustomerNotificationResult send(Client client, String subject, String plainBody, String htmlBody);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.notification;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Email delivery via {@link EmailSenderService} — the direct-SMTP path configured from this app's
|
||||
* own {@code spring.mail.*} properties, the same one already used for OTP emails and client-change
|
||||
* alerts.
|
||||
*
|
||||
* <p>Deliberately not {@code PlatformEmailService}: that resolves SMTP credentials from the
|
||||
* DB-backed "external services" table used by the campaign pipeline, which is a separate and
|
||||
* typically unconfigured surface in this deployment.
|
||||
*/
|
||||
@Component
|
||||
public class EmailCustomerNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EmailCustomerNotificationSender.class);
|
||||
private static final String PROVIDER = "SMTP";
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
|
||||
public EmailCustomerNotificationSender(final EmailSenderService emailSenderService) {
|
||||
this.emailSenderService = emailSenderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String to = client.getEmailAddress();
|
||||
if (StringUtils.isBlank(to)) {
|
||||
LOG.warn("KYC contact email skipped — client {} has no email address on file.", client.getId());
|
||||
return CustomerNotificationResult.noDestination(PROVIDER);
|
||||
}
|
||||
|
||||
final String masked = OtpDestinationMasker.maskEmail(to);
|
||||
try {
|
||||
final boolean accepted = StringUtils.isNotBlank(htmlBody)
|
||||
? this.emailSenderService.sendHtml(to, subject, htmlBody)
|
||||
: this.emailSenderService.sendText(to, subject, plainBody);
|
||||
|
||||
if (accepted) {
|
||||
LOG.info("KYC contact email sent to client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.sent(masked, PROVIDER, null);
|
||||
}
|
||||
LOG.warn("KYC contact email rejected by SMTP for client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, "SMTP rejected the message");
|
||||
} catch (final Exception e) {
|
||||
LOG.error("KYC contact email failed for client {} at {}", client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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.notification;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Shared HTTP plumbing for the SMS and WhatsApp gateways.
|
||||
*
|
||||
* <p><b>Why this is template-driven rather than one class per vendor.</b> Indian SMS/WhatsApp
|
||||
* gateways (MSG91, Gupshup, Kaleyra, Twilio, LincPay, …) all expose the same shape — POST a
|
||||
* destination plus a body to a URL with a static API key — but disagree on field names, auth header,
|
||||
* and whether the payload is JSON or form-encoded. Encoding those three differences as properties
|
||||
* means switching or adding a vendor is a config change, not a code change, and no vendor's
|
||||
* credentials end up hard-coded in the repo.
|
||||
*
|
||||
* <p>The body template supports these placeholders, substituted with correct escaping for the
|
||||
* configured content type:
|
||||
* <ul>
|
||||
* <li>{@code {{destination}}} — the customer's mobile number</li>
|
||||
* <li>{@code {{message}}} — the notice text</li>
|
||||
* <li>{@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number</li>
|
||||
* <li>{@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>TRAI's DLT regime means a transactional SMS to an Indian number is only accepted when the body
|
||||
* matches a pre-registered template, so {@code templateId} is exposed even though not every vendor
|
||||
* needs it.
|
||||
*/
|
||||
abstract class RestGatewayNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
protected abstract Logger log();
|
||||
|
||||
/** Human-readable gateway name recorded on the contact log (e.g. {@code MSG91}). */
|
||||
protected abstract String providerName();
|
||||
|
||||
protected abstract boolean isEnabled();
|
||||
|
||||
protected abstract String baseUrl();
|
||||
|
||||
protected abstract String path();
|
||||
|
||||
protected abstract String authHeaderName();
|
||||
|
||||
protected abstract String authHeaderValue();
|
||||
|
||||
protected abstract String contentType();
|
||||
|
||||
protected abstract String bodyTemplate();
|
||||
|
||||
protected abstract String senderId();
|
||||
|
||||
protected abstract String templateId();
|
||||
|
||||
/**
|
||||
* Dotted path into the JSON response holding the vendor's message ID (e.g.
|
||||
* {@code data.messageId}). Blank when the vendor returns no usable reference.
|
||||
*/
|
||||
protected abstract String referenceJsonPath();
|
||||
|
||||
/** Resolve the customer's destination for this channel off the client record. */
|
||||
protected abstract String resolveDestination(Client client);
|
||||
|
||||
/** Mask the destination for storage and logging. */
|
||||
protected abstract String maskDestination(String destination);
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String provider = providerName();
|
||||
|
||||
if (!isEnabled()) {
|
||||
log().warn("{} notice not sent for client {} — channel disabled in configuration.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider,
|
||||
"Channel is disabled — set the corresponding fineract.notification.*.enabled property to true");
|
||||
}
|
||||
if (StringUtils.isBlank(baseUrl()) || StringUtils.isBlank(authHeaderValue())) {
|
||||
log().warn("{} notice not sent for client {} — gateway URL or credential missing.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider, "Gateway base-url or API credential is not configured");
|
||||
}
|
||||
|
||||
final String destination = resolveDestination(client);
|
||||
if (StringUtils.isBlank(destination)) {
|
||||
log().warn("{} notice skipped — client {} has no destination on file for this channel.", provider, client.getId());
|
||||
return CustomerNotificationResult.noDestination(provider);
|
||||
}
|
||||
|
||||
final String masked = maskDestination(destination);
|
||||
final boolean json = !MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType());
|
||||
final String payload = renderBody(destination, plainBody, json);
|
||||
|
||||
try {
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(contentType()));
|
||||
if (StringUtils.isNotBlank(authHeaderName())) {
|
||||
headers.set(authHeaderName(), authHeaderValue());
|
||||
}
|
||||
|
||||
final String base = baseUrl().endsWith("/") ? baseUrl().substring(0, baseUrl().length() - 1) : baseUrl();
|
||||
final String url = base + path();
|
||||
final ResponseEntity<String> response = this.restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
final String reference = extractReference(response.getBody());
|
||||
log().info("{} notice sent to client {} at {} (ref={})", provider, client.getId(), masked, reference);
|
||||
return CustomerNotificationResult.sent(masked, provider, reference);
|
||||
}
|
||||
// Non-2xx without an exception is unusual for RestTemplate but treat it as a failure
|
||||
// rather than assuming delivery — an over-optimistic SENT would corrupt the audit trail.
|
||||
log().warn("{} notice rejected for client {} at {} — HTTP {}", provider, client.getId(), masked, response.getStatusCode());
|
||||
return CustomerNotificationResult.failed(masked, provider, "Gateway returned HTTP " + response.getStatusCode());
|
||||
} catch (final Exception e) {
|
||||
log().error("{} notice failed for client {} at {}", provider, client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, provider, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the placeholders, escaping each value for the target encoding. Escaping happens per
|
||||
* value rather than on the finished string so that a message containing a quote or an ampersand
|
||||
* cannot break out of its field and corrupt the request.
|
||||
*/
|
||||
private String renderBody(final String destination, final String message, final boolean json) {
|
||||
String body = bodyTemplate();
|
||||
body = body.replace("{{destination}}", escape(destination, json));
|
||||
body = body.replace("{{message}}", escape(message, json));
|
||||
body = body.replace("{{senderId}}", escape(StringUtils.defaultString(senderId()), json));
|
||||
body = body.replace("{{templateId}}", escape(StringUtils.defaultString(templateId()), json));
|
||||
return body;
|
||||
}
|
||||
|
||||
private static String escape(final String value, final boolean json) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (!json) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
final StringBuilder out = new StringBuilder(value.length() + 16);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
final char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> out.append("\\\"");
|
||||
case '\\' -> out.append("\\\\");
|
||||
case '\n' -> out.append("\\n");
|
||||
case '\r' -> out.append("\\r");
|
||||
case '\t' -> out.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) {
|
||||
out.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
out.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** Walk {@link #referenceJsonPath()} through the response body; null if absent or unparseable. */
|
||||
private String extractReference(final String responseBody) {
|
||||
final String jsonPath = referenceJsonPath();
|
||||
if (StringUtils.isBlank(jsonPath) || StringUtils.isBlank(responseBody)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonElement node = JsonParser.parseString(responseBody);
|
||||
for (final String segment : jsonPath.split("\\.")) {
|
||||
if (node == null || !node.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject object = node.getAsJsonObject();
|
||||
if (!object.has(segment)) {
|
||||
return null;
|
||||
}
|
||||
node = object.get(segment);
|
||||
}
|
||||
return node != null && node.isJsonPrimitive() ? node.getAsString() : null;
|
||||
} catch (final Exception e) {
|
||||
log().debug("Could not read reference '{}' from {} response", jsonPath, providerName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Plain-text SMS via the configured gateway. See {@link RestGatewayNotificationSender} for why the
|
||||
* request shape is property-driven.
|
||||
*
|
||||
* <p>Note this is a <em>separate</em> gateway from the LincPay eKYC endpoints already configured
|
||||
* for login OTP ({@code /mobile/generate-otp}). That endpoint generates and delivers an OTP of
|
||||
* LincPay's own choosing; it cannot carry an arbitrary message, and routing a KYC notice through it
|
||||
* would mint a real, verifiable OTP the customer never requested. Configure a genuine
|
||||
* send-message endpoint here.
|
||||
*/
|
||||
@Component
|
||||
public class SmsCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SmsCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.sms.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.sms.provider:SMS_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.sms.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.sms.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.sms.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.sms.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.sms.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.sms.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.sms.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.SMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return client.getMobileNo();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* WhatsApp Business delivery via the configured gateway (Meta Cloud API, Gupshup, Twilio, …).
|
||||
*
|
||||
* <p>Reuses the customer's registered mobile number as the WhatsApp destination — banks do not
|
||||
* capture a separate WhatsApp handle, and the Business API is keyed on the phone number anyway.
|
||||
* Most providers want it in E.164 form, so {@code fineract.notification.whatsapp.country-code} is
|
||||
* prefixed to bare 10-digit Indian numbers.
|
||||
*
|
||||
* <p>Meta's policy only permits business-initiated messages against an approved template outside the
|
||||
* 24-hour customer service window, which a KYC exception notice always is — so
|
||||
* {@code template-id} will normally need to be set alongside a template-shaped body.
|
||||
*/
|
||||
@Component
|
||||
public class WhatsAppCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WhatsAppCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.whatsapp.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.provider:WHATSAPP_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
/** Prefixed to bare national numbers to reach E.164. Blank disables normalisation. */
|
||||
@Value("${fineract.notification.whatsapp.country-code:91}")
|
||||
private String countryCode;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.WHATSAPP;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return toE164(client.getMobileNo());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip formatting and prefix the country code when the number is stored nationally. A number
|
||||
* that already carries a country code (leading {@code +}, or longer than 10 digits) is passed
|
||||
* through so we never double-prefix.
|
||||
*/
|
||||
private String toE164(final String mobileNo) {
|
||||
if (mobileNo == null || mobileNo.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String digits = mobileNo.replaceAll("[^0-9+]", "");
|
||||
if (digits.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (digits.startsWith("+")) {
|
||||
return digits.substring(1);
|
||||
}
|
||||
if (this.countryCode == null || this.countryCode.isBlank() || digits.length() > 10) {
|
||||
return digits;
|
||||
}
|
||||
return this.countryCode + digits;
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer {
|
||||
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
|
||||
ClientKycApiConstants.aadhaarOtpParam, element);
|
||||
|
||||
final Boolean isException = this.fromApiJsonHelper.extractBooleanNamed(
|
||||
ClientKycApiConstants.isExceptionParam, element);
|
||||
final String exceptionReason = this.fromApiJsonHelper.extractStringNamed(
|
||||
ClientKycApiConstants.exceptionReasonParam, element);
|
||||
final Boolean exceptionResolved = this.fromApiJsonHelper.extractBooleanNamed(
|
||||
ClientKycApiConstants.exceptionResolvedParam, element);
|
||||
|
||||
return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
|
||||
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin,
|
||||
aadhaarNumber, aadhaarRefNumber, aadhaarOtp);
|
||||
aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason,
|
||||
exceptionResolved);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-58
@@ -18,79 +18,42 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
|
||||
* <ul>
|
||||
* <li><b>TT</b> — Customer type code:
|
||||
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
|
||||
* Priority order when multiple flags overlap: <em>Staff > Minor > Corporate > Individual</em>.</li>
|
||||
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
|
||||
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
|
||||
* </ul>
|
||||
* Generates CIF numbers in the format <code>CIF</code> followed by the client's id
|
||||
* zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
|
||||
*
|
||||
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
|
||||
* The generator handles concurrent inserts by retrying on uniqueness collisions
|
||||
* (the caller's outer save is what actually triggers the unique constraint).
|
||||
* <p>
|
||||
* Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
|
||||
* shared by every office, so two clients booked concurrently at different branches can never derive
|
||||
* the same CIF. That makes the number globally unique across the whole institution, not just within
|
||||
* the branch that created it, and removes the read-then-write race a "count existing + 1" scheme has.
|
||||
* The <code>cif_number_unique</code> index on m_client remains the backstop.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The client must already be persisted when this is called, since the id is the source of the number.
|
||||
* </p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CifNumberGenerator {
|
||||
|
||||
private static final String TYPE_INDIVIDUAL = "11";
|
||||
private static final String TYPE_CORPORATE = "22";
|
||||
private static final String TYPE_MINOR = "33";
|
||||
private static final String TYPE_STAFF = "44";
|
||||
private static final String CIF_PREFIX = "CIF";
|
||||
|
||||
private static final int SEQUENCE_WIDTH = 6;
|
||||
private static final int MAX_COLLISION_RETRIES = 5;
|
||||
|
||||
private final ClientRepository clientRepository;
|
||||
/** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
|
||||
private static final int SEQUENCE_WIDTH = 10;
|
||||
|
||||
/**
|
||||
* Generate the next CIF number for the given client. Caller is responsible for persisting.
|
||||
* Generate the CIF number for the given client. Caller is responsible for persisting.
|
||||
*/
|
||||
public String generate(final Client client) {
|
||||
final String typeCode = resolveTypeCode(client);
|
||||
final int year = resolveYear(client);
|
||||
final String prefix = typeCode + year;
|
||||
|
||||
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
|
||||
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
|
||||
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
|
||||
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
|
||||
return candidate;
|
||||
}
|
||||
nextSequence++;
|
||||
final Long clientId = client.getId();
|
||||
if (clientId == null) {
|
||||
throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
|
||||
}
|
||||
// Last resort: append client id to guarantee uniqueness if every attempt collided
|
||||
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
|
||||
}
|
||||
|
||||
private String resolveTypeCode(final Client client) {
|
||||
if (client.isStaff()) {
|
||||
return TYPE_STAFF;
|
||||
}
|
||||
if (Boolean.TRUE.equals(client.getIsMinor())) {
|
||||
return TYPE_MINOR;
|
||||
}
|
||||
final Integer legalForm = client.getLegalForm();
|
||||
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
|
||||
return TYPE_CORPORATE;
|
||||
}
|
||||
return TYPE_INDIVIDUAL;
|
||||
}
|
||||
|
||||
private int resolveYear(final Client client) {
|
||||
final LocalDate submittedOn = client.getSubmittedOnDate();
|
||||
return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear();
|
||||
return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
|
||||
* Retrieve the KYC record for a client.
|
||||
*/
|
||||
ClientKycData retrieveClientKycByClientId(Long clientId);
|
||||
|
||||
java.util.List<ClientKycData> retrieveActiveKycExceptions();
|
||||
|
||||
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
|
||||
}
|
||||
|
||||
+46
@@ -19,6 +19,8 @@
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
@@ -55,10 +57,19 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
|
||||
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
|
||||
|
||||
final String mobileNo = kyc.getClient().getMobileNo();
|
||||
final String emailAddress = kyc.getClient().getEmailAddress();
|
||||
|
||||
return ClientKycData.builder()
|
||||
.id(kyc.getId())
|
||||
.clientId(kyc.getClient().getId())
|
||||
.clientDisplayName(kyc.getClient().getDisplayName())
|
||||
.clientAccountNo(kyc.getClient().getAccountNumber())
|
||||
.clientOfficeName(kyc.getClient().getOffice().getName())
|
||||
.clientMobileMasked(StringUtils.isNotBlank(mobileNo) ? OtpDestinationMasker.maskMobile(mobileNo) : null)
|
||||
.clientEmailMasked(StringUtils.isNotBlank(emailAddress) ? OtpDestinationMasker.maskEmail(emailAddress) : null)
|
||||
.hasMobile(StringUtils.isNotBlank(mobileNo))
|
||||
.hasEmail(StringUtils.isNotBlank(emailAddress))
|
||||
.kycStatus(status.getValue())
|
||||
.kycStatusCode(status.getCode())
|
||||
.kycType(type.getValue())
|
||||
@@ -74,14 +85,27 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
|
||||
.panCategory(kyc.getPanCategory())
|
||||
.panStatus(kyc.getPanStatus())
|
||||
.panDeclarationTaken(kyc.getPanDeclarationTaken())
|
||||
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
|
||||
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
|
||||
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
|
||||
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
|
||||
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
|
||||
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
|
||||
.aadhaarVerified(kyc.isAadhaarVerified())
|
||||
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
|
||||
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
|
||||
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
|
||||
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
|
||||
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
|
||||
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
|
||||
.gstinNumber(kyc.getGstinNumber())
|
||||
.gstinVerified(kyc.isGstinVerified())
|
||||
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
|
||||
.enterprisePan(kyc.getEnterprisePan())
|
||||
.cinNumber(kyc.getCinNumber())
|
||||
.cinVerified(kyc.isCinVerified())
|
||||
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
||||
.incorporationDate(kyc.getIncorporationDate())
|
||||
.kycDocumentName(kyc.getKycDocumentName())
|
||||
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
||||
@@ -95,6 +119,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.accountStatus(kyc.getAccountStatus())
|
||||
.freezeReason(kyc.getFreezeReason())
|
||||
.freezeAuthority(kyc.getFreezeAuthority())
|
||||
.freezeDate(kyc.getFreezeDate())
|
||||
.freezeAmount(kyc.getFreezeAmount())
|
||||
.unfreezeDate(kyc.getUnfreezeDate())
|
||||
.noDebitDate(kyc.getNoDebitDate())
|
||||
.form60Type(kyc.getForm60Type())
|
||||
.form60Date(kyc.getForm60Date())
|
||||
.form15Applicable(kyc.getForm15Applicable())
|
||||
@@ -103,6 +131,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.ckycNumber(kyc.getCkycNumber())
|
||||
.ckycUploadedOn(kyc.getCkycUploadedOn())
|
||||
.ckycBatchId(kyc.getCkycBatchId())
|
||||
.isException(kyc.getIsException())
|
||||
.exceptionReason(kyc.getExceptionReason())
|
||||
.exceptionResolved(kyc.getExceptionResolved())
|
||||
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
|
||||
.initiatedOn(kyc.getInitiatedOn())
|
||||
.verifiedOn(kyc.getVerifiedOn())
|
||||
.rejectedOn(kyc.getRejectedOn())
|
||||
@@ -110,4 +142,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.remarks(kyc.getRemarks())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveActiveKycExceptions() {
|
||||
return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveResolvedKycExceptions() {
|
||||
return this.kycRepository.findByExceptionResolvedTrue().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
-38
@@ -158,27 +158,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
final Map<String, Object> changes = kyc.update(command);
|
||||
|
||||
if (!changes.isEmpty()) {
|
||||
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
|
||||
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (panChanged && currentStatus.isVerified()) {
|
||||
kyc.resetPanVerification();
|
||||
changes.put("panVerified", false);
|
||||
}
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
|
||||
}
|
||||
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
|
||||
// Handles the common case of editing remarks / risk on a fully-verified KYC.
|
||||
kyc.tryAutoVerify();
|
||||
if (kyc.getKycStatus().isVerified()) {
|
||||
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
|
||||
}
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
}
|
||||
|
||||
@@ -1009,23 +988,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
return; // no-op — edit-client sent the same KYC values that are already on the row
|
||||
}
|
||||
|
||||
// Same audit semantics as the explicit updateKyc endpoint:
|
||||
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
|
||||
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
|
||||
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
|
||||
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (currentStatus.isVerified() && panChanged) {
|
||||
kyc.resetPanVerification();
|
||||
}
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
}
|
||||
kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
||||
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
||||
|
||||
+6
-4
@@ -619,9 +619,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
this.clientRepository.saveAndFlush(newClient);
|
||||
}
|
||||
|
||||
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
|
||||
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
|
||||
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
|
||||
// CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
|
||||
// Must run after the saveAndFlush above, since the id is what the number is derived from;
|
||||
// deriving it from the shared id sequence is what keeps CIFs unique across all branches.
|
||||
if (StringUtils.isBlank(cifNumber)) {
|
||||
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
|
||||
this.clientRepository.saveAndFlush(newClient);
|
||||
@@ -1466,7 +1466,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
|
||||
validateParentGroupRulesBeforeClientActivation(client);
|
||||
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
|
||||
// Mother/Father-or-Caretaker is enforced where family members are captured (the Family
|
||||
// Details step), not here: a client can reach approval through several different forms,
|
||||
// and gating activation on it left approvers unable to approve what was handed to them.
|
||||
final Locale locale = command.extractLocale();
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
|
||||
/**
|
||||
* Records — and, for the sending channels, actually transmits — a KYC officer's outreach to a
|
||||
* customer with an open KYC exception.
|
||||
*/
|
||||
public interface KycContactLogService {
|
||||
|
||||
/**
|
||||
* Log one contact attempt. For {@code SMS}/{@code EMAIL}/{@code WHATSAPP} this also sends the
|
||||
* notice to the customer; for the offline channels it only records what the officer already did.
|
||||
*
|
||||
* <p>The log row is persisted whether or not delivery succeeded — a gateway outage must not
|
||||
* discard the officer's notes.
|
||||
*
|
||||
* @param clientId customer the exception belongs to
|
||||
* @param rawChannel channel as submitted by the UI (label or enum name)
|
||||
* @param notes officer's summary; mandatory
|
||||
* @param officerName officer selected in the dialog
|
||||
* @return the persisted log, including its delivery outcome
|
||||
*/
|
||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
|
||||
|
||||
/** All contact attempts for one customer, oldest first. */
|
||||
List<KycContactLogData> retrieveForClient(Long clientId);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLog;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(KycContactLogServiceImpl.class);
|
||||
|
||||
private static final int NOTES_MAX_LENGTH = 1000;
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String bankName;
|
||||
|
||||
/** Days the customer is given to act, quoted in the notice body. */
|
||||
@Value("${fineract.notification.kyc-contact.response-days:15}")
|
||||
private int responseDays;
|
||||
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final ClientKycRepository kycRepository;
|
||||
private final ClientKycContactLogRepository contactLogRepository;
|
||||
private final CustomerNotificationDispatcher dispatcher;
|
||||
private final EmailTemplateService emailTemplateService;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
||||
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
|
||||
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.kycRepository = kycRepository;
|
||||
this.contactLogRepository = contactLogRepository;
|
||||
this.dispatcher = dispatcher;
|
||||
this.emailTemplateService = emailTemplateService;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
|
||||
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||
if (channel == null) {
|
||||
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
||||
}
|
||||
if (StringUtils.isBlank(notes)) {
|
||||
throw validationError("notes", "A summary of the contact is required.");
|
||||
}
|
||||
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
|
||||
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
|
||||
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
|
||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||
|
||||
if (channel.isPlatformSent()) {
|
||||
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||
final String plainBody = buildPlainBody(client, exceptionReason, channel);
|
||||
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
|
||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
|
||||
: null;
|
||||
|
||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
||||
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
|
||||
result.failureReason(), plainBody);
|
||||
|
||||
if (!result.isDelivered()) {
|
||||
// Logged, not thrown: the officer's notes are still worth persisting, and the UI
|
||||
// surfaces the failure so nobody assumes the customer was reached.
|
||||
LOG.warn("KYC contact notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel,
|
||||
result.status(), result.failureReason());
|
||||
}
|
||||
}
|
||||
|
||||
final ClientKycContactLog saved = this.contactLogRepository.saveAndFlush(log);
|
||||
return toData(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
||||
return this.contactLogRepository.findByClientIdOrderByContactedAtAsc(clientId).stream().map(this::toData).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice text. Kept short and channel-aware because an SMS over 160 GSM-7 characters is
|
||||
* billed and delivered as multiple segments; email can afford the longer framing but reuses the
|
||||
* same sentence so the audit trail shows one consistent message across channels.
|
||||
*/
|
||||
private String buildPlainBody(final Client client, final String exceptionReason, final KycContactChannel channel) {
|
||||
final String reason = StringUtils.isNotBlank(exceptionReason) ? exceptionReason : "KYC verification is incomplete";
|
||||
|
||||
if (channel == KycContactChannel.SMS) {
|
||||
return "Dear " + displayName(client) + ", your KYC is pending: " + reason + ". Please visit your branch with valid "
|
||||
+ "documents within " + this.responseDays + " days to avoid account restrictions. - " + this.bankName;
|
||||
}
|
||||
return "Dear " + displayName(client) + ", our records show that your KYC (Know Your Customer) verification is pending "
|
||||
+ "for the following reason: " + reason + ". As required by the Reserve Bank of India's KYC Master Direction, "
|
||||
+ "please visit your home branch with valid identity and address documents within " + this.responseDays
|
||||
+ " days. If this is not completed, operations on your account may be restricted. "
|
||||
+ "If you have already submitted these documents, please ignore this message. - " + this.bankName;
|
||||
}
|
||||
|
||||
private KycContactLogData toData(final ClientKycContactLog log) {
|
||||
final KycContactChannel channel = log.getChannel();
|
||||
final KycContactDeliveryStatus status = log.getDeliveryStatus();
|
||||
|
||||
return KycContactLogData.builder()
|
||||
.id(log.getId())
|
||||
.clientId(log.getClient().getId())
|
||||
.kycId(log.getKycId())
|
||||
.channel(channel.name())
|
||||
.channelLabel(channel.getDisplayLabel())
|
||||
.notes(log.getNotes())
|
||||
.officerName(log.getOfficerName())
|
||||
.exceptionReason(log.getExceptionReason())
|
||||
.deliveryStatus(status.name())
|
||||
.delivered(status.isDelivered())
|
||||
.platformSent(channel.isPlatformSent())
|
||||
.deliveryMessage(describeDelivery(channel, status, log.getDestinationMasked(), log.getFailureReason()))
|
||||
.destinationMasked(log.getDestinationMasked())
|
||||
.provider(log.getProvider())
|
||||
.providerReference(log.getProviderReference())
|
||||
.messageBody(log.getMessageBody())
|
||||
.contactedAt(log.getContactedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Officer-facing outcome sentence. Failures name the cause so the next action is obvious. */
|
||||
private String describeDelivery(final KycContactChannel channel, final KycContactDeliveryStatus status, final String destination,
|
||||
final String failureReason) {
|
||||
return switch (status) {
|
||||
case NOT_APPLICABLE -> channel.getDisplayLabel() + " recorded.";
|
||||
case SENT -> channel.getDisplayLabel() + " sent to " + (StringUtils.isNotBlank(destination) ? destination : "the customer")
|
||||
+ ".";
|
||||
case SKIPPED_NO_DESTINATION -> "Not sent — the customer has no "
|
||||
+ (channel == KycContactChannel.EMAIL ? "email address" : "mobile number") + " on file.";
|
||||
case PROVIDER_NOT_CONFIGURED -> "Not sent — no " + channel.getDisplayLabel()
|
||||
+ " gateway is configured. The attempt has been logged.";
|
||||
case FAILED -> "Not sent — the gateway rejected the message"
|
||||
+ (StringUtils.isNotBlank(failureReason) ? " (" + failureReason + ")" : "") + ". The attempt has been logged.";
|
||||
};
|
||||
}
|
||||
|
||||
private String displayName(final Client client) {
|
||||
if (StringUtils.isNotBlank(client.getFirstname())) {
|
||||
return client.getFirstname();
|
||||
}
|
||||
if (StringUtils.isNotBlank(client.getDisplayName())) {
|
||||
return client.getDisplayName();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
|
||||
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.contact.log." + parameter + ".invalid",
|
||||
message, parameter);
|
||||
return new PlatformApiDataValidationException(List.of(error));
|
||||
}
|
||||
}
|
||||
+3
@@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
|
||||
public interface KycRiskRulesReadPlatformService {
|
||||
List<KycRiskRuleData> retrieveAllRules();
|
||||
|
||||
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
|
||||
KycRiskRuleData retrieveRule(Long ruleId);
|
||||
}
|
||||
|
||||
+9
@@ -48,6 +48,15 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycRiskRuleData retrieveRule(final Long ruleId) {
|
||||
final ClientRiskRule rule = this.repository.findById(ruleId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
|
||||
final AmlTransactionRule txnRule = this.amlTransactionRuleRepository.findAll().stream()
|
||||
.filter(txn -> ruleId.equals(txn.getSourceClientRiskRuleId())).findFirst().orElse(null);
|
||||
return mapToData(rule, txnRule);
|
||||
}
|
||||
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
|
||||
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
||||
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
|
||||
public interface KycRiskRulesWritePlatformService {
|
||||
|
||||
/**
|
||||
* Adds a rule to the AML catalog. Creates one {@code m_client_risk_rule} row and, when the
|
||||
* request describes a transaction band or observation window, a companion
|
||||
* {@code m_aml_transaction_rule} row linked back to it.
|
||||
*
|
||||
* @return the persisted rule exactly as the catalog GET would return it
|
||||
*/
|
||||
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
|
||||
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.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
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.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePlatformService {
|
||||
|
||||
private static final String RESOURCE = "kycriskrule";
|
||||
|
||||
private final ClientRiskRuleRepository repository;
|
||||
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
|
||||
private final CodeValueRepositoryWrapper codeValueRepository;
|
||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycRiskRuleData createRule(final KycRiskRuleRequest request) {
|
||||
validate(request);
|
||||
|
||||
// Appending is the safe default: order_position drives evaluation precedence, so silently
|
||||
// inserting into the middle of the catalog would re-prioritise existing rules.
|
||||
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
|
||||
|
||||
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
|
||||
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
|
||||
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
|
||||
request.getMinAnnualTurnover(), request.getMaxAnnualTurnover(),
|
||||
StringUtils.defaultIfBlank(request.getAmlCheckStatus(), "CLEAR"),
|
||||
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"));
|
||||
|
||||
if (request.getOccupationCategoryCvId() != null) {
|
||||
final CodeValue occupationCategory = this.codeValueRepository
|
||||
.findOneWithNotFoundDetection(request.getOccupationCategoryCvId());
|
||||
rule.setOccupationCategory(occupationCategory);
|
||||
}
|
||||
if (request.getOccupationCvId() != null) {
|
||||
final CodeValue occupation = this.codeValueRepository.findOneWithNotFoundDetection(request.getOccupationCvId());
|
||||
rule.setOccupation(occupation);
|
||||
}
|
||||
|
||||
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
||||
|
||||
if (request.hasTransactionRule()) {
|
||||
this.amlTransactionRuleRepository.save(buildTransactionRule(saved, request));
|
||||
}
|
||||
|
||||
return this.readPlatformService.retrieveRule(saved.getId());
|
||||
}
|
||||
|
||||
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
|
||||
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
|
||||
// so a rule described purely as a band is still evaluable against live transactions.
|
||||
final BigDecimal threshold = request.getTxnThresholdAmount() != null ? request.getTxnThresholdAmount()
|
||||
: request.getTxnMinLimitAmount();
|
||||
final AmlRuleType ruleType = StringUtils.isBlank(request.getTxnRuleType()) ? AmlRuleType.THRESHOLD
|
||||
: AmlRuleType.valueOf(request.getTxnRuleType().trim().toUpperCase());
|
||||
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
||||
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
||||
|
||||
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
|
||||
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
}
|
||||
|
||||
private Integer nextOrderPosition() {
|
||||
final Integer max = this.repository.findMaxOrderPosition();
|
||||
return max == null ? 1 : max + 1;
|
||||
}
|
||||
|
||||
private void validate(final KycRiskRuleRequest request) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
|
||||
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
|
||||
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
|
||||
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
|
||||
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
|
||||
.integerGreaterThanZero();
|
||||
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
|
||||
.integerZeroOrGreater();
|
||||
validator.reset().parameter("txnMinLimitAmount").value(request.getTxnMinLimitAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
validator.reset().parameter("txnThresholdAmount").value(request.getTxnThresholdAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
|
||||
// A band whose ceiling sits under its floor would silently never match a transaction.
|
||||
if (request.getTxnMinLimitAmount() != null && request.getTxnMaxLimitAmount() != null
|
||||
&& request.getTxnMaxLimitAmount().compareTo(request.getTxnMinLimitAmount()) < 0) {
|
||||
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount())
|
||||
.failWithCode("must.be.greater.than.min.limit");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(request.getRuleName()) && this.repository.existsByRuleNameIgnoreCase(request.getRuleName().trim())) {
|
||||
validator.reset().parameter("ruleName").value(request.getRuleName()).failWithCode("duplicate");
|
||||
}
|
||||
|
||||
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
||||
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** Guards the enum conversions in {@link #buildTransactionRule}, which would otherwise throw a
|
||||
* bare IllegalArgumentException and surface to the officer as a 500. */
|
||||
private <E extends Enum<E>> void validateEnum(final DataValidatorBuilder validator, final String parameter,
|
||||
final String value, final Class<E> type) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
final String candidate = value.trim().toUpperCase();
|
||||
for (final E constant : type.getEnumConstants()) {
|
||||
if (constant.name().equals(candidate)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
validator.reset().parameter(parameter).value(value).failWithCode("is.not.supported");
|
||||
}
|
||||
}
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* 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.dividenddashboard.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
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.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Maker-checker workflow for a member to claim an unclaimed dividend (see
|
||||
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
|
||||
* that was never credited into any savings account). A clerk (maker) records exactly how the
|
||||
* member wants it split when they visit the branch: straight into an overdue loan, credited to
|
||||
* their linked savings account, or handed over as cash -- any combination, any leftover amount
|
||||
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
|
||||
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
|
||||
*
|
||||
* <p>
|
||||
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
|
||||
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
|
||||
* which debits whatever GL account is actually configured as that product's Fund Source -- not
|
||||
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
||||
* "Warehouse Receipt" account instead). Since the money never actually came from that account, an
|
||||
* immediate manual journal entry (Dr Dividend Payable / Cr whichever account was actually debited
|
||||
* -- read straight off the GL entry the native command just posted) replenishes it for that same
|
||||
* amount, so the net effect is exactly "Dividend Payable decreases, loan/savings changes, that
|
||||
* fund-source account is untouched". The cash leg has no account to post through, so it's a direct
|
||||
* manual entry only (Dr Dividend Payable / Cr Cash at Main Vault) -- real cash leaves the vault
|
||||
* into the member's hand.
|
||||
*/
|
||||
@Path("/v1/clerk/dividend-claim-requests")
|
||||
@Component
|
||||
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendClaimRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
|
||||
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
||||
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
||||
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
|
||||
"r.savings_apply_amount", "r.cash_apply_amount", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
|
||||
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at" };
|
||||
|
||||
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
|
||||
|
||||
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
|
||||
@GET
|
||||
@Path("/overdue-loans/{clientId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String overdueLoans(@PathParam("clientId") final Long clientId) {
|
||||
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
||||
SELECT ln.id AS loan_id, ln.account_no,
|
||||
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
||||
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
||||
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
|
||||
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
|
||||
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||
GROUP BY ln.id, ln.account_no
|
||||
ORDER BY overdue_amount DESC
|
||||
""", clientId);
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> loan : loans) {
|
||||
final JsonObject row = new JsonObject();
|
||||
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
|
||||
row.addProperty("accountNo", (String) loan.get("account_no"));
|
||||
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
|
||||
result.add(row);
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
|
||||
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
|
||||
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
|
||||
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
|
||||
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root));
|
||||
if (lockId == null) {
|
||||
throw apiError(400, "lockId is required");
|
||||
}
|
||||
|
||||
final Map<String, Object> lock = jdbcTemplate
|
||||
.queryForList("SELECT client_id, savings_account_id, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId)
|
||||
.stream().findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
||||
}
|
||||
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||
final Object lockSavingsAccountId = lock.get("savings_account_id");
|
||||
|
||||
// The share account isn't required to have a linked savings account up front any more --
|
||||
// if the member wants a savings credit and none is linked, the clerk supplies which
|
||||
// account to credit right here.
|
||||
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
|
||||
: savingsAccountIdOverride;
|
||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
if (resolvedSavingsAccountId == null) {
|
||||
throw apiError(400,
|
||||
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
|
||||
}
|
||||
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
||||
resolvedSavingsAccountId);
|
||||
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
|
||||
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
|
||||
}
|
||||
}
|
||||
|
||||
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
||||
if (total.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
|
||||
}
|
||||
if (total.compareTo(unclaimedAmount) > 0) {
|
||||
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
||||
}
|
||||
|
||||
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
if (loanId == null) {
|
||||
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
|
||||
}
|
||||
final Map<String, Object> loan = jdbcTemplate
|
||||
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
|
||||
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
|
||||
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
|
||||
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
|
||||
}
|
||||
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
|
||||
throw apiError(400, "Loan " + loanId + " is not Active");
|
||||
}
|
||||
}
|
||||
|
||||
final Long existingPending = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
||||
if (existingPending != null && existingPending > 0) {
|
||||
throw apiError(409, "A claim request for this dividend is already pending review.");
|
||||
}
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_dividend_claim_request
|
||||
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
|
||||
cash_apply_amount, status, clerk_id, clerk_name, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||
RETURNING id
|
||||
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
|
||||
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
}
|
||||
|
||||
private BigDecimal parseAmount(final String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw apiError(400, "'" + value + "' is not a valid amount");
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/dividend-claim-requests ── Manager sees claim requests ────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + """
|
||||
FROM m_share_dividend_claim_request r
|
||||
JOIN m_client c ON c.id = r.client_id
|
||||
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
|
||||
JOIN m_share_dividend_lock l ON l.id = r.lock_id
|
||||
WHERE r.status = ?
|
||||
ORDER BY r.submitted_at ASC
|
||||
""", status);
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Long clientId = ((Number) row.get("client_id")).longValue();
|
||||
row.put("has_overdue_loan", hasOverdueLoan(clientId));
|
||||
}
|
||||
return gson.toJson(rows);
|
||||
}
|
||||
|
||||
private boolean hasOverdueLoan(final Long clientId) {
|
||||
final Long count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||
""", Long.class, clientId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/dividend-claim-requests/{id}/approve ── Manager executes the disbursement ──
|
||||
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<String, Object> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId())) {
|
||||
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
|
||||
}
|
||||
|
||||
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
||||
final Map<String, Object> lock = jdbcTemplate
|
||||
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
|
||||
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
|
||||
}
|
||||
|
||||
final String currencyCode = (String) lock.get("currency_code");
|
||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
|
||||
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
|
||||
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
||||
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
||||
if (total.compareTo(unclaimedAmount) > 0) {
|
||||
throw apiError(409,
|
||||
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
|
||||
}
|
||||
final Object claimSavingsAccountId = record.get("savings_account_id");
|
||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
|
||||
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
|
||||
}
|
||||
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
|
||||
|
||||
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final Long loanId = ((Number) record.get("loan_id")).longValue();
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", today.toString());
|
||||
payload.addProperty("transactionAmount", loanApplyAmount);
|
||||
payload.addProperty("note", note);
|
||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(id, currencyCode, loanApplyAmount, today, fundSourceAccountId);
|
||||
}
|
||||
|
||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", today.toString());
|
||||
payload.addProperty("transactionAmount", savingsApplyAmount);
|
||||
payload.addProperty("note", note);
|
||||
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
||||
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForSavingsTransaction(depositResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(id, currencyCode, savingsApplyAmount, today, fundSourceAccountId);
|
||||
}
|
||||
|
||||
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
|
||||
}
|
||||
|
||||
final BigDecimal remainder = unclaimedAmount.subtract(total);
|
||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
||||
lockId);
|
||||
} else {
|
||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), id);
|
||||
|
||||
final JsonObject response = new JsonObject();
|
||||
response.addProperty("resourceId", id);
|
||||
response.addProperty("status", "APPROVED");
|
||||
response.addProperty("totalDisbursed", total);
|
||||
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which GL account a loan repayment or savings deposit's cash/fund-source leg actually debits
|
||||
* is a per-product (sometimes per-payment-type) configuration choice, not always literally
|
||||
* "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
||||
* "Warehouse Receipt" account instead. Rather than assume, these read the actual GL entry the
|
||||
* native command just posted for this transaction and return whichever account it debited.
|
||||
*/
|
||||
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
|
||||
return jdbcTemplate
|
||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
|
||||
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private Long resolveDebitAccountForSavingsTransaction(final Long savingsTransactionId) {
|
||||
return jdbcTemplate
|
||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ?",
|
||||
Long.class, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loan repayment / savings deposit just posted above assumed the money came out of
|
||||
* whichever account is that product's actual Fund Source -- it didn't really. This puts that
|
||||
* fund straight back, sourced from the Dividend Payable liability instead, so the net effect
|
||||
* is exactly "liability shrinks, the loan/savings account changes, that fund-source account is
|
||||
* unaffected". Skipped (loan/savings still updated) if the bank hasn't mapped the Dividend
|
||||
* Payable financial activity, or if the command's own debit leg couldn't be found.
|
||||
*/
|
||||
private void replenishFundSourceFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
||||
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
||||
final String insertSql = """
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
||||
submitted_on_date, transaction_date)
|
||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||
""";
|
||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
|
||||
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
|
||||
* member's hand.
|
||||
*/
|
||||
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
||||
final LocalDate entryDate) {
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long cashAtVaultAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
||||
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
||||
final String insertSql = """
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
||||
submitted_on_date, transaction_date)
|
||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||
""";
|
||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<String, Object> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING claim requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Rejection reason is required");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_share_dividend_claim_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, rejection_reason = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), reason, Timestamp.from(Instant.now()), id);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
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("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject a dividend claim request.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
||||
status, clerk_id, clerk_name
|
||||
FROM m_share_dividend_claim_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Dividend claim request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject errorBody = new JsonObject();
|
||||
errorBody.addProperty("message", message);
|
||||
errorBody.addProperty("defaultUserMessage", message);
|
||||
errorBody.addProperty("httpStatus", httpStatus);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 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.dividenddashboard.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
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.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
|
||||
* progress, full 3-year lock history, and members whose unclaimed dividend (see
|
||||
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
|
||||
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
|
||||
* to be forfeited to profit after 3 years.
|
||||
*/
|
||||
@Path("/v1/clerk/dividend-dashboard")
|
||||
@Component
|
||||
@Tag(name = "Dividend Dashboard", description = "Bank Manager view of locked share dividends belonging to members with overdue loan EMIs, with an action to apply the dividend towards the loan.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendDashboardApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
|
||||
private static final int DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL = 100;
|
||||
private static final int DIVIDEND_PAYOUT_STATUS_APPROVED = 300;
|
||||
|
||||
// ── GET /v1/clerk/dividend-dashboard/summary ─────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("/summary")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String summary() {
|
||||
final Map<String, Object> declarationStats = jdbcTemplate.queryForMap("""
|
||||
SELECT COUNT(*) AS total_declarations,
|
||||
COUNT(*) FILTER (WHERE status = %d) AS pending_approval_declarations,
|
||||
COUNT(*) FILTER (WHERE status = %d) AS approved_declarations
|
||||
FROM m_share_product_dividend_pay_out
|
||||
""".formatted(DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL, DIVIDEND_PAYOUT_STATUS_APPROVED));
|
||||
|
||||
// Approval and disbursement now happen atomically (see
|
||||
// ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable) -- there's no
|
||||
// more "posted vs pending vs failed" spread to track at the detail level once approved.
|
||||
final Map<String, Object> detailStats = jdbcTemplate.queryForMap("""
|
||||
SELECT COUNT(DISTINCT sa.client_id) AS members_affected,
|
||||
COUNT(sad.id) AS total_member_dividends,
|
||||
COALESCE(SUM(sad.amount), 0) AS total_amount
|
||||
FROM m_share_account_dividend_details sad
|
||||
JOIN m_share_account sa ON sa.id = sad.account_id
|
||||
""");
|
||||
|
||||
final Map<String, Object> lockStats = jdbcTemplate.queryForMap("""
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'UNCLAIMED') AS locked_count,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'UNCLAIMED'), 0) AS locked_amount,
|
||||
COUNT(*) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL) AS applied_count,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL), 0) AS applied_amount,
|
||||
COUNT(*) FILTER (WHERE status = 'FORFEITED') AS forfeited_count,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'FORFEITED'), 0) AS forfeited_amount,
|
||||
COUNT(*) FILTER (WHERE status = 'CLAIMED') AS claimed_count,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED'), 0) AS claimed_amount
|
||||
FROM m_share_dividend_lock
|
||||
""");
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
|
||||
result.addProperty("pendingApprovalDeclarations",
|
||||
((Number) declarationStats.get("pending_approval_declarations")).longValue());
|
||||
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
|
||||
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
|
||||
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
|
||||
result.addProperty("totalAmount", (BigDecimal) detailStats.get("total_amount"));
|
||||
result.addProperty("lockedCount", ((Number) lockStats.get("locked_count")).longValue());
|
||||
result.addProperty("lockedAmount", (BigDecimal) lockStats.get("locked_amount"));
|
||||
result.addProperty("appliedToLoanCount", ((Number) lockStats.get("applied_count")).longValue());
|
||||
result.addProperty("appliedToLoanAmount", (BigDecimal) lockStats.get("applied_amount"));
|
||||
result.addProperty("forfeitedCount", ((Number) lockStats.get("forfeited_count")).longValue());
|
||||
result.addProperty("forfeitedAmount", (BigDecimal) lockStats.get("forfeited_amount"));
|
||||
result.addProperty("claimedCount", ((Number) lockStats.get("claimed_count")).longValue());
|
||||
result.addProperty("claimedAmount", (BigDecimal) lockStats.get("claimed_amount"));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
||||
|
||||
/**
|
||||
* Every dividend ever declared (across every share product), so a manager tracking multiple
|
||||
* dividends at once can see -- per declaration -- how many members it covers and how far its
|
||||
* payout has actually progressed, not just whether it was approved.
|
||||
*/
|
||||
@GET
|
||||
@Path("/declarations")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String declarations() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT spd.id AS declaration_id, sp.name AS product_name,
|
||||
spd.dividend_period_start_date, spd.dividend_period_end_date, spd.amount AS declared_amount,
|
||||
spd.status AS declaration_status, spd.created_date, spd.lastmodified_date AS approved_on,
|
||||
COUNT(sad.id) AS total_members,
|
||||
COALESCE(SUM(sad.amount), 0) AS total_distributed_amount
|
||||
FROM m_share_product_dividend_pay_out spd
|
||||
JOIN m_share_product sp ON sp.id = spd.product_id
|
||||
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = spd.id
|
||||
GROUP BY spd.id, sp.name, spd.dividend_period_start_date, spd.dividend_period_end_date,
|
||||
spd.amount, spd.status, spd.created_date, spd.lastmodified_date
|
||||
ORDER BY spd.created_date DESC
|
||||
""");
|
||||
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final int declarationStatus = ((Number) row.get("declaration_status")).intValue();
|
||||
final Timestamp approvedOn = (Timestamp) row.get("approved_on");
|
||||
final boolean isApproved = declarationStatus == DIVIDEND_PAYOUT_STATUS_APPROVED;
|
||||
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("declarationId", ((Number) row.get("declaration_id")).longValue());
|
||||
entry.addProperty("productName", (String) row.get("product_name"));
|
||||
entry.addProperty("periodStartDate", row.get("dividend_period_start_date").toString());
|
||||
entry.addProperty("periodEndDate", row.get("dividend_period_end_date").toString());
|
||||
entry.addProperty("declaredAmount", (BigDecimal) row.get("declared_amount"));
|
||||
entry.addProperty("totalDistributedAmount", (BigDecimal) row.get("total_distributed_amount"));
|
||||
entry.addProperty("createdDate", row.get("created_date").toString());
|
||||
entry.addProperty("approvedOn", approvedOn == null ? null : approvedOn.toString());
|
||||
entry.addProperty("totalMembers", ((Number) row.get("total_members")).longValue());
|
||||
entry.addProperty("status", isApproved ? "APPROVED" : "PENDING_APPROVAL");
|
||||
result.add(entry);
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
|
||||
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
|
||||
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
|
||||
* profit). Optional {@code status} query param filters to one.
|
||||
*/
|
||||
@GET
|
||||
@Path("/locks")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String locks(@QueryParam("status") final String status) {
|
||||
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
|
||||
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
||||
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
||||
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
|
||||
+ "FROM m_share_dividend_lock l " //
|
||||
+ "JOIN m_client c ON c.id = l.client_id " //
|
||||
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
||||
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
||||
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
||||
+ "ORDER BY l.locked_date DESC";
|
||||
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
|
||||
: jdbcTemplate.queryForList(sql);
|
||||
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
||||
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||
final Object savingsAccountId = row.get("savings_account_id");
|
||||
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
||||
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||
entry.addProperty("lockedDate", row.get("locked_date").toString());
|
||||
entry.addProperty("releaseDueDate", row.get("release_due_date").toString());
|
||||
entry.addProperty("status", (String) row.get("status"));
|
||||
final Object appliedLoanId = row.get("applied_loan_id");
|
||||
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
|
||||
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
||||
result.add(entry);
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/dividend-dashboard ─────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list() {
|
||||
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
||||
l.locked_date, l.release_due_date
|
||||
FROM m_share_dividend_lock l
|
||||
JOIN m_client c ON c.id = l.client_id
|
||||
WHERE l.status = 'UNCLAIMED'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||
WHERE ln.client_id = l.client_id AND ln.loan_status_id = 300
|
||||
AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||
)
|
||||
ORDER BY l.locked_date ASC
|
||||
""");
|
||||
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> lock : locks) {
|
||||
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||
final JsonObject row = new JsonObject();
|
||||
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
||||
row.addProperty("clientId", clientId);
|
||||
row.addProperty("clientName", (String) lock.get("client_name"));
|
||||
final Object savingsAccountId = lock.get("savings_account_id");
|
||||
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
||||
row.addProperty("lockedDate", lock.get("locked_date").toString());
|
||||
row.addProperty("releaseDueDate", lock.get("release_due_date").toString());
|
||||
row.add("overdueLoans", overdueLoansFor(clientId));
|
||||
result.add(row);
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private JsonArray overdueLoansFor(final Long clientId) {
|
||||
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
||||
SELECT ln.id AS loan_id, ln.account_no,
|
||||
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
||||
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
||||
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
|
||||
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
|
||||
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||
GROUP BY ln.id, ln.account_no
|
||||
ORDER BY overdue_amount DESC
|
||||
""", clientId);
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> loan : loans) {
|
||||
final JsonObject row = new JsonObject();
|
||||
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
|
||||
row.addProperty("accountNo", (String) loan.get("account_no"));
|
||||
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
|
||||
|
||||
/**
|
||||
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
|
||||
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
|
||||
* and file a claim. Since the dividend was never credited into any savings account (it's a
|
||||
* pure "Dividend Payable" GL liability until claimed -- see
|
||||
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
|
||||
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
|
||||
* native and correct) and then replenishes the vault it assumed the cash came from, sourced
|
||||
* instead from the Dividend Payable liability -- identical mechanic to a claim's loan leg.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{lockId}/apply-to-loan")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String applyToLoan(@PathParam("lockId") final Long lockId, final String body) {
|
||||
final Map<String, Object> lock = jdbcTemplate
|
||||
.queryForList("SELECT client_id, currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
|
||||
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
||||
}
|
||||
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||
final String currencyCode = (String) lock.get("currency_code");
|
||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
|
||||
final String amountString = fromJsonHelper.extractStringNamed("amount", root);
|
||||
if (loanId == null) {
|
||||
throw apiError(400, "loanId is required");
|
||||
}
|
||||
BigDecimal applyAmount;
|
||||
try {
|
||||
applyAmount = amountString == null ? null : new BigDecimal(amountString.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw apiError(400, "amount must be a valid number");
|
||||
}
|
||||
if (applyAmount == null || applyAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "amount must be greater than zero");
|
||||
}
|
||||
if (applyAmount.compareTo(unclaimedAmount) > 0) {
|
||||
throw apiError(400, "amount cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
||||
}
|
||||
final Map<String, Object> loan = jdbcTemplate.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId)
|
||||
.stream().findFirst().orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
|
||||
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
|
||||
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
|
||||
}
|
||||
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", today.toString());
|
||||
payload.addProperty("transactionAmount", applyAmount);
|
||||
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
|
||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(lockId, currencyCode, applyAmount, today, fundSourceAccountId);
|
||||
|
||||
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
||||
lockId);
|
||||
} else {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_share_dividend_lock
|
||||
SET status = 'CLAIMED', applied_loan_id = ?, applied_transaction_id = ?, last_modified_date = now()
|
||||
WHERE id = ?
|
||||
""", loanId, repaymentResult.getResourceId(), lockId);
|
||||
}
|
||||
|
||||
final JsonObject response = new JsonObject();
|
||||
response.addProperty("lockId", lockId);
|
||||
response.addProperty("appliedAmount", applyAmount);
|
||||
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which GL account a loan repayment's cash/fund-source leg actually debits is a per-product
|
||||
* (sometimes per-payment-type) configuration choice, not always literally "Cash at Main Vault"
|
||||
* -- e.g. a Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account
|
||||
* instead. Rather than assume, this reads the actual GL entry the native repayment command
|
||||
* just posted for this transaction and returns whichever account it debited.
|
||||
*/
|
||||
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
|
||||
return jdbcTemplate
|
||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
|
||||
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loan repayment just posted above assumed the money came out of whichever account is
|
||||
* this loan product's actual Fund Source -- it didn't really. This puts that fund straight
|
||||
* back, sourced from the Dividend Payable liability instead, so the net effect is exactly
|
||||
* "liability shrinks, loan reduces, that fund-source account is unaffected". Skipped (loan
|
||||
* still updated) if the bank hasn't mapped the Dividend Payable financial activity, or if the
|
||||
* repayment's own debit leg couldn't be found.
|
||||
*/
|
||||
private void replenishFundSourceFromDividendPayable(final Long lockId, final String currencyCode, final BigDecimal amount,
|
||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVAPPLYLOAN" + lockId + "-" + System.nanoTime();
|
||||
final String description = "Unclaimed dividend lock #" + lockId + " applied to loan";
|
||||
final String insertSql = """
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
||||
submitted_on_date, transaction_date)
|
||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||
""";
|
||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject errorBody = new JsonObject();
|
||||
errorBody.addProperty("message", message);
|
||||
errorBody.addProperty("defaultUserMessage", message);
|
||||
errorBody.addProperty("httpStatus", httpStatus);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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.dividenddashboard.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.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Every Share Product's dividend declaration that is still awaiting approval — Fineract only
|
||||
* exposes dividends scoped to one product at a time
|
||||
* ({@code GET /shareproduct/{productId}/dividend}), so a Bank Manager has no single place to see
|
||||
* "everything currently waiting on me" across products. This aggregates all
|
||||
* {@code m_share_product_dividend_pay_out} rows still INITIATED so they can be surfaced on the
|
||||
* Approval Center (Checker Inbox) alongside the other maker-checker request types. Approval
|
||||
* itself still goes through Fineract's native {@code approveDividend} command from the
|
||||
* frontend — this resource only covers the missing cross-product listing.
|
||||
*/
|
||||
@Path("/v1/clerk/pending-dividends")
|
||||
@Component
|
||||
@Tag(name = "Pending Dividends", description = "Dividend declarations awaiting approval, across all Share Products.")
|
||||
@RequiredArgsConstructor
|
||||
public class PendingDividendsApiResource {
|
||||
|
||||
private static final int DIVIDEND_PAYOUT_STATUS_INITIATED = 100;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT p.id AS dividend_id, p.product_id, sp.name AS product_name, p.amount,
|
||||
p.dividend_period_start_date, p.dividend_period_end_date, p.created_date,
|
||||
(SELECT COUNT(*) FROM m_share_account_dividend_details d WHERE d.dividend_pay_out_id = p.id) AS member_count
|
||||
FROM m_share_product_dividend_pay_out p
|
||||
JOIN m_share_product sp ON sp.id = p.product_id
|
||||
WHERE p.status = ?
|
||||
ORDER BY p.created_date ASC
|
||||
""", DIVIDEND_PAYOUT_STATUS_INITIATED);
|
||||
|
||||
final JsonArray result = new JsonArray();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("dividendId", ((Number) row.get("dividend_id")).longValue());
|
||||
entry.addProperty("productId", ((Number) row.get("product_id")).longValue());
|
||||
entry.addProperty("productName", (String) row.get("product_name"));
|
||||
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||
entry.addProperty("dividendPeriodStartDate", row.get("dividend_period_start_date").toString());
|
||||
entry.addProperty("dividendPeriodEndDate", row.get("dividend_period_end_date").toString());
|
||||
entry.addProperty("declaredOn", row.get("created_date") == null ? null : row.get("created_date").toString());
|
||||
entry.addProperty("memberCount", ((Number) row.get("member_count")).intValue());
|
||||
result.add(entry);
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -93,6 +93,15 @@ public class SanctionsXmlLoaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the previous list rather than adding to it. A consolidated-list XML is a full
|
||||
// snapshot, so appending would both duplicate every entry that is still listed and leave
|
||||
// de-listed individuals active forever — screening has to reflect the current list only.
|
||||
// Done after parsing has fully succeeded, so a malformed file cannot leave the table empty.
|
||||
final long removed = sanctionsEntryRepository.countByActiveTrue();
|
||||
sanctionsEntryRepository.deleteAllByActiveTrue();
|
||||
sanctionsEntryRepository.flush();
|
||||
LOG.info("Removed {} previously loaded sanctions entries before loading the new snapshot", removed);
|
||||
|
||||
// Save in batches
|
||||
final int batchSize = 100;
|
||||
for (int i = 0; i < allEntries.size(); i += batchSize) {
|
||||
|
||||
+1
-1
@@ -647,7 +647,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
}
|
||||
|
||||
private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) {
|
||||
return this.withHoldTax(savingsAccountData) && this.depositAccountType(savingsAccountData).isSavingsDeposit();
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean withHoldTax(final SavingsAccountData savingsAccountData) {
|
||||
|
||||
+9
@@ -67,6 +67,10 @@ public class ShareAccountData implements Serializable, AccountData {
|
||||
|
||||
private Long savingsAccountId;
|
||||
|
||||
private Long linkedLoanId;
|
||||
|
||||
private String linkedLoanAccountNo;
|
||||
|
||||
private BigDecimal currentMarketPrice;
|
||||
|
||||
private Integer lockinPeriod;
|
||||
@@ -301,6 +305,11 @@ public class ShareAccountData implements Serializable, AccountData {
|
||||
this.currentMarketPrice = currentMarketPrice;
|
||||
}
|
||||
|
||||
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
|
||||
this.linkedLoanId = linkedLoanId;
|
||||
this.linkedLoanAccountNo = linkedLoanAccountNo;
|
||||
}
|
||||
|
||||
public void setDividends(Collection<ShareAccountDividendData> dividends) {
|
||||
this.dividends = dividends;
|
||||
}
|
||||
|
||||
+6
@@ -51,4 +51,10 @@ public class ShareAccountTransactionData implements Serializable {
|
||||
|
||||
private final BigDecimal amountPaid;
|
||||
|
||||
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
|
||||
* only for a redeem (withdrawal) transaction; null otherwise. */
|
||||
private final String resolutionNumber;
|
||||
|
||||
private final LocalDate meetingDate;
|
||||
|
||||
}
|
||||
|
||||
+19
@@ -147,6 +147,12 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccount", orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private Set<ShareAccountCharge> charges;
|
||||
|
||||
/** Optional loan this share account is held as margin/security against. A raw id (not a JPA
|
||||
* relation) since shareaccounts and loanaccount are otherwise unrelated aggregate roots in
|
||||
* this codebase — kept loosely coupled, resolved via a lookup where needed. */
|
||||
@Column(name = "linked_loan_id")
|
||||
private Long linkedLoanId;
|
||||
|
||||
@Transient
|
||||
protected boolean accountNumberRequiresAutoGeneration = false;
|
||||
|
||||
@@ -599,6 +605,19 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
return this.activatedDate;
|
||||
}
|
||||
|
||||
public Long getLinkedLoanId() {
|
||||
return this.linkedLoanId;
|
||||
}
|
||||
|
||||
public boolean setLinkedLoanId(final Long linkedLoanId) {
|
||||
boolean returnValue = false;
|
||||
if (this.linkedLoanId == null || !this.linkedLoanId.equals(linkedLoanId)) {
|
||||
this.linkedLoanId = linkedLoanId;
|
||||
returnValue = true;
|
||||
}
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
public Integer status() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
+24
@@ -67,6 +67,14 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
/** Board Resolution Number and Date of Meeting authorising this transaction — required for a
|
||||
* share withdrawal (redeem), a governance requirement for cooperative-sector share capital. */
|
||||
@Column(name = "resolution_number")
|
||||
private String resolutionNumber;
|
||||
|
||||
@Column(name = "meeting_date")
|
||||
private LocalDate meetingDate;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
|
||||
|
||||
@@ -109,6 +117,22 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
return new ShareAccountTransaction(transactionDate, totalShares, shareValue, status, type, amount, null, amountPaid);
|
||||
}
|
||||
|
||||
public static ShareAccountTransaction createRedeemTransaction(final LocalDate transactionDate, final Long totalShares,
|
||||
final BigDecimal shareValue, final String resolutionNumber, final LocalDate meetingDate) {
|
||||
ShareAccountTransaction transaction = createRedeemTransaction(transactionDate, totalShares, shareValue);
|
||||
transaction.resolutionNumber = resolutionNumber;
|
||||
transaction.meetingDate = meetingDate;
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public String getResolutionNumber() {
|
||||
return this.resolutionNumber;
|
||||
}
|
||||
|
||||
public LocalDate getMeetingDate() {
|
||||
return this.meetingDate;
|
||||
}
|
||||
|
||||
public static ShareAccountTransaction createChargeTransaction(final LocalDate transactionDate, final ShareAccountCharge charge) {
|
||||
final Long totalShares = null;
|
||||
final BigDecimal unitPrice = null;
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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.jobs.forfeitunclaimeddividends;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class ForfeitUnclaimedDividendsConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
@Autowired
|
||||
private ShareAccountSchedularService shareAccountSchedularService;
|
||||
|
||||
@Bean
|
||||
protected Step forfeitUnclaimedDividendsStep() {
|
||||
return new StepBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository)
|
||||
.tasklet(forfeitUnclaimedDividendsTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job forfeitUnclaimedDividendsJob() {
|
||||
return new JobBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository).start(forfeitUnclaimedDividendsStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ForfeitUnclaimedDividendsTasklet forfeitUnclaimedDividendsTasklet() {
|
||||
return new ForfeitUnclaimedDividendsTasklet(jdbcTemplate, shareAccountSchedularService);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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.jobs.forfeitunclaimeddividends;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
|
||||
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
|
||||
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;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
|
||||
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
|
||||
* forfeited — the hold on the member's savings account is released and the amount is swept back
|
||||
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
|
||||
* the actual GL/balance mechanics.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ShareAccountSchedularService shareAccountSchedularService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
final List<Throwable> exceptions = new ArrayList<>();
|
||||
final List<Long> lockIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
|
||||
|
||||
for (final Long lockId : lockIds) {
|
||||
try {
|
||||
this.shareAccountSchedularService.forfeitDividendLock(lockId);
|
||||
} catch (final Exception e) {
|
||||
log.error("Forfeit Unclaimed Dividends failed for share dividend lock id: {}", lockId, e);
|
||||
exceptions.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!exceptions.isEmpty()) {
|
||||
throw new JobExecutionException(exceptions);
|
||||
}
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+149
-9
@@ -72,6 +72,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -93,6 +94,8 @@ public class ShareAccountDataSerializer {
|
||||
|
||||
private final SavingsAccountReadPlatformService savingsAccountReadPlatformService;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final Set<String> approvalParameters = new HashSet<>(
|
||||
Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.dateformat_paramname,
|
||||
ShareAccountApiConstants.approveddate_paramname, ShareAccountApiConstants.note_paramname));
|
||||
@@ -106,13 +109,14 @@ public class ShareAccountDataSerializer {
|
||||
|
||||
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
|
||||
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
|
||||
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname));
|
||||
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
|
||||
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
|
||||
|
||||
@Autowired
|
||||
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
|
||||
final ChargeRepositoryWrapper chargeRepository, final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||
final ClientRepositoryWrapper clientRepositoryWrapper, final ShareProductRepositoryWrapper shareProductRepository,
|
||||
final SavingsAccountReadPlatformService savingsAccountReadPlatformService) {
|
||||
final SavingsAccountReadPlatformService savingsAccountReadPlatformService, final JdbcTemplate jdbcTemplate) {
|
||||
this.platformSecurityContext = platformSecurityContext;
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
this.chargeRepository = chargeRepository;
|
||||
@@ -120,6 +124,7 @@ public class ShareAccountDataSerializer {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.shareProductRepository = shareProductRepository;
|
||||
this.savingsAccountReadPlatformService = savingsAccountReadPlatformService;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public ShareAccount validateAndCreate(JsonCommand jsonCommand) {
|
||||
@@ -313,6 +318,13 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
|
||||
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
|
||||
if (account.setLinkedLoanId(linkedLoanId)) {
|
||||
actualChanges.put(ShareAccountApiConstants.linkedloanid_paramname, linkedLoanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.savingsaccountid_paramname, element)) {
|
||||
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
|
||||
@@ -885,6 +897,13 @@ public class ShareAccountDataSerializer {
|
||||
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(sharesRequested).notNull()
|
||||
.longGreaterThanZero();
|
||||
// Governance requirement: a share withdrawal must be authorised by a Board Resolution.
|
||||
final String resolutionNumber = this.fromApiJsonHelper
|
||||
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
|
||||
final LocalDate meetingDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname,
|
||||
element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
|
||||
boolean isTransactionBeforeExistingTransactions = false;
|
||||
isTransactionBeforeExistingTransactions = isTransactionBeforeExistingTransactions(requestedDate,
|
||||
isTransactionBeforeExistingTransactions, account);
|
||||
@@ -896,7 +915,8 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(requestedDate);
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice);
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice,
|
||||
resolutionNumber, meetingDate);
|
||||
validateRedeemRequest(account, transaction, baseDataValidator, dataValidationErrors);
|
||||
account.addAdditionalPurchasedShares(transaction);
|
||||
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
|
||||
@@ -917,6 +937,18 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
final Long remainingShares = account.getTotalApprovedShares() - redeemTransaction.getTotalShares();
|
||||
|
||||
validateAgainstLinkedLoan(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
validateMinimumSharesAndTenure(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
LocalDate redeemDate = redeemTransaction.getPurchasedDate();
|
||||
final Integer lockinPeriod = account.getLockinPeriodFrequency();
|
||||
final PeriodFrequencyType periodType = account.getLockinPeriodFrequencyType();
|
||||
@@ -967,6 +999,63 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
/** If this share account is held as margin/security against a loan, the shares remaining
|
||||
* after this redemption (at current unit price) must still cover that loan's outstanding
|
||||
* balance. */
|
||||
private void validateAgainstLinkedLoan(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
|
||||
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long linkedLoanId = account.getLinkedLoanId();
|
||||
if (linkedLoanId == null) {
|
||||
return;
|
||||
}
|
||||
BigDecimal outstandingLoanBalance;
|
||||
try {
|
||||
outstandingLoanBalance = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(total_outstanding_derived, principal_disbursed_derived, 0) FROM m_loan WHERE id = ? AND loan_status_id = 300",
|
||||
BigDecimal.class, linkedLoanId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
// Linked loan is no longer active (closed/written-off) — nothing left to protect.
|
||||
outstandingLoanBalance = null;
|
||||
}
|
||||
if (outstandingLoanBalance == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal remainingShareValue = redeemTransaction.getPurchasePrice().multiply(BigDecimal.valueOf(remainingShares));
|
||||
if (remainingShareValue.compareTo(outstandingLoanBalance) < 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
|
||||
.value(redeemTransaction.getTotalShares())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.shares.linked.to.active.loan.margin.shortfall");
|
||||
}
|
||||
}
|
||||
|
||||
/** If this redemption would bring the holding below the Share Product's configured minimum
|
||||
* client shares, the account must have been active for at least the product's configured
|
||||
* minimum active period before the reduction is allowed. */
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
|
||||
final PeriodFrequencyType minimumActivePeriodType = account.getMinimumActivePeriodFrequencyType();
|
||||
final LocalDate activatedDate = account.getActivatedDate();
|
||||
if (minimumActivePeriod == null || minimumActivePeriodType == null || activatedDate == null) {
|
||||
// Product enforces a minimum balance but defines no minimum tenure — reducing below
|
||||
// minimum is simply not allowed at all in that case.
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
|
||||
.value(redeemTransaction.getTotalShares())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.required.by.product");
|
||||
return;
|
||||
}
|
||||
final LocalDate eligibleFromDate = deriveLockinPeriodDuration(minimumActivePeriod, minimumActivePeriodType, activatedDate);
|
||||
if (DateUtils.isAfter(eligibleFromDate, redeemTransaction.getPurchasedDate())) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname)
|
||||
.value(redeemTransaction.getPurchasedDate())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.before.minimum.tenure");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate deriveLockinPeriodDuration(final Integer lockinPeriod, final PeriodFrequencyType periodType, LocalDate purchaseDate) {
|
||||
LocalDate lockinDate = purchaseDate;
|
||||
if (periodType != null) {
|
||||
@@ -1044,14 +1133,65 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
validateNoActiveLoanOrGuaranteeBeforeClosure(account, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
|
||||
final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, account.getTotalApprovedShares(),
|
||||
unitPrice);
|
||||
account.addAdditionalPurchasedShares(transaction);
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
account.close(closedDate, approvedUser);
|
||||
handleRedeemSharesChargeTransactions(account, transaction);
|
||||
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
/** A share account cannot be closed while the member has an active loan (share capital is
|
||||
* often required as continuing membership/security while a liability is outstanding) or is
|
||||
* standing as an active guarantor on someone else's active loan. */
|
||||
private void validateNoActiveLoanOrGuaranteeBeforeClosure(final ShareAccount account, final DataValidatorBuilder baseDataValidator,
|
||||
final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long clientId = account.getClientId();
|
||||
Integer activeLoanCount;
|
||||
try {
|
||||
activeLoanCount = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id IN (300, 303, 304, 700)", Integer.class,
|
||||
clientId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
activeLoanCount = 0;
|
||||
}
|
||||
if (activeLoanCount != null && activeLoanCount > 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
|
||||
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.loan");
|
||||
return;
|
||||
}
|
||||
|
||||
Integer activeGuaranteeCount;
|
||||
try {
|
||||
activeGuaranteeCount = this.jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(g.id)
|
||||
FROM m_guarantor g
|
||||
JOIN m_loan l ON l.id = g.loan_id
|
||||
WHERE g.entity_id = ?
|
||||
AND g.type_enum = 1
|
||||
AND g.is_active = true
|
||||
AND l.loan_status_id IN (300, 303, 304, 700)
|
||||
""", Integer.class, clientId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
activeGuaranteeCount = 0;
|
||||
}
|
||||
if (activeGuaranteeCount != null && activeGuaranteeCount > 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
|
||||
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.guarantee");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -50,7 +50,8 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
||||
StringBuilder buff = new StringBuilder()
|
||||
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
|
||||
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
|
||||
.append("saps.amount_paid as amountPaid").append(" from m_share_account_transactions saps ");
|
||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
|
||||
.append(" from m_share_account_transactions saps ");
|
||||
schema = buff.toString();
|
||||
}
|
||||
|
||||
@@ -68,9 +69,12 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
||||
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amount");
|
||||
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "chargeamount");
|
||||
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amountPaid");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||
|
||||
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||
amount, chargeAmount, amountPaid);
|
||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
||||
}
|
||||
|
||||
public String schema() {
|
||||
|
||||
+15
-6
@@ -267,6 +267,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
this.purchasedShares = purchasedShares;
|
||||
StringBuilder buff = new StringBuilder().append(
|
||||
"sa.id as id, sa.external_id as externalId, sa.resolution_number as resolutionNumber, sa.status_enum as statusEnum, ")
|
||||
.append("sa.linked_loan_id as linkedLoanId, ml.account_no as linkedLoanAccountNo, ")
|
||||
.append("sa.savings_account_id, msa.account_no as savingsAccNo, ")
|
||||
.append("c.id as clientId, c.display_name as clientName, ")
|
||||
.append("sa.account_no as accountNo, sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ")
|
||||
@@ -294,7 +295,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
.append("left join m_appuser abu on abu.id = sa.approved_userid ")
|
||||
.append("left join m_appuser avbu on rbu.id = sa.activated_userid ")
|
||||
.append("left join m_appuser cbu on cbu.id = sa.closed_userid ")
|
||||
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ");
|
||||
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ")
|
||||
.append("left join m_loan ml on ml.id = sa.linked_loan_id ");
|
||||
this.schema = buff.toString();
|
||||
}
|
||||
|
||||
@@ -304,6 +306,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final String accountNo = rs.getString("accountNo");
|
||||
final String externalId = rs.getString("externalId");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final Long linkedLoanId = JdbcSupport.getLong(rs, "linkedLoanId");
|
||||
final String linkedLoanAccountNo = rs.getString("linkedLoanAccountNo");
|
||||
final Long savingsAccountId = JdbcSupport.getLong(rs, "savings_account_id");
|
||||
final String savingsAccountNumber = rs.getString("savingsAccNo");
|
||||
final Long clientId = JdbcSupport.getLong(rs, "clientId");
|
||||
@@ -375,10 +379,12 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final String shortProductName = null;
|
||||
final ShareAccountSummaryData summary = new ShareAccountSummaryData(id, accountNo, externalId, productId, productName,
|
||||
shortProductName, status, currency, totalApprovedShares, totalPendingShares, timeline);
|
||||
return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId,
|
||||
clientName, productId, productName, status, timeline, currency, summary, charges, purchasedShares,
|
||||
lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
|
||||
final ShareAccountData shareAccountData = new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId,
|
||||
savingsAccountNumber, clientId, clientName, productId, productName, status, timeline, currency, summary, charges,
|
||||
purchasedShares, lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
|
||||
allowdividendsforinactiveclients);
|
||||
shareAccountData.setLinkedLoan(linkedLoanId, linkedLoanAccountNo);
|
||||
return shareAccountData;
|
||||
|
||||
}
|
||||
|
||||
@@ -461,7 +467,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
StringBuilder buff = new StringBuilder().append(
|
||||
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
|
||||
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
|
||||
.append("saps.amount_paid as amountPaid ");
|
||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
|
||||
|
||||
schema = buff.toString();
|
||||
}
|
||||
@@ -480,8 +486,11 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amount");
|
||||
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeamount");
|
||||
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amountPaid");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||
amount, chargeAmount, amountPaid);
|
||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
||||
}
|
||||
|
||||
public String schema() {
|
||||
|
||||
+7
@@ -22,4 +22,11 @@ public interface ShareAccountSchedularService {
|
||||
|
||||
void postDividend(Long dividendDetailId, Long savingsId);
|
||||
|
||||
/**
|
||||
* Writes off an unclaimed share-dividend liability (see {@code m_share_dividend_lock}) to the
|
||||
* bank's profit — called once a lock's 3-year release_due_date has passed with no member claim
|
||||
* or manager action.
|
||||
*/
|
||||
void forfeitDividendLock(Long lockId);
|
||||
|
||||
}
|
||||
|
||||
+77
@@ -18,8 +18,12 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.shareaccounts.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
@@ -27,14 +31,22 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
|
||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
|
||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
|
||||
|
||||
private final ShareAccountDividendRepository shareAccountDividendRepository;
|
||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -49,4 +61,69 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
||||
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
|
||||
* account up front -- it stays purely a "Dividend Payable" GL liability (see
|
||||
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
|
||||
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
|
||||
* dividend past its 3-year deadline has no savings hold to release and no account balance to
|
||||
* deduct -- the liability is simply written off directly against the bank's profit.
|
||||
*/
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void forfeitDividendLock(final Long lockId) {
|
||||
final Map<String, Object> lock = this.jdbcTemplate.queryForList("""
|
||||
SELECT dividend_detail_id, currency_code, amount, status
|
||||
FROM m_share_dividend_lock WHERE id = ?
|
||||
""", lockId).stream().findFirst().orElseThrow();
|
||||
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||
return;
|
||||
}
|
||||
final String currencyCode = (String) lock.get("currency_code");
|
||||
final BigDecimal amount = (BigDecimal) lock.get("amount");
|
||||
|
||||
postForfeitureJournalEntry(lockId, currencyCode, amount);
|
||||
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
|
||||
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
|
||||
* Reads currency straight off the lock row (set from the share account at creation time) --
|
||||
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
|
||||
* both GL accounts yet, exactly like the declaration-time Reserve entry.
|
||||
*/
|
||||
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
||||
final Long payableDividendsAccountId = this.jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long forfeitedIncomeAccountId = this.jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 description = "Dividend forfeited to profit — share dividend lock #" + lockId;
|
||||
final String insertSql = """
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
||||
submitted_on_date, transaction_date)
|
||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||
""";
|
||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-12
@@ -484,23 +484,25 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
try {
|
||||
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
|
||||
Map<String, Object> changes = this.accountDataSerializer.validateAndClose(jsonCommand, account);
|
||||
if (!changes.isEmpty()) {
|
||||
this.shareAccountRepository.saveAndFlush(account);
|
||||
final String noteText = jsonCommand.stringValueOfParameterNamed("note");
|
||||
if (StringUtils.isNotBlank(noteText)) {
|
||||
final Note note = Note.shareNote(account, noteText);
|
||||
changes.put("note", noteText);
|
||||
this.noteRepository.save(note);
|
||||
}
|
||||
ShareAccountTransaction transaction = (ShareAccountTransaction) changes
|
||||
.get(ShareAccountApiConstants.requestedshares_paramname);
|
||||
transaction = account.getShareAccountTransaction(transaction);
|
||||
// Account status/closedDate are applied regardless of whether there was anything left to
|
||||
// redeem — save and note-capture must not be gated on a transaction actually existing.
|
||||
this.shareAccountRepository.saveAndFlush(account);
|
||||
final String noteText = jsonCommand.stringValueOfParameterNamed("note");
|
||||
if (StringUtils.isNotBlank(noteText)) {
|
||||
final Note note = Note.shareNote(account, noteText);
|
||||
this.noteRepository.save(note);
|
||||
}
|
||||
// Only closures that actually redeemed remaining shares carry a transaction to post GL
|
||||
// entries for — an account already at zero shares has none, and posting a zero-value
|
||||
// transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL.
|
||||
final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname);
|
||||
if (transactionChange instanceof ShareAccountTransaction) {
|
||||
ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange);
|
||||
Set<ShareAccountTransaction> transactions = new HashSet<>();
|
||||
transactions.add(transaction);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||
changes.clear();
|
||||
changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId());
|
||||
|
||||
}
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(jsonCommand.commandId()) //
|
||||
|
||||
+5
-2
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
||||
import org.apache.fineract.commands.domain.CommandSourceRepository;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
||||
@@ -112,8 +113,10 @@ public class ShareAccountsConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
||||
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler) {
|
||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler);
|
||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
|
||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
||||
context, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+9
@@ -42,6 +42,15 @@ public class ShareProductDividentPayOutDetailsRepositoryWrapper {
|
||||
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #save}, but flushes immediately so the generated id is guaranteed to be
|
||||
* populated on the entity before the caller reads it (plain save() can leave a newly
|
||||
* INSERTed entity's identity-generated id unset until the surrounding transaction flushes).
|
||||
*/
|
||||
public void saveAndFlush(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
|
||||
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(shareProductDividendPayOutDetails);
|
||||
}
|
||||
|
||||
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
|
||||
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
|
||||
}
|
||||
|
||||
+7
@@ -115,6 +115,13 @@ public class ShareProductDividendAssembler {
|
||||
numberOfShares += purchasedSharesData.getNumberOfShares();
|
||||
} else {
|
||||
numberOfShares -= purchasedSharesData.getNumberOfShares();
|
||||
// A purchase that hadn't yet cleared the Minimum Active Period is skipped
|
||||
// above (via `continue`) and never added here — but its later full/partial
|
||||
// redemption is never skipped, so without this floor numberOfShares (and
|
||||
// therefore this account's dividend) can go negative.
|
||||
if (numberOfShares < 0) {
|
||||
numberOfShares = 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+114
-1
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMappe
|
||||
import com.google.gson.JsonObject;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -85,6 +86,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
|
||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||
private final CommandSourceRepository commandSourceRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
||||
// accounting module into this constructor just for two int constants.
|
||||
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
|
||||
@@ -152,12 +159,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
final BigDecimal dividendAmount = this.fromApiJsonHelper
|
||||
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
|
||||
|
||||
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
|
||||
|
||||
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
|
||||
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
|
||||
if (dividendPayOutDetails == null) {
|
||||
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
|
||||
}
|
||||
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
|
||||
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
|
||||
|
||||
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
|
||||
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
|
||||
|
||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
|
||||
|
||||
@@ -172,6 +184,68 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see
|
||||
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is
|
||||
* a real reservation of that period's profit. Two declarations covering the same days would
|
||||
* pay members twice for the same period, so any overlap against an existing declaration for
|
||||
* this product is blocked outright.
|
||||
*/
|
||||
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
|
||||
final LocalDate dividendPeriodEndDate) {
|
||||
final Integer overlappingCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_share_product_dividend_pay_out
|
||||
WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ?
|
||||
""", Integer.class, productId, java.sql.Date.valueOf(dividendPeriodEndDate),
|
||||
java.sql.Date.valueOf(dividendPeriodStartDate));
|
||||
if (overlappingCount != null && overlappingCount > 0) {
|
||||
throw new DividentProcessingException("period.overlaps.existing.dividend",
|
||||
"This product already has a dividend declared for a period that overlaps " + dividendPeriodStartDate + " to "
|
||||
+ dividendPeriodEndDate + ". Choose a period that doesn't overlap with an existing declaration.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own
|
||||
* Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just
|
||||
* silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry
|
||||
* pair rather than through the transaction-bound AccountingProcessorHelper (which expects a
|
||||
* loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if
|
||||
* the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity
|
||||
* Mappings yet, so dividend declaration itself is never blocked by missing GL setup.
|
||||
*/
|
||||
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
|
||||
final Long reserveAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (reserveAccountId == null || payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVRES" + dividendPayOutId;
|
||||
final String description = "Reserve for Dividend declaration #" + dividendPayOutId;
|
||||
final String insertSql = """
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
||||
submitted_on_date, transaction_date)
|
||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||
""";
|
||||
// Debit reduces the equity Reserve; credit increases the Dividend Payable liability.
|
||||
jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
|
||||
try {
|
||||
@@ -182,6 +256,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
}
|
||||
dividendPayOutDetails.approveDividendPayout();
|
||||
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
|
||||
makeDividendsClaimable(PayOutDetailId);
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(PayOutDetailId) //
|
||||
.build();
|
||||
@@ -191,6 +266,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account.
|
||||
* Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at
|
||||
* declaration time) until the member actually comes to claim it -- at which point it can be
|
||||
* split between clearing an overdue loan, a savings credit, or cash (see
|
||||
* {@code DividendClaimRequestApiResource}). This just marks each member's share of the
|
||||
* declaration POSTED (their entitlement is locked in, no more recalculation) and opens a
|
||||
* 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather
|
||||
* than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This
|
||||
* is cheap enough (no per-member external command calls, just two bulk statements) to run
|
||||
* synchronously in the approve request itself -- no background job needed.
|
||||
*/
|
||||
private void makeDividendsClaimable(final Long payOutDetailId) {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
||||
final java.sql.Date releaseDueDate = java.sql.Date.valueOf(today.plusYears(3));
|
||||
|
||||
// A linked savings account is no longer required to create the unclaimed-liability
|
||||
// record -- disbursement destination (loan / savings / cash) is chosen when the member
|
||||
// actually claims it, not up front. So every member gets treated identically here, whether
|
||||
// their share account has a linked savings account or not; currency is read straight off
|
||||
// the share account itself.
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_share_dividend_lock
|
||||
(dividend_detail_id, client_id, savings_account_id, currency_code, amount, locked_date, release_due_date,
|
||||
status, created_date, last_modified_date)
|
||||
SELECT sad.id, sa.client_id, sa.savings_account_id, sa.currency_code, sad.amount, ?, ?, 'UNCLAIMED', now(), now()
|
||||
FROM m_share_account_dividend_details sad
|
||||
JOIN m_share_account sa ON sa.id = sad.account_id
|
||||
WHERE sad.dividend_pay_out_id = ? AND sad.status = 100 AND sad.amount > 0
|
||||
""", lockedDate, releaseDueDate, payOutDetailId);
|
||||
|
||||
// A member whose entitlement worked out to zero has nothing to claim; everyone else is now
|
||||
// covered by the unclaimed-liability row above -- both get marked posted.
|
||||
jdbcTemplate.update("UPDATE m_share_account_dividend_details SET status = 300 WHERE dividend_pay_out_id = ? AND status = 100",
|
||||
payOutDetailId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
|
||||
try {
|
||||
|
||||
+2
-2
@@ -117,11 +117,11 @@ public class ShareProductsConfiguration {
|
||||
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
CommandSourceRepository commandSourceRepository) {
|
||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
|
||||
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
||||
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
||||
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository);
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
@@ -688,6 +688,80 @@ fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com
|
||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
||||
|
||||
# ── Customer notification gateways (KYC exception outreach) ──────────────────
|
||||
# Used by the "Log Customer Contact" action on the KYC Exceptions screen: choosing SMS, Email or
|
||||
# WhatsApp transmits a notice to the customer and records the delivery outcome on the contact log.
|
||||
#
|
||||
# Email needs no configuration here — it uses the spring.mail.* SMTP settings above.
|
||||
#
|
||||
# SMS and WhatsApp are vendor-agnostic: the request shape is described by properties so switching
|
||||
# gateway is a config change. `body-template` supports {{destination}}, {{message}}, {{senderId}}
|
||||
# and {{templateId}}; values are escaped for the configured content-type at send time.
|
||||
# Both are DISABLED by default — a channel that is off records the attempt and reports
|
||||
# "not sent, no gateway configured" rather than silently claiming success.
|
||||
#
|
||||
# NOTE: this is NOT the LincPay eKYC endpoint used for login OTP above. That endpoint mints an OTP
|
||||
# of its own and cannot carry an arbitrary message; point these at a real send-message API.
|
||||
#
|
||||
# Example (MSG91 transactional SMS):
|
||||
# fineract.notification.sms.base-url=https://control.msg91.com
|
||||
# fineract.notification.sms.path=/api/v5/flow/
|
||||
# fineract.notification.sms.auth-header-name=authkey
|
||||
# fineract.notification.sms.auth-header-value=<your authkey>
|
||||
# fineract.notification.sms.body-template={"template_id":"{{templateId}}","short_url":"0",\
|
||||
# "recipients":[{"mobiles":"{{destination}}","MESSAGE":"{{message}}"}]}
|
||||
# fineract.notification.sms.reference-json-path=data.messageId
|
||||
# ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL TEST GATEWAY — NOT A REAL SMS PROVIDER. │
|
||||
# │ │
|
||||
# │ These defaults point at scratchpad/mock-sms-gateway.js on localhost so │
|
||||
# │ the outreach flow can be demonstrated end to end. NOTHING REACHES A │
|
||||
# │ PHONE. Every contact-log row records provider=MOCK_SMS_GATEWAY_TEST_ONLY │
|
||||
# │ so a mock send can never be mistaken for a real delivery in the audit │
|
||||
# │ trail. │
|
||||
# │ │
|
||||
# │ BEFORE PRODUCTION: set FINERACT_NOTIFICATION_SMS_* to the real gateway, │
|
||||
# │ or set FINERACT_NOTIFICATION_SMS_ENABLED=false. Do not ship the mock. │
|
||||
# └──────────────────────────────────────────────────────────────────────────┘
|
||||
fineract.notification.sms.enabled=${FINERACT_NOTIFICATION_SMS_ENABLED:true}
|
||||
fineract.notification.sms.provider=${FINERACT_NOTIFICATION_SMS_PROVIDER:MOCK_SMS_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.sms.base-url=${FINERACT_NOTIFICATION_SMS_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.sms.path=${FINERACT_NOTIFICATION_SMS_PATH:/send}
|
||||
fineract.notification.sms.auth-header-name=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.sms.auth-header-value=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.sms.content-type=${FINERACT_NOTIFICATION_SMS_CONTENT_TYPE:application/json}
|
||||
fineract.notification.sms.body-template=${FINERACT_NOTIFICATION_SMS_BODY_TEMPLATE:{"mobileNumber":"{{destination}}","message":"{{message}}","senderId":"{{senderId}}","templateId":"{{templateId}}"\}}
|
||||
# DLT-registered sender ID and template ID — TRAI requires both for transactional SMS in India.
|
||||
fineract.notification.sms.sender-id=${FINERACT_NOTIFICATION_SMS_SENDER_ID:}
|
||||
fineract.notification.sms.template-id=${FINERACT_NOTIFICATION_SMS_TEMPLATE_ID:}
|
||||
fineract.notification.sms.reference-json-path=${FINERACT_NOTIFICATION_SMS_REFERENCE_JSON_PATH:data.messageId}
|
||||
|
||||
# Example (Meta WhatsApp Cloud API):
|
||||
# fineract.notification.whatsapp.base-url=https://graph.facebook.com
|
||||
# fineract.notification.whatsapp.path=/v21.0/<phone-number-id>/messages
|
||||
# fineract.notification.whatsapp.auth-header-value=Bearer <access token>
|
||||
# fineract.notification.whatsapp.body-template={"messaging_product":"whatsapp",\
|
||||
# "to":"{{destination}}","type":"text","text":{"body":"{{message}}"}}
|
||||
# fineract.notification.whatsapp.reference-json-path=messages.0.id
|
||||
# Business-initiated messages outside the 24h service window must use an approved template, so a
|
||||
# KYC notice will normally need the template-shaped body rather than the plain "text" one above.
|
||||
# Also pointed at the LOCAL TEST GATEWAY above — see that banner. Nothing reaches WhatsApp.
|
||||
fineract.notification.whatsapp.enabled=${FINERACT_NOTIFICATION_WHATSAPP_ENABLED:true}
|
||||
fineract.notification.whatsapp.provider=${FINERACT_NOTIFICATION_WHATSAPP_PROVIDER:MOCK_WHATSAPP_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.whatsapp.base-url=${FINERACT_NOTIFICATION_WHATSAPP_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.whatsapp.path=${FINERACT_NOTIFICATION_WHATSAPP_PATH:/send}
|
||||
fineract.notification.whatsapp.auth-header-name=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.whatsapp.auth-header-value=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.whatsapp.content-type=${FINERACT_NOTIFICATION_WHATSAPP_CONTENT_TYPE:application/json}
|
||||
fineract.notification.whatsapp.body-template=${FINERACT_NOTIFICATION_WHATSAPP_BODY_TEMPLATE:{"to":"{{destination}}","message":"{{message}}"\}}
|
||||
fineract.notification.whatsapp.sender-id=${FINERACT_NOTIFICATION_WHATSAPP_SENDER_ID:}
|
||||
fineract.notification.whatsapp.template-id=${FINERACT_NOTIFICATION_WHATSAPP_TEMPLATE_ID:}
|
||||
fineract.notification.whatsapp.reference-json-path=${FINERACT_NOTIFICATION_WHATSAPP_REFERENCE_JSON_PATH:data.messageId}
|
||||
fineract.notification.whatsapp.country-code=${FINERACT_NOTIFICATION_WHATSAPP_COUNTRY_CODE:91}
|
||||
|
||||
# Days the customer is given to complete KYC, quoted in the notice body.
|
||||
fineract.notification.kyc-contact.response-days=${FINERACT_NOTIFICATION_KYC_RESPONSE_DAYS:15}
|
||||
|
||||
# Frontend base URL — used for email links (voting, etc.)
|
||||
cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in}
|
||||
|
||||
|
||||
@@ -491,13 +491,40 @@
|
||||
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0438_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0439_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0444_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0446_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0438_share_account_withdrawal_governance.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0439_create_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0440_seed_share_redemption_payment_type.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0444_add_kyc_exception_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_create_kyc_contact_log.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0446_create_clerk_share_closure_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0447_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0448_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0449_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0450_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0451_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0452_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
|
||||
KYC/AML). Both sets are already applied against real local databases under these exact
|
||||
filenames, so neither is renumbered here -- Liquibase identifies a changeSet by its
|
||||
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
|
||||
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
||||
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0457_seed_forfeit_unclaimed_dividends_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0458_seed_dividend_financial_activity_accounts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0459_speed_up_post_dividends_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0460_create_share_dividend_claim_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0461_rework_dividend_claim_for_gl_only_liability.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0462_dividend_lock_no_savings_account_required.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0456_add_freeze_date_and_amount_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0457_add_unfreeze_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0458_add_no_debit_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0459_seed_aml_alerts_awaiting_review.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0460_create_aml_recurring_review.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0461_add_pan_declaration_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+24
-16
@@ -23,24 +23,32 @@
|
||||
|
||||
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
|
||||
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
|
||||
by "INTEREST PAYABLE" (gl_code 9.2). -->
|
||||
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
|
||||
by "INTEREST PAYABLE" (gl_code 9.2).
|
||||
|
||||
parent_id is resolved by gl_code (not hardcoded) — the "OTHER LIABILITY" row's numeric id
|
||||
is an auto-increment value that depends on each tenant's own insert history, so a
|
||||
hardcoded id (previously 22) only happened to match the `default` tenant and broke FK
|
||||
inserts on any tenant whose chart of accounts was seeded differently (e.g. `saga`), which
|
||||
aborted that tenant's whole Liquibase run — and with it, the entire application startup.
|
||||
runOnChange lets this corrected version safely re-validate on tenants where the old,
|
||||
already-recorded version succeeded: the "gl_code 9.3 doesn't exist yet" precondition below
|
||||
is already false there, so it's skipped rather than re-inserted. -->
|
||||
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account" runOnChange="true">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
|
||||
</not>
|
||||
<and>
|
||||
<not>
|
||||
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
|
||||
</not>
|
||||
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Sponsor DD Payable - SBI"/>
|
||||
<column name="gl_code" value="9.3"/>
|
||||
<column name="parent_id" valueNumeric="22"/>
|
||||
<column name="hierarchy" value="."/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="2"/>
|
||||
<column name="disabled" valueBoolean="false"/>
|
||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
||||
<column name="description" value="Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing."/>
|
||||
</insert>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_account (name, gl_code, parent_id, hierarchy, account_usage, classification_enum, disabled, manual_journal_entries_allowed, description)
|
||||
SELECT 'Sponsor DD Payable - SBI', '9.3', parent.id, '.', 1, 2, FALSE, TRUE,
|
||||
'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.'
|
||||
FROM acc_gl_account parent
|
||||
WHERE parent.gl_code = '9';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
|
||||
|
||||
+20
-13
@@ -32,6 +32,13 @@
|
||||
Threshold numbers marked "illustrative default" mirror the bank's own Rules Parameter
|
||||
screens where a configured value was available (Rules 1, 6, 10, 11, 12, 13); others are
|
||||
reasonable starting points meant to be tuned via the AML rule administration UI.
|
||||
|
||||
order_position is only unique WITHIN 0430's own 221-row insert - it collides with the
|
||||
older, differently-numbered seed rows from 0428_add_client_risk_rules.xml (e.g. both
|
||||
insert a row at order_position 10 and 30), which made every subquery below ambiguous
|
||||
("more than one row returned") and aborted the whole tenant migration. aml_check_status
|
||||
is populated only on 0430's rows (0428's predate that column and are NULL there), so
|
||||
pairing it with order_position uniquely targets the intended 0430 row.
|
||||
-->
|
||||
<changeSet author="fineract" id="0434-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
@@ -42,19 +49,19 @@
|
||||
VALUES
|
||||
('CTR_THRESHOLD_10L', 'Mandatory CTR reporting threshold - single cash transaction of INR 10,00,000 or more', 'ALL', 1000000, 'THRESHOLD', null, null, true),
|
||||
('STRUCTURED_CTR_10L_MONTHLY', 'Connected cash transactions summing to INR 10,00,000 or more within a calendar month (CTR aggregation rule)', 'ALL', 1000000, 'STRUCTURING', 30, null, true),
|
||||
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 LIMIT 1), true),
|
||||
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 LIMIT 1), true),
|
||||
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 LIMIT 1), true),
|
||||
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 LIMIT 1), true),
|
||||
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 LIMIT 1), true),
|
||||
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 LIMIT 1), true),
|
||||
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 LIMIT 1), true),
|
||||
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 LIMIT 1), true),
|
||||
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 LIMIT 1), true),
|
||||
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 LIMIT 1), true),
|
||||
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 LIMIT 1), true),
|
||||
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 LIMIT 1), true),
|
||||
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 LIMIT 1), true);
|
||||
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 AND aml_check_status IS NOT NULL), true),
|
||||
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 AND aml_check_status IS NOT NULL), true),
|
||||
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 AND aml_check_status IS NOT NULL), true),
|
||||
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 AND aml_check_status IS NOT NULL), true),
|
||||
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 AND aml_check_status IS NOT NULL), true),
|
||||
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 AND aml_check_status IS NOT NULL), true),
|
||||
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 AND aml_check_status IS NOT NULL), true),
|
||||
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 AND aml_check_status IS NOT NULL), true),
|
||||
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 AND aml_check_status IS NOT NULL), true),
|
||||
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 AND aml_check_status IS NOT NULL), true),
|
||||
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 AND aml_check_status IS NOT NULL), true),
|
||||
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 AND aml_check_status IS NOT NULL), true),
|
||||
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 AND aml_check_status IS NOT NULL), true);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?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.
|
||||
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Share withdrawal governance: a share account may optionally be linked to a loan (held
|
||||
as margin/security) so a redeem-shares request can be checked against that loan's
|
||||
outstanding balance; a redeem transaction records the Board Resolution Number and Date
|
||||
of Meeting that authorised it.
|
||||
-->
|
||||
<changeSet author="fineract" id="0438-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_share_account" columnName="linked_loan_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_share_account">
|
||||
<column name="linked_loan_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="FK_m_share_account_linked_loan_id"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_share_account" baseColumnNames="linked_loan_id"
|
||||
referencedTableName="m_loan" referencedColumnNames="id"
|
||||
constraintName="FK_m_share_account_linked_loan_id" />
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_share_account_transactions" columnName="resolution_number"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_share_account_transactions">
|
||||
<column name="resolution_number" type="VARCHAR(100)"/>
|
||||
<column name="meeting_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?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.
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Maker-checker for Share Account withdrawal (redeem shares) requests, submitted only from
|
||||
the Clerk Dashboard — mirrors m_clerk_withdrawal_request (cash withdrawal), but records the
|
||||
Board Resolution Number / Date of Meeting a share withdrawal is governed by, and links to a
|
||||
Share Account instead of a Savings/Current account.
|
||||
-->
|
||||
<changeSet id="0439_create_clerk_share_withdrawal_request" author="CBS">
|
||||
<createTable tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="share_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_no" type="VARCHAR(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(255)"/>
|
||||
<column name="requested_shares" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="requested_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution_number" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="TEXT"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="clerk_id" type="BIGINT"/>
|
||||
<column name="clerk_name" type="VARCHAR(255)"/>
|
||||
<column name="clerk_username" type="VARCHAR(100)"/>
|
||||
<column name="manager_id" type="BIGINT"/>
|
||||
<column name="manager_name" type="VARCHAR(255)"/>
|
||||
<column name="manager_username" type="VARCHAR(100)"/>
|
||||
<column name="rejection_reason" type="TEXT"/>
|
||||
<column name="share_transaction_id" type="VARCHAR(100)"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_clerk_id">
|
||||
<column name="clerk_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_submitted_at">
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_share_account_id">
|
||||
<column name="share_account_id"/>
|
||||
</createIndex>
|
||||
</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